Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

### Fixed

- Bound `!usage` ccusage subprocess lifetime so a hung usage query is terminated and reported instead of keeping the request pending.
- Prevent mixed reaction replies from narrating the bot's internal choice to react while preserving natural reaction-plus-text responses.
- Run Discord-initiated Claude login in a pseudo-terminal so the CLI accepts submitted OAuth codes.
- Isolate saved history and summaries by Discord channel ID in dedicated storage namespaces so same-named channels do not share automatic context.
Expand Down
89 changes: 76 additions & 13 deletions src/discord/commands/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,81 @@ const EMPTY_TOTALS: UsageTotals = {
totalCost: 0,
};

const DEFAULT_USAGE_TIMEOUT_MS = 120_000;
const FORCE_KILL_GRACE_MS = 1_000;

export interface UsageCommandOptions {
command?: string;
timeoutMs?: number;
}

export function runUsageCommand(
args: string[],
options: UsageCommandOptions = {},
): Promise<{ stdout: string; stderr: string }> {
const command = options.command || "npx";
const timeoutMs = options.timeoutMs ?? DEFAULT_USAGE_TIMEOUT_MS;

return new Promise((resolve, reject) => {
const proc = spawn(command, args, {
env: { ...process.env },
});
let stdout = "";
let stderr = "";
let settled = false;
let forceKillTimeout: NodeJS.Timeout | undefined;

const clearForceKillTimeout = (): void => {
if (forceKillTimeout) {
clearTimeout(forceKillTimeout);
forceKillTimeout = undefined;
}
};

const timeout = setTimeout(() => {
if (settled) return;
settled = true;
proc.kill();
forceKillTimeout = setTimeout(() => {
forceKillTimeout = undefined;
if (proc.exitCode !== null || proc.signalCode !== null) return;
try {
proc.kill("SIGKILL");
} catch {
// Process termination is best-effort.
}
}, FORCE_KILL_GRACE_MS);
forceKillTimeout.unref();
reject(new Error(`ccusage timed out after ${timeoutMs} milliseconds`));
}, timeoutMs);

proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("error", (error) => {
clearTimeout(timeout);
clearForceKillTimeout();
if (settled) return;
settled = true;
reject(error);
});
proc.on("close", (code) => {
clearTimeout(timeout);
clearForceKillTimeout();
if (settled) return;
settled = true;
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(stderr || `ccusage exited with code ${code}`));
}
});
});
}

function formatCompactUtcDate(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, "");
}
Expand Down Expand Up @@ -289,19 +364,7 @@ export async function handleUsage(msg: Message): Promise<void> {
await (msg.channel as TextChannel).sendTyping();

try {
const { stdout } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
const proc = spawn("npx", ccArgs, {
env: { ...process.env },
shell: true,
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (d) => (stdout += d.toString()));
proc.stderr.on("data", (d) => (stderr += d.toString()));
proc.on("close", (code) =>
code === 0 ? resolve({ stdout, stderr }) : reject(new Error(stderr || `exit ${code}`)),
);
});
const { stdout } = await runUsageCommand(ccArgs);

const data = JSON.parse(stdout);
const embeds: EmbedBuilder[] = [];
Expand Down
11 changes: 11 additions & 0 deletions tests/usagePeriods.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@ import {
buildCurrentPeriodUsageEmbed,
createUsageRequest,
getCurrentUsagePeriod,
runUsageCommand,
} from "../build/discord/commands/usage.js";

const fixedNow = new Date("2026-08-05T14:30:45.000Z");

test("times out a usage subprocess that does not exit", async () => {
await assert.rejects(
() => runUsageCommand(
["-e", "setInterval(() => {}, 1000)"],
{ command: process.execPath, timeoutMs: 50 },
),
/ccusage timed out after 50 milliseconds/,
);
});

test("calculates the current UTC week from Monday through now", () => {
assert.deepEqual(getCurrentUsagePeriod("week", fixedNow), {
since: "20260803",
Expand Down