@scrylog/cli 0.1.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -7
- package/package.json +13 -10
- package/src/api/docs/sessions.ts +3 -2
- package/src/api/docs/system.ts +3 -0
- package/src/api/docs/usage.ts +2 -2
- package/src/api/routes/sessions.ts +6 -2
- package/src/api/routes/system.ts +4 -1
- package/src/api/routes/webhooks/claude.ts +9 -21
- package/src/api/routes/webhooks/opencode.ts +6 -1
- package/src/api/routes.ts +1 -1
- package/src/api/schemas/common.ts +8 -0
- package/src/api/schemas/sessions.ts +15 -1
- package/src/api/schemas/usage.ts +1 -0
- package/src/cli/commands.ts +170 -26
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +11 -1
- package/src/daemon/collectors/claude/discovery.ts +2 -23
- package/src/daemon/collectors/codex/collector.ts +3 -3
- package/src/daemon/collectors/git.ts +60 -2
- package/src/daemon/collectors/herdr/collector.ts +128 -0
- package/src/daemon/collectors/herdr/index.ts +2 -0
- package/src/daemon/collectors/herdr/normalization.ts +111 -0
- package/src/daemon/collectors/opencode/collector.ts +4 -2
- package/src/daemon/collectors/opencode-db.ts +17 -5
- package/src/daemon/collectors/tmux/collector.ts +21 -1
- package/src/daemon/collectors/tmux/enrichment.ts +1 -0
- package/src/daemon/collectors/usage.ts +115 -49
- package/src/daemon/index.ts +2 -0
- package/src/daemon/store.ts +109 -14
- package/src/postinstall.cjs +11 -0
- package/src/shared/config.ts +33 -0
- package/src/shared/session-exclusions.ts +5 -0
- package/src/shared/session-view.ts +67 -6
- package/src/shared/terminal-capabilities.ts +130 -0
- package/src/shared/types.ts +33 -2
- package/src/shared/version.ts +44 -0
- package/src/tui/App.tsx +240 -0
- package/src/tui/assets/providers/SOURCES.md +21 -0
- package/src/tui/assets/providers/claude-terminal.png +0 -0
- package/src/tui/assets/providers/claude-terminal.svg +62 -0
- package/src/tui/assets/providers/claude.png +0 -0
- package/src/tui/assets/providers/copilot.png +0 -0
- package/src/tui/assets/providers/copilot.svg +44 -0
- package/src/tui/assets/providers/openai-terminal.png +0 -0
- package/src/tui/assets/providers/openai-terminal.svg +62 -0
- package/src/tui/assets/providers/openai.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.svg +62 -0
- package/src/tui/assets/providers/opencode.png +0 -0
- package/src/tui/assets/providers/shell.png +0 -0
- package/src/tui/assets/providers/shell.svg +23 -0
- package/src/tui/assets/providers/unknown.png +0 -0
- package/src/tui/assets/providers/unknown.svg +23 -0
- package/src/tui/client.ts +22 -8
- package/src/tui/components/BindingsModal.tsx +82 -0
- package/src/tui/components/DashboardHeader.tsx +256 -0
- package/src/tui/components/KeyBar.tsx +67 -0
- package/src/tui/components/SessionDetailModal.tsx +169 -0
- package/src/tui/components/SessionsView.tsx +184 -0
- package/src/tui/components/TabBar.tsx +40 -0
- package/src/tui/components/UsageSummaryPane.tsx +272 -0
- package/src/tui/components/UsageView.tsx +242 -0
- package/src/tui/components/tool-icons.ts +24 -0
- package/src/tui/index.tsx +106 -0
- package/src/tui/keybindings.ts +49 -0
- package/src/tui/model.ts +295 -0
- package/src/tui/state.ts +77 -0
- package/src/tui/theme.ts +110 -0
- package/src/usage/README.md +3 -3
- package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
- package/src/usage/clients/anthropic-web-usage.ts +133 -49
- package/src/usage/clients/claude-cli-probe.ts +458 -132
- package/src/usage/clients/codex-app-server.ts +354 -0
- package/src/usage/clients/codex-oauth-usage.ts +143 -16
- package/src/usage/clients/opencode-go-api.ts +100 -0
- package/src/usage/clients/opencode-go-usage.ts +163 -27
- package/src/usage/errors.ts +33 -0
- package/src/usage/index.ts +2 -1
- package/src/usage/local/opencode-go-local.ts +194 -0
- package/src/usage/mappers.ts +24 -15
- package/src/usage/planner.ts +89 -25
- package/src/usage/providers/claude.ts +203 -83
- package/src/usage/providers/codex.ts +92 -11
- package/src/usage/providers/opencode-go.ts +94 -16
- package/src/usage/resolvers/browser-cookies.ts +679 -69
- package/src/usage/resolvers/claude-auth.ts +150 -41
- package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
- package/src/usage/resolvers/claude-web.ts +72 -6
- package/src/usage/resolvers/codex-auth.ts +74 -17
- package/src/usage/resolvers/keychain-coordinator.ts +397 -0
- package/src/usage/resolvers/opencode-go-web.ts +47 -6
- package/src/usage/types.ts +37 -2
- package/src/usage/web-session-cache.ts +37 -0
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.ts +0 -920
- package/src/tui/index.ts +0 -193
|
@@ -1,187 +1,513 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import type { UsageClient } from "../client";
|
|
6
|
+
import { classifyUsageError, UsageError } from "../errors";
|
|
3
7
|
import type { ClaudeUsageSnapshot, RateWindowLegacy } from "../types";
|
|
4
8
|
|
|
5
|
-
export
|
|
9
|
+
export type ClaudeProbeStrategy = "script-darwin" | "script-linux" | "expect" | "plain";
|
|
10
|
+
|
|
11
|
+
export class TimeoutError extends UsageError {
|
|
6
12
|
constructor(message: string) {
|
|
7
|
-
super(message);
|
|
13
|
+
super("transient", message);
|
|
8
14
|
this.name = "TimeoutError";
|
|
9
15
|
}
|
|
10
16
|
}
|
|
11
17
|
|
|
18
|
+
interface ProbeOptions {
|
|
19
|
+
initialTimeoutMs?: number;
|
|
20
|
+
retryTimeoutMs?: number;
|
|
21
|
+
killGraceMs?: number;
|
|
22
|
+
maxOutputBytes?: number;
|
|
23
|
+
cooldownMs?: number;
|
|
24
|
+
strategies?: readonly ClaudeProbeStrategy[];
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
function parseResetDate(text: string): Date | null {
|
|
13
|
-
// Patterns:
|
|
14
|
-
// "Resets at 8:00PM PDT"
|
|
15
|
-
// "Resets at 8:00 PM PDT"
|
|
16
|
-
// "Resets at Apr 28 at 8:00PM PDT"
|
|
17
|
-
// "Resets at Apr 28 at 8:00 PM PDT"
|
|
18
28
|
const match = text.match(/Resets at (.+?)(?:\s*\(([^)]+)\))?$/i);
|
|
19
29
|
if (!match) return null;
|
|
20
30
|
const datePart = match[1]!.trim();
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// Fallback: try appending current year if no year present
|
|
26
|
-
const withYear = `${datePart} ${new Date().getFullYear()}`;
|
|
27
|
-
const d2 = new Date(withYear);
|
|
28
|
-
if (!Number.isNaN(d2.getTime())) return d2;
|
|
29
|
-
} catch {
|
|
30
|
-
// ignore
|
|
31
|
-
}
|
|
32
|
-
return null;
|
|
31
|
+
const direct = new Date(datePart);
|
|
32
|
+
if (!Number.isNaN(direct.getTime())) return direct;
|
|
33
|
+
const withYear = new Date(`${datePart} ${new Date().getFullYear()}`);
|
|
34
|
+
return Number.isNaN(withYear.getTime()) ? null : withYear;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
function parsePercent(line: string): number | null {
|
|
36
38
|
const match = line.match(/(\d{1,3}(?:\.\d+)?)\s*%/);
|
|
37
39
|
if (!match) return null;
|
|
38
|
-
const
|
|
39
|
-
return Number.isNaN(
|
|
40
|
+
const value = Number.parseFloat(match[1]!);
|
|
41
|
+
return Number.isNaN(value) ? null : value;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
function toRateWindow(usedPercent: number, resetText: string | null, windowMinutes: number): RateWindowLegacy {
|
|
43
|
-
const resetsAt = resetText ? parseResetDate(resetText) : null;
|
|
44
45
|
return {
|
|
45
46
|
usedPercent: Math.max(0, Math.min(100, usedPercent)),
|
|
46
47
|
windowMinutes,
|
|
47
|
-
resetsAt,
|
|
48
|
+
resetsAt: resetText ? parseResetDate(resetText) : null,
|
|
48
49
|
resetDescription: resetText,
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
const SCRIPT_FAILURE_PATTERNS: RegExp[] = [
|
|
54
|
+
/tcgetattr/i,
|
|
55
|
+
/Operation not supported on socket/i,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function stripAnsi(text: string): string {
|
|
59
|
+
// eslint-disable-next-line no-control-regex
|
|
60
|
+
return text.replace(/\u001B\[[0-9;?]*[a-zA-Z]/g, "").replace(/\u001B\][^\u0007]*\u0007/g, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function looksLikeLauncherFailure(output: string, maxOutputBytes: number): boolean {
|
|
64
|
+
if (!output.trim()) return false;
|
|
65
|
+
if (output.length > Math.min(maxOutputBytes, 512)) return false;
|
|
66
|
+
const plain = stripAnsi(output);
|
|
67
|
+
return SCRIPT_FAILURE_PATTERNS.some((pattern) => pattern.test(plain));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function defaultWaitingStrategies(): ClaudeProbeStrategy[] {
|
|
71
|
+
if (process.platform === "darwin") return ["script-darwin", "expect", "plain"];
|
|
72
|
+
if (process.platform === "linux") {
|
|
73
|
+
const strategies: ClaudeProbeStrategy[] = [];
|
|
74
|
+
if (existsSync("/usr/bin/script")) strategies.push("script-linux");
|
|
75
|
+
if (existsSync("/usr/bin/expect")) strategies.push("expect");
|
|
76
|
+
strategies.push("plain");
|
|
77
|
+
return strategies;
|
|
78
|
+
}
|
|
79
|
+
return ["plain"];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildExpectScript(binaryPath: string, timeoutMs: number): string {
|
|
83
|
+
const seconds = Math.max(5, Math.ceil(timeoutMs / 1000));
|
|
84
|
+
const usageAt = Math.min(4000, Math.max(2500, seconds * 250));
|
|
85
|
+
const statusAt = usageAt + 2000;
|
|
86
|
+
const exitAt = Math.max(statusAt + 1000, timeoutMs - 1000);
|
|
87
|
+
const escaped = binaryPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
88
|
+
return `set timeout ${seconds}
|
|
89
|
+
spawn "${escaped}"
|
|
90
|
+
after ${usageAt} { send "/usage\\r" }
|
|
91
|
+
after ${statusAt} { send "/status\\r" }
|
|
92
|
+
after ${exitAt} { exit 0 }
|
|
93
|
+
expect eof
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Runs one launcher strategy to capture stdout/stderr, killing the process on
|
|
99
|
+
* timeout. Resolves with captured text and nothing else: a launcher that
|
|
100
|
+
* produces no bytes or only failure signatures is retried by the caller.
|
|
101
|
+
*/
|
|
102
|
+
function spawnStrategy( strategy: ClaudeProbeStrategy,
|
|
103
|
+
binaryPath: string,
|
|
104
|
+
timeoutMs: number,
|
|
105
|
+
signal?: AbortSignal,
|
|
106
|
+
maxOutputBytes = 1024 * 1024,
|
|
107
|
+
killGraceMs = 500,
|
|
108
|
+
): Promise<string> {
|
|
109
|
+
const useScript = process.platform === "darwin";
|
|
110
|
+
let command = binaryPath;
|
|
111
|
+
let args: string[] = [];
|
|
112
|
+
let stdin: string | null = null;
|
|
113
|
+
let expectDir: string | null = null;
|
|
114
|
+
|
|
115
|
+
if (strategy === "script-darwin") {
|
|
116
|
+
command = "script";
|
|
117
|
+
args = ["-q", "/dev/null", binaryPath];
|
|
118
|
+
} else if (strategy === "script-linux") {
|
|
119
|
+
command = "script";
|
|
120
|
+
args = ["-q", "-c", binaryPath, "/dev/null"];
|
|
121
|
+
} else if (strategy === "expect") {
|
|
122
|
+
expectDir = mkdtempSync(join(tmpdir(), "scrylog-expect-"));
|
|
123
|
+
const scriptPath = join(expectDir, "probe.exp");
|
|
124
|
+
writeFileSync(scriptPath, buildExpectScript(binaryPath, timeoutMs), { mode: 0o600 });
|
|
125
|
+
command = "expect";
|
|
126
|
+
args = [scriptPath];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cleanupDir = (path: string | null): void => {
|
|
130
|
+
if (!path) return;
|
|
131
|
+
try {
|
|
132
|
+
rmSync(path, { recursive: true, force: true });
|
|
133
|
+
} catch {
|
|
134
|
+
// Bun can throw EFAULT when rmSync races with closeNotify teardown of
|
|
135
|
+
// the expect child; the temp dir is per-run and below tmpdir(), so a
|
|
136
|
+
// missed unlink is harmless and must not kill the daemon.
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
53
140
|
return new Promise((resolve, reject) => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const child = useScript
|
|
60
|
-
? spawn("script", ["-q", "/dev/null", binaryPath], {
|
|
61
|
-
env: process.env,
|
|
62
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
63
|
-
})
|
|
64
|
-
: spawn(binaryPath, [], {
|
|
65
|
-
env: process.env,
|
|
66
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const timer = setTimeout(() => {
|
|
70
|
-
killed = true;
|
|
71
|
-
child.kill("SIGTERM");
|
|
72
|
-
reject(new TimeoutError(`CLI probe timed out after ${timeoutMs}ms`));
|
|
73
|
-
}, timeoutMs);
|
|
141
|
+
if (signal?.aborted) {
|
|
142
|
+
cleanupDir(expectDir);
|
|
143
|
+
reject(new UsageError("cancelled", "Claude CLI probe cancelled"));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
74
146
|
|
|
75
|
-
child
|
|
76
|
-
|
|
77
|
-
|
|
147
|
+
let child: ChildProcessWithoutNullStreams;
|
|
148
|
+
try {
|
|
149
|
+
child = spawn(command, args, { env: process.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
cleanupDir(expectDir);
|
|
152
|
+
reject(new UsageError("transient", `Could not launch Claude CLI: ${error instanceof Error ? error.message : String(error)}`));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const chunks: Buffer[] = [];
|
|
156
|
+
let outputBytes = 0;
|
|
157
|
+
let terminalError: Error | null = null;
|
|
158
|
+
let terminating = false;
|
|
159
|
+
let settled = false;
|
|
160
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
78
161
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
162
|
+
const commandTimers: Array<ReturnType<typeof setTimeout>> = [];
|
|
163
|
+
const clearTimers = () => {
|
|
164
|
+
clearTimeout(timeoutTimer);
|
|
165
|
+
for (const timer of commandTimers) clearTimeout(timer);
|
|
166
|
+
if (killTimer) clearTimeout(killTimer);
|
|
167
|
+
};
|
|
168
|
+
const terminate = (error: Error) => {
|
|
169
|
+
if (terminating || settled) return;
|
|
170
|
+
terminating = true;
|
|
171
|
+
terminalError = error;
|
|
172
|
+
child.stdin.end();
|
|
173
|
+
child.kill("SIGTERM");
|
|
174
|
+
killTimer = setTimeout(() => child.kill("SIGKILL"), killGraceMs);
|
|
175
|
+
};
|
|
176
|
+
const onAbort = () => terminate(new UsageError("cancelled", "Claude CLI probe cancelled"));
|
|
177
|
+
const timeoutTimer = setTimeout(
|
|
178
|
+
() => terminate(new TimeoutError(`Claude CLI probe (${strategy}) timed out after ${timeoutMs}ms`)),
|
|
179
|
+
timeoutMs,
|
|
180
|
+
);
|
|
82
181
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
182
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
183
|
+
const capture = (data: Buffer) => {
|
|
184
|
+
if (terminating) return;
|
|
185
|
+
outputBytes += data.byteLength;
|
|
186
|
+
if (outputBytes > maxOutputBytes) {
|
|
187
|
+
terminate(new UsageError("invalid-response", `Claude CLI output exceeded ${maxOutputBytes} bytes`));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
chunks.push(Buffer.from(data));
|
|
191
|
+
};
|
|
192
|
+
child.stdout.on("data", capture);
|
|
193
|
+
child.stderr.on("data", capture);
|
|
194
|
+
child.once("error", (error) => {
|
|
195
|
+
if (settled) return;
|
|
196
|
+
settled = true;
|
|
197
|
+
clearTimers();
|
|
198
|
+
cleanupDir(expectDir);
|
|
199
|
+
signal?.removeEventListener("abort", onAbort);
|
|
200
|
+
reject(new UsageError("transient", `Could not launch Claude CLI (${strategy}): ${error.message}`));
|
|
86
201
|
});
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
202
|
+
child.once("close", () => {
|
|
203
|
+
if (settled) return;
|
|
204
|
+
settled = true;
|
|
205
|
+
clearTimers();
|
|
206
|
+
cleanupDir(expectDir);
|
|
207
|
+
signal?.removeEventListener("abort", onAbort);
|
|
208
|
+
if (terminalError) reject(terminalError);
|
|
209
|
+
else resolve(Buffer.concat(chunks).toString("utf8"));
|
|
91
210
|
});
|
|
92
211
|
|
|
93
|
-
|
|
212
|
+
if (stdin) {
|
|
213
|
+
child.stdin.write(stdin);
|
|
214
|
+
child.stdin.end();
|
|
215
|
+
commandTimers.push(setTimeout(() => child.stdin.end(), 500));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
94
218
|
child.stdin.write("/usage\n");
|
|
95
|
-
setTimeout(() => {
|
|
219
|
+
commandTimers.push(setTimeout(() => {
|
|
220
|
+
if (terminating || child.stdin.destroyed) return;
|
|
96
221
|
child.stdin.write("/status\n");
|
|
97
|
-
setTimeout(() =>
|
|
98
|
-
|
|
99
|
-
}, 500);
|
|
100
|
-
}, 500);
|
|
222
|
+
commandTimers.push(setTimeout(() => child.stdin.end(), 500));
|
|
223
|
+
}, 500));
|
|
101
224
|
});
|
|
102
225
|
}
|
|
103
226
|
|
|
227
|
+
export function probeClaudeCli(
|
|
228
|
+
binaryPath: string,
|
|
229
|
+
timeoutMs: number,
|
|
230
|
+
signal?: AbortSignal,
|
|
231
|
+
maxOutputBytes = 1024 * 1024,
|
|
232
|
+
killGraceMs = 500,
|
|
233
|
+
strategies: readonly ClaudeProbeStrategy[] = defaultWaitingStrategies(),
|
|
234
|
+
spawnOne: typeof spawnStrategy = spawnStrategy,
|
|
235
|
+
): Promise<string> {
|
|
236
|
+
const deadline = Date.now() + timeoutMs;
|
|
237
|
+
return (async () => {
|
|
238
|
+
let lastFailure: Error | null = null;
|
|
239
|
+
for (const strategy of strategies) {
|
|
240
|
+
const remaining = deadline - Date.now();
|
|
241
|
+
if (remaining < 2_500) break;
|
|
242
|
+
try {
|
|
243
|
+
const output = await spawnOne(
|
|
244
|
+
strategy,
|
|
245
|
+
binaryPath,
|
|
246
|
+
remaining,
|
|
247
|
+
signal,
|
|
248
|
+
maxOutputBytes,
|
|
249
|
+
killGraceMs,
|
|
250
|
+
);
|
|
251
|
+
if (output.trim() && !looksLikeLauncherFailure(output, maxOutputBytes)) return output;
|
|
252
|
+
// The launcher itself failed (for example `script` without a TTY on
|
|
253
|
+
// socket stdin) — fall through to the next strategy.
|
|
254
|
+
} catch (catchError) {
|
|
255
|
+
if (classifyUsageError(catchError).kind === "cancelled") throw catchError;
|
|
256
|
+
lastFailure = catchError instanceof Error ? catchError : new Error(String(catchError));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
throw new UsageError(
|
|
260
|
+
"transient",
|
|
261
|
+
`Could not launch Claude CLI in a usable terminal (tried: ${strategies.join(" → ")}; last: ${lastFailure?.message ?? "no output"})`,
|
|
262
|
+
);
|
|
263
|
+
})();
|
|
264
|
+
}
|
|
265
|
+
|
|
104
266
|
export class ClaudeCliProbeClient implements UsageClient<string, ClaudeUsageSnapshot> {
|
|
105
|
-
|
|
267
|
+
private readonly inFlight = new Map<string, Promise<ClaudeUsageSnapshot>>();
|
|
268
|
+
private readonly cooldownUntil = new Map<string, number>();
|
|
269
|
+
|
|
270
|
+
constructor(
|
|
271
|
+
private readonly options: ProbeOptions = {},
|
|
272
|
+
private readonly probe: (
|
|
273
|
+
binaryPath: string,
|
|
274
|
+
timeoutMs: number,
|
|
275
|
+
signal?: AbortSignal,
|
|
276
|
+
maxOutputBytes?: number,
|
|
277
|
+
killGraceMs?: number,
|
|
278
|
+
strategies?: readonly ClaudeProbeStrategy[],
|
|
279
|
+
) => Promise<string> = probeClaudeCli,
|
|
280
|
+
) {}
|
|
281
|
+
|
|
282
|
+
fetch(binaryPath: string, options?: { signal?: AbortSignal }): Promise<ClaudeUsageSnapshot> {
|
|
283
|
+
const existing = this.inFlight.get(binaryPath);
|
|
284
|
+
if (existing) return this.waitForShared(existing, options?.signal);
|
|
285
|
+
if ((this.cooldownUntil.get(binaryPath) ?? 0) > Date.now()) {
|
|
286
|
+
return Promise.reject(new UsageError("transient", "Claude CLI recovery is cooling down after a failed attempt"));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const attempt = this.fetchOnce(binaryPath, options?.signal)
|
|
290
|
+
.catch((error) => {
|
|
291
|
+
// A cancelled poll is not a failed attempt: the next poll should retry
|
|
292
|
+
// immediately instead of being penalized by the failure cooldown.
|
|
293
|
+
if (classifyUsageError(error).kind !== "cancelled") {
|
|
294
|
+
this.cooldownUntil.set(binaryPath, Date.now() + (this.options.cooldownMs ?? 30_000));
|
|
295
|
+
}
|
|
296
|
+
throw error;
|
|
297
|
+
})
|
|
298
|
+
.finally(() => this.inFlight.delete(binaryPath));
|
|
299
|
+
this.inFlight.set(binaryPath, attempt);
|
|
300
|
+
return attempt;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async fetchOnce(binaryPath: string, signal?: AbortSignal): Promise<ClaudeUsageSnapshot> {
|
|
106
304
|
let output: string;
|
|
107
305
|
try {
|
|
108
|
-
output = await
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
306
|
+
output = await this.probe(
|
|
307
|
+
binaryPath,
|
|
308
|
+
this.options.initialTimeoutMs ?? 10_000,
|
|
309
|
+
signal,
|
|
310
|
+
this.options.maxOutputBytes,
|
|
311
|
+
this.options.killGraceMs,
|
|
312
|
+
this.options.strategies,
|
|
313
|
+
);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (!(error instanceof TimeoutError) || signal?.aborted) throw error;
|
|
316
|
+
output = await this.probe(
|
|
317
|
+
binaryPath,
|
|
318
|
+
this.options.retryTimeoutMs ?? 24_000,
|
|
319
|
+
signal,
|
|
320
|
+
this.options.maxOutputBytes,
|
|
321
|
+
this.options.killGraceMs,
|
|
322
|
+
this.options.strategies,
|
|
323
|
+
);
|
|
116
324
|
}
|
|
325
|
+
return parseClaudeCliOutput(output);
|
|
326
|
+
}
|
|
117
327
|
|
|
118
|
-
|
|
328
|
+
private waitForShared(promise: Promise<ClaudeUsageSnapshot>, signal?: AbortSignal): Promise<ClaudeUsageSnapshot> {
|
|
329
|
+
if (!signal) return promise;
|
|
330
|
+
if (signal.aborted) return Promise.reject(new UsageError("cancelled", "Claude CLI probe cancelled"));
|
|
331
|
+
return new Promise((resolve, reject) => {
|
|
332
|
+
const abort = () => reject(new UsageError("cancelled", "Claude CLI probe cancelled"));
|
|
333
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
334
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
119
338
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
339
|
+
export function parseClaudeCliOutput(output: string): ClaudeUsageSnapshot {
|
|
340
|
+
const lines = output.split(/\r?\n/);
|
|
341
|
+
if (claudeCliOutputIndicatesExpiredSession(output)) {
|
|
342
|
+
throw new UsageError("invalid-credentials", "Claude CLI session expired — run \"claude\" to re-login");
|
|
343
|
+
}
|
|
344
|
+
let primary: RateWindowLegacy | null = null;
|
|
345
|
+
let secondary: RateWindowLegacy | null = null;
|
|
346
|
+
let opus: RateWindowLegacy | null = null;
|
|
347
|
+
let accountEmail: string | null = null;
|
|
348
|
+
let accountOrganization: string | null = null;
|
|
349
|
+
const accountOrganizationId: string | null = null;
|
|
350
|
+
let loginMethod: string | null = null;
|
|
126
351
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
352
|
+
for (let i = 0; i < lines.length; i++) {
|
|
353
|
+
const line = lines[i]!;
|
|
354
|
+
const lower = line.toLowerCase();
|
|
355
|
+
const identity = line.match(/(account_email|account_org|login_method)[:=]\s*(.+)/i);
|
|
356
|
+
if (identity?.[1].toLowerCase() === "account_email") accountEmail = identity[2]!.trim();
|
|
357
|
+
if (identity?.[1].toLowerCase() === "account_org") accountOrganization = identity[2]!.trim();
|
|
358
|
+
if (identity?.[1].toLowerCase() === "login_method") loginMethod = identity[2]!.trim();
|
|
130
359
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (lower.includes("
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
360
|
+
const percent = parsePercent(line);
|
|
361
|
+
const resetLine = lines[i + 1] || "";
|
|
362
|
+
const resetText = resetLine.includes("Resets") ? resetLine.trim() : null;
|
|
363
|
+
if (lower.includes("current session") && percent !== null) {
|
|
364
|
+
primary = toRateWindow(100 - percent, resetText, 300);
|
|
365
|
+
}
|
|
366
|
+
if (lower.includes("current week") && percent !== null) {
|
|
367
|
+
const window = toRateWindow(100 - percent, resetText, 10080);
|
|
368
|
+
if (lower.includes("opus") || lower.includes("sonnet only")) opus = window;
|
|
369
|
+
else secondary = window;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
143
372
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
373
|
+
if (!primary) {
|
|
374
|
+
const modern = parseModernClaudeUsageOutput(lines, output);
|
|
375
|
+
if (modern) return modern;
|
|
376
|
+
if (outputIndicatesNoPlanUsage(output)) {
|
|
377
|
+
throw new UsageError(
|
|
378
|
+
"invalid-response",
|
|
379
|
+
"Claude CLI output contains no plan usage windows (plan may not expose usage — e.g. API Usage Billing)",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
throw new UsageError(
|
|
383
|
+
"invalid-response",
|
|
384
|
+
`Could not parse primary usage window from Claude CLI output (${describeUnparseableOutput(output)})`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
source: "cli",
|
|
389
|
+
primary,
|
|
390
|
+
secondary,
|
|
391
|
+
opus,
|
|
392
|
+
extraRateWindows: [],
|
|
393
|
+
providerCost: null,
|
|
394
|
+
updatedAt: new Date(),
|
|
395
|
+
accountEmail,
|
|
396
|
+
accountOrganization,
|
|
397
|
+
accountOrganizationId,
|
|
398
|
+
loginMethod,
|
|
399
|
+
subscriptionType: null,
|
|
400
|
+
rateLimitTier: null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
152
403
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
404
|
+
/**
|
|
405
|
+
* Scanner for current Claude Code `/usage` renderings (v2.6.x "Usage" tabs and
|
|
406
|
+
* later). The legacy "Current session: NN%" block is gone; the panel renders
|
|
407
|
+
* `NN%`-bearing rows whose labels mention session/week tokens elsewhere on the
|
|
408
|
+
* same line or its immediate neighbors. Percentage values are remaining
|
|
409
|
+
* (legacy conversion applies when the row reads as "remaining").
|
|
410
|
+
*/
|
|
411
|
+
export function parseModernClaudeUsageOutput(lines: string[], output: string): ClaudeUsageSnapshot | null {
|
|
412
|
+
const plain = lines.map((line) => stripAnsi(line));
|
|
413
|
+
const collapsed = plain.join("\n").replace(/[ ]+/g, " ").toLowerCase();
|
|
414
|
+
if (!/\b\d{1,3}(?:\.\d+)?\s*%/.test(collapsed)) return null;
|
|
415
|
+
|
|
416
|
+
interface WindowHit {
|
|
417
|
+
percent: number;
|
|
418
|
+
resetText: string | null;
|
|
419
|
+
windowMinutes: number;
|
|
420
|
+
kind: "primary" | "secondary" | "opus";
|
|
421
|
+
}
|
|
422
|
+
const hits: WindowHit[] = [];
|
|
423
|
+
for (let i = 0; i < plain.length; i++) {
|
|
424
|
+
const raw = plain[i]!;
|
|
425
|
+
const lower = raw.toLowerCase();
|
|
426
|
+
const percent = parsePercent(raw);
|
|
427
|
+
if (percent === null) continue; // Consider a bounded text window around the percent occurrence: the same
|
|
428
|
+
// line and the immediately previous line carry the window label (the
|
|
429
|
+
// terminal renders the panel in a grid; legacy puts both on one line,
|
|
430
|
+
// modern renderings may separate them by one row).
|
|
431
|
+
const label = lower;
|
|
432
|
+
const labelContext = `${plain[i - 1] ?? ""} ${raw}`.toLowerCase();
|
|
433
|
+
|
|
434
|
+
const reset = findResetNearby(plain, i);
|
|
435
|
+
|
|
436
|
+
if (
|
|
437
|
+
/(?:5[- ]?hour|session|rolling)/.test(labelContext) ||
|
|
438
|
+
/(?:5[- ]?hour|session|rolling)/.test(label)
|
|
439
|
+
) {
|
|
440
|
+
if (!hits.some((hit) => hit.kind === "primary")) {
|
|
441
|
+
hits.push({ kind: "primary", percent, resetText: reset, windowMinutes: 300 });
|
|
165
442
|
}
|
|
443
|
+
continue;
|
|
166
444
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
445
|
+
if (/(?:week|7[- ]?day)/.test(labelContext)) {
|
|
446
|
+
const isOpus = /opus|sonnet only/.test(labelContext);
|
|
447
|
+
const kind: WindowHit["kind"] = isOpus ? "opus" : "secondary";
|
|
448
|
+
if (!hits.some((hit) => hit.kind === kind)) {
|
|
449
|
+
hits.push({ kind, percent, resetText: reset, windowMinutes: 10080 });
|
|
450
|
+
}
|
|
170
451
|
}
|
|
452
|
+
}
|
|
171
453
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
454
|
+
const primary = hits.find((hit) => hit.kind === "primary");
|
|
455
|
+
const secondary = hits.find((hit) => hit.kind === "secondary");
|
|
456
|
+
const opus = hits.find((hit) => hit.kind === "opus");
|
|
457
|
+
if (!primary) return null;
|
|
458
|
+
|
|
459
|
+
// Claude renders remaining percentages in both legacy and modern panels;
|
|
460
|
+
// normalize to usedPercent like the legacy path.
|
|
461
|
+
const remainingToUsed = (percent: number): number => Math.max(0, Math.min(100, 100 - percent));
|
|
462
|
+
|
|
463
|
+
const identity = output.match(/(account_email|account_org|login_method)[:=]\s*(.+)/i);
|
|
464
|
+
return {
|
|
465
|
+
source: "cli",
|
|
466
|
+
primary: toRateWindow(remainingToUsed(primary.percent), primary.resetText, primary.windowMinutes),
|
|
467
|
+
secondary: secondary ? toRateWindow(remainingToUsed(secondary.percent), secondary.resetText, secondary.windowMinutes) : null,
|
|
468
|
+
opus: opus ? toRateWindow(remainingToUsed(opus.percent), opus.resetText, opus.windowMinutes) : null,
|
|
469
|
+
extraRateWindows: [],
|
|
470
|
+
providerCost: null,
|
|
471
|
+
updatedAt: new Date(),
|
|
472
|
+
accountEmail: identity?.[1]?.toLowerCase() === "account_email" ? identity[2]!.trim() : null,
|
|
473
|
+
accountOrganization: identity?.[1]?.toLowerCase() === "account_org" ? identity[2]!.trim() : null,
|
|
474
|
+
accountOrganizationId: null,
|
|
475
|
+
loginMethod: identity?.[1]?.toLowerCase() === "login_method" ? identity[2]!.trim() : null,
|
|
476
|
+
subscriptionType: null,
|
|
477
|
+
rateLimitTier: null,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function findResetNearby(lines: string[], percentIndex: number): string | null {
|
|
482
|
+
for (let offset = 1; offset <= 2; offset++) {
|
|
483
|
+
const line = lines[percentIndex + offset];
|
|
484
|
+
if (!line) break;
|
|
485
|
+
const trimmed = line.trim();
|
|
486
|
+
if (/resets?\b/i.test(trimmed)) return trimmed;
|
|
186
487
|
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const PLAN_USAGE_ABSENT_SIGNATURES: RegExp[] = [/total cost/i, /usage:\s*\d+ (?:input|tokens)/i, /total duration/i];
|
|
492
|
+
|
|
493
|
+
function outputIndicatesNoPlanUsage(output: string): boolean {
|
|
494
|
+
const plain = stripAnsi(output);
|
|
495
|
+
if (/\d{1,3}(?:\.\d+)?\s*%/.test(plain)) return false;
|
|
496
|
+
return PLAN_USAGE_ABSENT_SIGNATURES.every((signature) => signature.test(plain));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function describeUnparseableOutput(output: string): string {
|
|
500
|
+
const plain = stripAnsi(output);
|
|
501
|
+
if (!plain.trim()) return "empty output";
|
|
502
|
+
const firstLine = plain.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
|
|
503
|
+
return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function claudeCliOutputIndicatesExpiredSession(output: string): boolean {
|
|
507
|
+
const lower = output.toLowerCase();
|
|
508
|
+
return (
|
|
509
|
+
lower.includes("failed to authenticate") ||
|
|
510
|
+
lower.includes("oauth session expired") ||
|
|
511
|
+
lower.includes("could not be refreshed")
|
|
512
|
+
);
|
|
187
513
|
}
|