@scrylog/cli 0.2.0 → 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.
Files changed (94) hide show
  1. package/README.md +46 -7
  2. package/package.json +13 -10
  3. package/src/api/docs/sessions.ts +3 -2
  4. package/src/api/docs/system.ts +3 -0
  5. package/src/api/docs/usage.ts +2 -2
  6. package/src/api/routes/sessions.ts +6 -2
  7. package/src/api/routes/system.ts +4 -1
  8. package/src/api/routes/webhooks/claude.ts +8 -0
  9. package/src/api/routes/webhooks/opencode.ts +1 -0
  10. package/src/api/schemas/common.ts +8 -0
  11. package/src/api/schemas/sessions.ts +15 -1
  12. package/src/api/schemas/usage.ts +1 -0
  13. package/src/cli/commands.ts +85 -3
  14. package/src/cli/index.ts +18 -0
  15. package/src/cli/program.ts +175 -0
  16. package/src/daemon/collectors/claude/collector.ts +1 -1
  17. package/src/daemon/collectors/claude/discovery.ts +2 -23
  18. package/src/daemon/collectors/codex/collector.ts +3 -3
  19. package/src/daemon/collectors/git.ts +60 -2
  20. package/src/daemon/collectors/herdr/collector.ts +128 -0
  21. package/src/daemon/collectors/herdr/index.ts +2 -0
  22. package/src/daemon/collectors/herdr/normalization.ts +111 -0
  23. package/src/daemon/collectors/opencode-db.ts +17 -5
  24. package/src/daemon/collectors/tmux/collector.ts +21 -1
  25. package/src/daemon/collectors/tmux/enrichment.ts +1 -0
  26. package/src/daemon/collectors/usage.ts +115 -49
  27. package/src/daemon/index.ts +2 -0
  28. package/src/daemon/store.ts +44 -2
  29. package/src/postinstall.cjs +11 -0
  30. package/src/shared/config.ts +33 -0
  31. package/src/shared/session-exclusions.ts +5 -0
  32. package/src/shared/session-view.ts +67 -6
  33. package/src/shared/terminal-capabilities.ts +130 -0
  34. package/src/shared/types.ts +33 -2
  35. package/src/shared/version.ts +44 -0
  36. package/src/tui/App.tsx +240 -0
  37. package/src/tui/assets/providers/SOURCES.md +21 -0
  38. package/src/tui/assets/providers/claude-terminal.png +0 -0
  39. package/src/tui/assets/providers/claude-terminal.svg +62 -0
  40. package/src/tui/assets/providers/claude.png +0 -0
  41. package/src/tui/assets/providers/copilot.png +0 -0
  42. package/src/tui/assets/providers/copilot.svg +44 -0
  43. package/src/tui/assets/providers/openai-terminal.png +0 -0
  44. package/src/tui/assets/providers/openai-terminal.svg +62 -0
  45. package/src/tui/assets/providers/openai.png +0 -0
  46. package/src/tui/assets/providers/opencode-terminal.png +0 -0
  47. package/src/tui/assets/providers/opencode-terminal.svg +62 -0
  48. package/src/tui/assets/providers/opencode.png +0 -0
  49. package/src/tui/assets/providers/shell.png +0 -0
  50. package/src/tui/assets/providers/shell.svg +23 -0
  51. package/src/tui/assets/providers/unknown.png +0 -0
  52. package/src/tui/assets/providers/unknown.svg +23 -0
  53. package/src/tui/client.ts +22 -8
  54. package/src/tui/components/BindingsModal.tsx +82 -0
  55. package/src/tui/components/DashboardHeader.tsx +256 -0
  56. package/src/tui/components/KeyBar.tsx +67 -0
  57. package/src/tui/components/SessionDetailModal.tsx +169 -0
  58. package/src/tui/components/SessionsView.tsx +184 -0
  59. package/src/tui/components/TabBar.tsx +40 -0
  60. package/src/tui/components/UsageSummaryPane.tsx +272 -0
  61. package/src/tui/components/UsageView.tsx +242 -0
  62. package/src/tui/components/tool-icons.ts +24 -0
  63. package/src/tui/index.tsx +106 -0
  64. package/src/tui/keybindings.ts +49 -0
  65. package/src/tui/model.ts +295 -0
  66. package/src/tui/state.ts +77 -0
  67. package/src/tui/theme.ts +110 -0
  68. package/src/usage/README.md +3 -3
  69. package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
  70. package/src/usage/clients/anthropic-web-usage.ts +133 -49
  71. package/src/usage/clients/claude-cli-probe.ts +458 -132
  72. package/src/usage/clients/codex-app-server.ts +354 -0
  73. package/src/usage/clients/codex-oauth-usage.ts +143 -16
  74. package/src/usage/clients/opencode-go-api.ts +100 -0
  75. package/src/usage/clients/opencode-go-usage.ts +163 -27
  76. package/src/usage/errors.ts +33 -0
  77. package/src/usage/local/opencode-go-local.ts +194 -0
  78. package/src/usage/mappers.ts +24 -15
  79. package/src/usage/planner.ts +89 -25
  80. package/src/usage/providers/claude.ts +203 -83
  81. package/src/usage/providers/codex.ts +92 -11
  82. package/src/usage/providers/opencode-go.ts +94 -16
  83. package/src/usage/resolvers/browser-cookies.ts +407 -69
  84. package/src/usage/resolvers/claude-auth.ts +140 -40
  85. package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
  86. package/src/usage/resolvers/claude-web.ts +72 -6
  87. package/src/usage/resolvers/codex-auth.ts +74 -17
  88. package/src/usage/resolvers/keychain-coordinator.ts +397 -0
  89. package/src/usage/resolvers/opencode-go-web.ts +47 -6
  90. package/src/usage/types.ts +37 -2
  91. package/src/usage/web-session-cache.ts +37 -0
  92. package/src/neo-blessed.d.ts +0 -4
  93. package/src/tui/app.ts +0 -920
  94. package/src/tui/index.ts +0 -217
@@ -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 class TimeoutError extends Error {
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 tz = match[2] || undefined;
22
- try {
23
- const d = new Date(datePart);
24
- if (!Number.isNaN(d.getTime())) return d;
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 val = Number.parseFloat(match[1]!);
39
- return Number.isNaN(val) ? null : val;
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
- function probeCli(binaryPath: string, timeoutMs: number): Promise<string> {
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
- let output = "";
55
- let killed = false;
56
-
57
- // Use `script` for PTY on macOS; fallback to direct spawn
58
- const useScript = process.platform === "darwin";
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.stdout.on("data", (data: Buffer) => {
76
- output += data.toString("utf8");
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
- child.stderr.on("data", (data: Buffer) => {
80
- output += data.toString("utf8");
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
- child.on("error", (err) => {
84
- clearTimeout(timer);
85
- if (!killed) reject(err);
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
- child.on("close", () => {
89
- clearTimeout(timer);
90
- if (!killed) resolve(output);
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
- // Send commands
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
- child.stdin.end();
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
- async fetch(binaryPath: string): Promise<ClaudeUsageSnapshot> {
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 probeCli(binaryPath, 10_000);
109
- } catch (err) {
110
- if (err instanceof TimeoutError) {
111
- // Retry with longer timeout
112
- output = await probeCli(binaryPath, 24_000);
113
- } else {
114
- throw err;
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
- const lines = output.split(/\r?\n/);
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
- let primary: RateWindowLegacy | null = null;
121
- let secondary: RateWindowLegacy | null = null;
122
- let opus: RateWindowLegacy | null = null;
123
- let accountEmail: string | null = null;
124
- let accountOrganization: string | null = null;
125
- let loginMethod: string | null = null;
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
- for (let i = 0; i < lines.length; i++) {
128
- const line = lines[i]!;
129
- const lower = line.toLowerCase();
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
- if (lower.includes("account_email")) {
132
- const m = line.match(/account_email[:=]\s*(.+)/i);
133
- if (m) accountEmail = m[1]!.trim();
134
- }
135
- if (lower.includes("account_org")) {
136
- const m = line.match(/account_org[:=]\s*(.+)/i);
137
- if (m) accountOrganization = m[1]!.trim();
138
- }
139
- if (lower.includes("login_method")) {
140
- const m = line.match(/login_method[:=]\s*(.+)/i);
141
- if (m) loginMethod = m[1]!.trim();
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
- if (lower.includes("current session")) {
145
- const percent = parsePercent(line);
146
- if (percent !== null) {
147
- const used = Math.max(0, Math.min(100, 100 - percent));
148
- const resetLine = lines[i + 1] || "";
149
- primary = toRateWindow(used, resetLine.includes("Resets") ? resetLine.trim() : null, 300);
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
- if (lower.includes("current week")) {
154
- const percent = parsePercent(line);
155
- if (percent !== null) {
156
- const used = Math.max(0, Math.min(100, 100 - percent));
157
- const resetLine = lines[i + 1] || "";
158
- const resetText = resetLine.includes("Resets") ? resetLine.trim() : null;
159
- if (lower.includes("(all models)") || lower.includes("all models")) {
160
- secondary = toRateWindow(used, resetText, 10080);
161
- } else if (lower.includes("opus") || lower.includes("sonnet only")) {
162
- opus = toRateWindow(used, resetText, 10080);
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
- if (!primary) {
169
- throw new Error("Could not parse primary usage window from CLI output");
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
- return {
173
- source: "cli",
174
- primary,
175
- secondary,
176
- opus,
177
- extraRateWindows: [],
178
- providerCost: null,
179
- updatedAt: new Date(),
180
- accountEmail,
181
- accountOrganization,
182
- loginMethod,
183
- subscriptionType: null,
184
- rateLimitTier: null,
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
  }