@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.
Files changed (97) 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 +9 -21
  9. package/src/api/routes/webhooks/opencode.ts +6 -1
  10. package/src/api/routes.ts +1 -1
  11. package/src/api/schemas/common.ts +8 -0
  12. package/src/api/schemas/sessions.ts +15 -1
  13. package/src/api/schemas/usage.ts +1 -0
  14. package/src/cli/commands.ts +170 -26
  15. package/src/cli/index.ts +18 -0
  16. package/src/cli/program.ts +175 -0
  17. package/src/daemon/collectors/claude/collector.ts +11 -1
  18. package/src/daemon/collectors/claude/discovery.ts +2 -23
  19. package/src/daemon/collectors/codex/collector.ts +3 -3
  20. package/src/daemon/collectors/git.ts +60 -2
  21. package/src/daemon/collectors/herdr/collector.ts +128 -0
  22. package/src/daemon/collectors/herdr/index.ts +2 -0
  23. package/src/daemon/collectors/herdr/normalization.ts +111 -0
  24. package/src/daemon/collectors/opencode/collector.ts +4 -2
  25. package/src/daemon/collectors/opencode-db.ts +17 -5
  26. package/src/daemon/collectors/tmux/collector.ts +21 -1
  27. package/src/daemon/collectors/tmux/enrichment.ts +1 -0
  28. package/src/daemon/collectors/usage.ts +115 -49
  29. package/src/daemon/index.ts +2 -0
  30. package/src/daemon/store.ts +109 -14
  31. package/src/postinstall.cjs +11 -0
  32. package/src/shared/config.ts +33 -0
  33. package/src/shared/session-exclusions.ts +5 -0
  34. package/src/shared/session-view.ts +67 -6
  35. package/src/shared/terminal-capabilities.ts +130 -0
  36. package/src/shared/types.ts +33 -2
  37. package/src/shared/version.ts +44 -0
  38. package/src/tui/App.tsx +240 -0
  39. package/src/tui/assets/providers/SOURCES.md +21 -0
  40. package/src/tui/assets/providers/claude-terminal.png +0 -0
  41. package/src/tui/assets/providers/claude-terminal.svg +62 -0
  42. package/src/tui/assets/providers/claude.png +0 -0
  43. package/src/tui/assets/providers/copilot.png +0 -0
  44. package/src/tui/assets/providers/copilot.svg +44 -0
  45. package/src/tui/assets/providers/openai-terminal.png +0 -0
  46. package/src/tui/assets/providers/openai-terminal.svg +62 -0
  47. package/src/tui/assets/providers/openai.png +0 -0
  48. package/src/tui/assets/providers/opencode-terminal.png +0 -0
  49. package/src/tui/assets/providers/opencode-terminal.svg +62 -0
  50. package/src/tui/assets/providers/opencode.png +0 -0
  51. package/src/tui/assets/providers/shell.png +0 -0
  52. package/src/tui/assets/providers/shell.svg +23 -0
  53. package/src/tui/assets/providers/unknown.png +0 -0
  54. package/src/tui/assets/providers/unknown.svg +23 -0
  55. package/src/tui/client.ts +22 -8
  56. package/src/tui/components/BindingsModal.tsx +82 -0
  57. package/src/tui/components/DashboardHeader.tsx +256 -0
  58. package/src/tui/components/KeyBar.tsx +67 -0
  59. package/src/tui/components/SessionDetailModal.tsx +169 -0
  60. package/src/tui/components/SessionsView.tsx +184 -0
  61. package/src/tui/components/TabBar.tsx +40 -0
  62. package/src/tui/components/UsageSummaryPane.tsx +272 -0
  63. package/src/tui/components/UsageView.tsx +242 -0
  64. package/src/tui/components/tool-icons.ts +24 -0
  65. package/src/tui/index.tsx +106 -0
  66. package/src/tui/keybindings.ts +49 -0
  67. package/src/tui/model.ts +295 -0
  68. package/src/tui/state.ts +77 -0
  69. package/src/tui/theme.ts +110 -0
  70. package/src/usage/README.md +3 -3
  71. package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
  72. package/src/usage/clients/anthropic-web-usage.ts +133 -49
  73. package/src/usage/clients/claude-cli-probe.ts +458 -132
  74. package/src/usage/clients/codex-app-server.ts +354 -0
  75. package/src/usage/clients/codex-oauth-usage.ts +143 -16
  76. package/src/usage/clients/opencode-go-api.ts +100 -0
  77. package/src/usage/clients/opencode-go-usage.ts +163 -27
  78. package/src/usage/errors.ts +33 -0
  79. package/src/usage/index.ts +2 -1
  80. package/src/usage/local/opencode-go-local.ts +194 -0
  81. package/src/usage/mappers.ts +24 -15
  82. package/src/usage/planner.ts +89 -25
  83. package/src/usage/providers/claude.ts +203 -83
  84. package/src/usage/providers/codex.ts +92 -11
  85. package/src/usage/providers/opencode-go.ts +94 -16
  86. package/src/usage/resolvers/browser-cookies.ts +679 -69
  87. package/src/usage/resolvers/claude-auth.ts +150 -41
  88. package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
  89. package/src/usage/resolvers/claude-web.ts +72 -6
  90. package/src/usage/resolvers/codex-auth.ts +74 -17
  91. package/src/usage/resolvers/keychain-coordinator.ts +397 -0
  92. package/src/usage/resolvers/opencode-go-web.ts +47 -6
  93. package/src/usage/types.ts +37 -2
  94. package/src/usage/web-session-cache.ts +37 -0
  95. package/src/neo-blessed.d.ts +0 -4
  96. package/src/tui/app.ts +0 -920
  97. package/src/tui/index.ts +0 -193
@@ -1,7 +1,14 @@
1
1
  import type { UsageClient } from "../client";
2
2
  import type { OpenCodeGoUsageSnapshot } from "../types";
3
- import { InvalidCredentialsError } from "../clients/anthropic-web-usage";
4
- import { ParseError } from "../clients/anthropic-oauth-usage";
3
+ import { classifyUsageError, UsageError } from "../errors";
4
+
5
+ const AUTH_COOKIE_NAMES = new Set(["auth", "__Host-auth", "__Host-console_session"]);
6
+
7
+ export interface OpenCodeGoClientCredentials {
8
+ cookies: string;
9
+ serverFnId: string;
10
+ onCookieRotation?: (cookies: string) => void;
11
+ }
5
12
 
6
13
  function randomUuid(): string {
7
14
  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
@@ -13,8 +20,112 @@ function randomUuid(): string {
13
20
 
14
21
  function isSignedOut(text: string): boolean {
15
22
  const lower = text.toLowerCase();
16
- const indicators = ["login", "sign in", "auth/authorize", "not associated with an account", 'actor of type "public"'];
17
- return indicators.some((ind) => lower.includes(ind.toLowerCase()));
23
+ return (
24
+ lower.includes("auth/authorize") ||
25
+ lower.includes("not associated with an account") ||
26
+ lower.includes('actor of type "public"') ||
27
+ /(?:href|action)=["'][^"']*\/(?:login|signin)(?:[?"'])/.test(lower)
28
+ );
29
+ }
30
+
31
+ function parseCookieHeader(header: string): Map<string, string> {
32
+ const cookies = new Map<string, string>();
33
+ for (const part of header.split(";")) {
34
+ const separator = part.indexOf("=");
35
+ if (separator < 1) continue;
36
+ const name = part.slice(0, separator).trim();
37
+ if (AUTH_COOKIE_NAMES.has(name)) cookies.set(name, part.slice(separator + 1).trim());
38
+ }
39
+ return cookies;
40
+ }
41
+
42
+ function buildAuthCookieHeader(cookies: ReadonlyMap<string, string>): string {
43
+ return [...cookies].map(([name, value]) => `${name}=${value}`).join("; ");
44
+ }
45
+
46
+ function getSetCookieHeaders(headers: Headers): string[] {
47
+ const values = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie?.();
48
+ if (values?.length) return values;
49
+ const combined = headers.get("set-cookie");
50
+ return combined ? combined.split(/,\s*(?=(?:auth|__Host-auth)=)/) : [];
51
+ }
52
+
53
+ function applyAuthCookieRotation(current: string, headers: Headers): string {
54
+ const cookies = parseCookieHeader(current);
55
+ for (const setCookie of getSetCookieHeaders(headers)) {
56
+ const [pair, ...attributes] = setCookie.split(";");
57
+ const separator = pair!.indexOf("=");
58
+ if (separator < 1) continue;
59
+ const name = pair!.slice(0, separator).trim();
60
+ if (!AUTH_COOKIE_NAMES.has(name)) continue;
61
+
62
+ const value = pair!.slice(separator + 1).trim();
63
+ const expired = attributes.some((attribute) => {
64
+ const [rawName, rawValue = ""] = attribute.trim().split("=", 2);
65
+ const attributeName = rawName!.toLowerCase();
66
+ if (attributeName === "max-age") return Number(rawValue) <= 0;
67
+ if (attributeName !== "expires") return false;
68
+ const expiresAt = Date.parse(rawValue);
69
+ return !Number.isNaN(expiresAt) && expiresAt <= Date.now();
70
+ });
71
+ if (!value || expired) cookies.delete(name);
72
+ else cookies.set(name, value);
73
+ }
74
+ return buildAuthCookieHeader(cookies);
75
+ }
76
+
77
+ function retryAfterMs(response: Response): number | undefined {
78
+ const value = response.headers.get("retry-after");
79
+ if (!value) return undefined;
80
+ const seconds = Number(value);
81
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
82
+ const date = Date.parse(value);
83
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
84
+ }
85
+
86
+ function assertResponseStatus(response: Response, context: string): void {
87
+ if (response.ok) return;
88
+ if (response.status === 401) throw new UsageError("invalid-credentials", `${context} rejected the session`);
89
+ if (response.status === 403) throw new UsageError("forbidden", `${context} is forbidden`);
90
+ if (response.status === 429) {
91
+ throw new UsageError("rate-limited", `${context} is rate limited`, retryAfterMs(response));
92
+ }
93
+ if (response.status >= 500) throw new UsageError("transient", `${context} failed with HTTP ${response.status}`);
94
+ throw new UsageError("invalid-response", `${context} failed with HTTP ${response.status}`);
95
+ }
96
+
97
+ function isAuthenticationUrl(url: URL): boolean {
98
+ const path = url.pathname.toLowerCase().replace(/\/+$/, "");
99
+ return path === "/login" || path === "/signin" || path === "/console/login" || path === "/auth/authorize";
100
+ }
101
+
102
+ async function fetchSameHost(url: string, init: RequestInit): Promise<Response> {
103
+ let currentUrl = new URL(url);
104
+ let currentInit = init;
105
+ for (let redirects = 0; redirects <= 5; redirects++) {
106
+ let response: Response;
107
+ try {
108
+ response = await fetch(currentUrl, { ...currentInit, redirect: "manual" });
109
+ } catch (error) {
110
+ throw classifyUsageError(error);
111
+ }
112
+ if (response.status < 300 || response.status >= 400) return response;
113
+
114
+ const location = response.headers.get("location");
115
+ if (!location) throw new UsageError("invalid-response", "OpenCode Go redirect is missing a location");
116
+ const nextUrl = new URL(location, currentUrl);
117
+ if (nextUrl.protocol !== "https:" || nextUrl.hostname !== "opencode.ai") {
118
+ throw new UsageError("invalid-response", "OpenCode Go refused a cross-origin redirect");
119
+ }
120
+ if (isAuthenticationUrl(nextUrl)) {
121
+ throw new UsageError("invalid-credentials", "OpenCode Go session redirected to sign in");
122
+ }
123
+ if (response.status === 303 || ((response.status === 301 || response.status === 302) && currentInit.method === "POST")) {
124
+ currentInit = { ...currentInit, method: "GET", body: undefined };
125
+ }
126
+ currentUrl = nextUrl;
127
+ }
128
+ throw new UsageError("invalid-response", "OpenCode Go exceeded the redirect limit");
18
129
  }
19
130
 
20
131
  function extractWorkspaceIds(text: string): string[] {
@@ -232,7 +343,7 @@ function parseSubscriptionPage(text: string): {
232
343
  hasMonthly: boolean;
233
344
  } {
234
345
  if (isSignedOut(text)) {
235
- throw new InvalidCredentialsError("Session is signed out");
346
+ throw new UsageError("invalid-credentials", "Session is signed out");
236
347
  }
237
348
 
238
349
  // Strategy 1: JSON direct lookup
@@ -343,7 +454,7 @@ function parseSubscriptionPage(text: string): {
343
454
  const weeklyPercent = weeklyPercentMatch ? Number.parseFloat(weeklyPercentMatch[1]!) : null;
344
455
 
345
456
  if (rollingPercent === null || weeklyPercent === null) {
346
- throw new ParseError("Missing usage fields.");
457
+ throw new UsageError("invalid-response", "Missing usage fields.");
347
458
  }
348
459
 
349
460
  return {
@@ -357,27 +468,40 @@ function parseSubscriptionPage(text: string): {
357
468
  };
358
469
  }
359
470
 
360
- export class OpenCodeGoUsageClient implements UsageClient<{ cookies: string; serverFnId: string }, OpenCodeGoUsageSnapshot> {
361
- async fetch(creds: { cookies: string; serverFnId: string }, options?: { signal?: AbortSignal }): Promise<OpenCodeGoUsageSnapshot> {
362
- const workspace = await this.resolveWorkspace(creds.cookies, creds.serverFnId, options?.signal);
471
+ export class OpenCodeGoUsageClient implements UsageClient<OpenCodeGoClientCredentials, OpenCodeGoUsageSnapshot> {
472
+ async fetch(creds: OpenCodeGoClientCredentials, options?: { signal?: AbortSignal }): Promise<OpenCodeGoUsageSnapshot> {
473
+ let cookies = buildAuthCookieHeader(parseCookieHeader(creds.cookies));
474
+ const request = async (url: string, init: RequestInit = {}): Promise<Response> => {
475
+ const headers = new Headers(init.headers);
476
+ headers.set("Cookie", cookies);
477
+ const response = await fetchSameHost(url, { ...init, headers, signal: options?.signal });
478
+ const rotated = applyAuthCookieRotation(cookies, response.headers);
479
+ if (rotated !== cookies) {
480
+ cookies = rotated;
481
+ creds.onCookieRotation?.(cookies);
482
+ }
483
+ return response;
484
+ };
485
+
486
+ const workspace = await this.resolveWorkspace(creds.serverFnId, request);
363
487
  const url = `https://opencode.ai/workspace/${workspace.id}/go`;
364
488
 
365
- const res = await fetch(url, {
366
- signal: options?.signal,
489
+ const res = await request(url, {
367
490
  headers: {
368
- Cookie: creds.cookies,
369
491
  "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
370
492
  Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
371
493
  },
372
494
  });
495
+ assertResponseStatus(res, "OpenCode Go subscription request");
373
496
 
374
- if (res.status === 401 || res.status === 403) {
375
- throw new InvalidCredentialsError("OpenCode Go session invalid");
497
+ let text: string;
498
+ try {
499
+ text = await res.text();
500
+ } catch (error) {
501
+ throw classifyUsageError(error);
376
502
  }
377
-
378
- const text = await res.text();
379
503
  if (isSignedOut(text)) {
380
- throw new InvalidCredentialsError("OpenCode Go session is signed out");
504
+ throw new UsageError("invalid-credentials", "OpenCode Go session is signed out");
381
505
  }
382
506
 
383
507
  const parsed = parseSubscriptionPage(text);
@@ -397,9 +521,11 @@ export class OpenCodeGoUsageClient implements UsageClient<{ cookies: string; ser
397
521
  };
398
522
  }
399
523
 
400
- private async resolveWorkspace(cookies: string, serverFnId: string, signal?: AbortSignal): Promise<{ id: string; name: string | null }> {
524
+ private async resolveWorkspace(
525
+ serverFnId: string,
526
+ request: (url: string, init?: RequestInit) => Promise<Response>,
527
+ ): Promise<{ id: string; name: string | null }> {
401
528
  const headers: Record<string, string> = {
402
- Cookie: cookies,
403
529
  "X-Server-Id": serverFnId,
404
530
  "X-Server-Instance": `server-fn:${randomUuid()}`,
405
531
  "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
@@ -409,31 +535,41 @@ export class OpenCodeGoUsageClient implements UsageClient<{ cookies: string; ser
409
535
  };
410
536
 
411
537
  // GET attempt
412
- let res = await fetch(`https://opencode.ai/_server?id=${serverFnId}`, { headers, signal });
413
- let text = await res.text();
538
+ let res = await request(`https://opencode.ai/_server?id=${serverFnId}`, { headers });
539
+ assertResponseStatus(res, "OpenCode Go workspace request");
540
+ let text: string;
541
+ try {
542
+ text = await res.text();
543
+ } catch (error) {
544
+ throw classifyUsageError(error);
545
+ }
414
546
 
415
547
  if (isSignedOut(text)) {
416
- throw new InvalidCredentialsError("OpenCode Go session is signed out");
548
+ throw new UsageError("invalid-credentials", "OpenCode Go session is signed out");
417
549
  }
418
550
 
419
551
  let workspace = extractWorkspace(text);
420
552
  if (!workspace) {
421
553
  // POST fallback
422
- res = await fetch("https://opencode.ai/_server", {
554
+ res = await request("https://opencode.ai/_server", {
423
555
  method: "POST",
424
- signal,
425
556
  headers: { ...headers, "Content-Type": "application/json" },
426
557
  body: "[]",
427
558
  });
428
- text = await res.text();
559
+ assertResponseStatus(res, "OpenCode Go workspace request");
560
+ try {
561
+ text = await res.text();
562
+ } catch (error) {
563
+ throw classifyUsageError(error);
564
+ }
429
565
  if (isSignedOut(text)) {
430
- throw new InvalidCredentialsError("OpenCode Go session is signed out");
566
+ throw new UsageError("invalid-credentials", "OpenCode Go session is signed out");
431
567
  }
432
568
  workspace = extractWorkspace(text);
433
569
  }
434
570
 
435
571
  if (!workspace) {
436
- throw new ParseError("Missing workspace id.");
572
+ throw new UsageError("invalid-response", "Missing workspace id.");
437
573
  }
438
574
 
439
575
  return workspace;
@@ -0,0 +1,33 @@
1
+ export type UsageFailureKind =
2
+ | "invalid-credentials"
3
+ | "forbidden"
4
+ | "rate-limited"
5
+ | "transient"
6
+ | "invalid-response"
7
+ | "cancelled";
8
+
9
+ export class UsageError extends Error {
10
+ constructor(
11
+ public readonly kind: UsageFailureKind,
12
+ message: string,
13
+ public readonly retryAfterMs?: number,
14
+ ) {
15
+ super(message);
16
+ this.name = "UsageError";
17
+ }
18
+ }
19
+
20
+ export function classifyUsageError(error: unknown): UsageError {
21
+ if (error instanceof UsageError) return error;
22
+ if (error instanceof DOMException && error.name === "AbortError") {
23
+ return new UsageError("cancelled", "Usage request cancelled");
24
+ }
25
+ if (error instanceof Error && error.name === "AbortError") {
26
+ return new UsageError("cancelled", "Usage request cancelled");
27
+ }
28
+ return new UsageError("transient", error instanceof Error ? error.message : String(error));
29
+ }
30
+
31
+ export function isRecoverableAuthenticationError(error: unknown): boolean {
32
+ return classifyUsageError(error).kind === "invalid-credentials";
33
+ }
@@ -8,11 +8,12 @@ export { ClaudeUsageProvider } from "./providers/claude";
8
8
  export { CodexUsageProvider } from "./providers/codex";
9
9
  export { OpenCodeGoUsageProvider } from "./providers/opencode-go";
10
10
  export { ClaudeAuthResolver } from "./resolvers/claude-auth";
11
+ export { ClaudeDesktopAuthResolver } from "./resolvers/claude-desktop-auth";
11
12
  export { ClaudeWebResolver } from "./resolvers/claude-web";
12
13
  export { ClaudeCliResolver } from "./resolvers/claude-cli";
13
14
  export { CodexAuthResolver } from "./resolvers/codex-auth";
14
15
  export { OpenCodeGoWebResolver } from "./resolvers/opencode-go-web";
15
- export { extractCookies, buildCookieHeader } from "./resolvers/browser-cookies";
16
+ export { extractCookies, buildCookieHeader, decryptSafeStorageBlob, getChromiumSafeStorageKey } from "./resolvers/browser-cookies";
16
17
  export { AnthropicOAuthUsageClient } from "./clients/anthropic-oauth-usage";
17
18
  export { AnthropicWebUsageClient } from "./clients/anthropic-web-usage";
18
19
  export { CodexOAuthUsageClient } from "./clients/codex-oauth-usage";
@@ -0,0 +1,194 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { resolveDbCandidates } from "../../daemon/collectors/opencode-db";
3
+ import type { OpenCodeGoUsageSnapshot } from "../types";
4
+
5
+ const FIVE_HOURS_SEC = 5 * 60 * 60;
6
+ const WEEK_SEC = 7 * 24 * 60 * 60;
7
+ const SESSION_LIMIT_USD = 12;
8
+ const WEEKLY_LIMIT_USD = 30;
9
+ const MONTHLY_LIMIT_USD = 60;
10
+
11
+ export interface OpenCodeGoLocalUsageFetcher {
12
+ fetch(now?: Date): Promise<OpenCodeGoUsageSnapshot | null>;
13
+ }
14
+
15
+ interface UsageRow {
16
+ createdMs: number;
17
+ cost: number;
18
+ }
19
+
20
+ const MESSAGE_USAGE_SQL = `
21
+ SELECT
22
+ CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs,
23
+ CAST(json_extract(data, '$.cost') AS REAL) AS cost
24
+ FROM message
25
+ WHERE json_valid(data)
26
+ AND json_extract(data, '$.providerID') = 'opencode-go'
27
+ AND json_extract(data, '$.role') = 'assistant'
28
+ AND json_type(data, '$.cost') IN ('integer', 'real')
29
+ `;
30
+
31
+ const MESSAGE_AND_PART_USAGE_SQL = `
32
+ WITH provider_messages AS (
33
+ SELECT
34
+ id AS messageID,
35
+ CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs,
36
+ CAST(json_extract(data, '$.cost') AS REAL) AS cost,
37
+ json_type(data, '$.cost') IN ('integer', 'real') AS hasCost
38
+ FROM message
39
+ WHERE json_valid(data)
40
+ AND json_extract(data, '$.providerID') = 'opencode-go'
41
+ AND json_extract(data, '$.role') = 'assistant'
42
+ )
43
+ SELECT
44
+ CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.createdMs) AS INTEGER) AS createdMs,
45
+ CAST(json_extract(p.data, '$.cost') AS REAL) AS cost
46
+ FROM part p
47
+ JOIN provider_messages m ON m.messageID = p.message_id
48
+ WHERE json_valid(p.data)
49
+ AND json_extract(p.data, '$.type') = 'step-finish'
50
+ AND json_type(p.data, '$.cost') IN ('integer', 'real')
51
+ UNION ALL
52
+ SELECT createdMs, cost
53
+ FROM provider_messages m
54
+ WHERE hasCost
55
+ AND NOT EXISTS (
56
+ SELECT 1
57
+ FROM part p
58
+ WHERE p.message_id = m.messageID
59
+ AND json_valid(p.data)
60
+ AND json_extract(p.data, '$.type') = 'step-finish'
61
+ AND json_type(p.data, '$.cost') IN ('integer', 'real')
62
+ )
63
+ `;
64
+
65
+ export class OpenCodeGoLocalUsageReader implements OpenCodeGoLocalUsageFetcher {
66
+ async fetch(now: Date = new Date()): Promise<OpenCodeGoUsageSnapshot | null> {
67
+ for (const dbPath of resolveDbCandidates()) {
68
+ let db: Database | undefined;
69
+ try {
70
+ db = new Database(dbPath, { readonly: true });
71
+ const rows = readLocalRows(db);
72
+ if (rows.length > 0) {
73
+ return buildLocalSnapshot(rows, now);
74
+ }
75
+ } catch {
76
+ // Missing or corrupt databases are not usable local usage sources.
77
+ } finally {
78
+ db?.close();
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function readLocalRows(db: Database): UsageRow[] {
86
+ const partMatch = db
87
+ .query<unknown, [string]>("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1")
88
+ .get("part");
89
+ // bun:sqlite returns null (not undefined) when a row cannot be resolved.
90
+ const hasPartTable = partMatch !== null && partMatch !== undefined;
91
+ const statement = db.query<RowShape, []>(hasPartTable ? MESSAGE_AND_PART_USAGE_SQL : MESSAGE_USAGE_SQL);
92
+ const rows: UsageRow[] = [];
93
+ for (const row of statement.all()) {
94
+ const createdMs = Number(row.createdMs);
95
+ const cost = Number(row.cost);
96
+ if (Number.isFinite(createdMs) && createdMs > 0 && Number.isFinite(cost) && cost >= 0) {
97
+ rows.push({ createdMs, cost });
98
+ }
99
+ }
100
+ return rows;
101
+ }
102
+
103
+ interface RowShape {
104
+ createdMs: number | null;
105
+ cost: number | null;
106
+ }
107
+
108
+ interface MonthBounds {
109
+ startMs: number;
110
+ endMs: number;
111
+ }
112
+
113
+ export function buildLocalSnapshot(rows: UsageRow[], now: Date): OpenCodeGoUsageSnapshot {
114
+ const nowMs = now.getTime();
115
+ const sessionStartMs = nowMs - FIVE_HOURS_SEC * 1000;
116
+ const weekStartMs = startOfUtcWeek(now).getTime();
117
+ const weekEndMs = weekStartMs + WEEK_SEC * 1000;
118
+ const { startMs, endMs } = monthBounds(now, rows);
119
+
120
+ let sessionCost = 0;
121
+ let weekCost = 0;
122
+ let monthCost = 0;
123
+ let oldestSessionMs: number | undefined;
124
+ for (const row of rows) {
125
+ if (row.createdMs >= sessionStartMs && row.createdMs < nowMs) {
126
+ sessionCost += row.cost;
127
+ if (oldestSessionMs === undefined || row.createdMs < oldestSessionMs) oldestSessionMs = row.createdMs;
128
+ }
129
+ if (row.createdMs >= weekStartMs && row.createdMs < weekEndMs) {
130
+ weekCost += row.cost;
131
+ }
132
+ if (row.createdMs >= startMs && row.createdMs < endMs) {
133
+ monthCost += row.cost;
134
+ }
135
+ }
136
+
137
+ const oldestSession = oldestSessionMs ?? nowMs;
138
+ return {
139
+ source: "local",
140
+ hasMonthlyUsage: true,
141
+ rollingUsagePercent: percentOf(sessionCost, SESSION_LIMIT_USD),
142
+ weeklyUsagePercent: percentOf(weekCost, WEEKLY_LIMIT_USD),
143
+ monthlyUsagePercent: percentOf(monthCost, MONTHLY_LIMIT_USD),
144
+ rollingResetInSec: Math.max(0, Math.round((oldestSession + FIVE_HOURS_SEC * 1000 - nowMs) / 1000)),
145
+ weeklyResetInSec: Math.max(0, Math.round((weekEndMs - nowMs) / 1000)),
146
+ monthlyResetInSec: Math.max(0, Math.round((endMs - nowMs) / 1000)),
147
+ updatedAt: now,
148
+ workspaceId: null,
149
+ workspaceName: null,
150
+ };
151
+ }
152
+
153
+ function percentOf(used: number, limit: number): number {
154
+ if (!Number.isFinite(used) || limit <= 0) return 0;
155
+ return Math.max(0, Math.min(100, Math.round((used / limit) * 1000) / 10));
156
+ }
157
+
158
+ function startOfUtcWeek(now: Date): Date {
159
+ const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
160
+ const weekdayIndex = (midnight.getUTCDay() + 6) % 7; // Monday = 0
161
+ return new Date(midnight.getTime() - weekdayIndex * 24 * 60 * 60 * 1000);
162
+ }
163
+
164
+ function monthBounds(now: Date, rows: UsageRow[]): MonthBounds {
165
+ const earliestMs = rows.length > 0 ? Math.min(...rows.map((row) => row.createdMs)) : undefined;
166
+ const anchor = earliestMs !== undefined ? new Date(earliestMs) : now;
167
+
168
+ // Month window anchored to the earliest local row (billing-cycle estimate),
169
+ // mirroring CodexBar's OpenCodeGoLocalUsageReader month heuristic.
170
+ const start = new Date(Date.UTC(
171
+ now.getUTCFullYear(),
172
+ now.getUTCMonth(),
173
+ anchor.getUTCDate(),
174
+ anchor.getUTCHours(),
175
+ anchor.getUTCMinutes(),
176
+ anchor.getUTCSeconds(),
177
+ ));
178
+ const shifted = start.getTime() > now.getTime()
179
+ ? new Date(start.getTime() - 31 * 24 * 60 * 60 * 1000)
180
+ : start;
181
+ const end = new Date(Date.UTC(
182
+ shifted.getUTCFullYear() + (shifted.getUTCMonth() === 11 ? 1 : 0),
183
+ shifted.getUTCMonth() === 11 ? 0 : shifted.getUTCMonth() + 1,
184
+ Math.min(shifted.getUTCDate(), daysInUtcMonth(shifted.getUTCFullYear(), shifted.getUTCMonth() === 11 ? 0 : shifted.getUTCMonth() + 1)),
185
+ shifted.getUTCHours(),
186
+ shifted.getUTCMinutes(),
187
+ shifted.getUTCSeconds(),
188
+ ));
189
+ return { startMs: shifted.getTime(), endMs: end.getTime() };
190
+ }
191
+
192
+ function daysInUtcMonth(year: number, month: number): number {
193
+ return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
194
+ }
@@ -83,7 +83,7 @@ export function mapClaudeToEntry(
83
83
  provider: "claude",
84
84
  accountId: buildClaudeAccountId(snapshot, oauthCreds),
85
85
  available,
86
- status: snapshot ? (errors.length > 0 ? "error" : "ok") : available ? "error" : "unavailable",
86
+ status: snapshot ? "ok" : available ? "error" : "unavailable",
87
87
  };
88
88
 
89
89
  if (!snapshot) {
@@ -129,10 +129,6 @@ export function mapClaudeToEntry(
129
129
  rateLimitTier: snapshot.rateLimitTier,
130
130
  };
131
131
 
132
- if (errors.length > 0) {
133
- base.errors = errors;
134
- }
135
-
136
132
  base.windows = windows;
137
133
  base.cost = cost;
138
134
  base.updatedAt = toIsoString(snapshot.updatedAt) ?? new Date().toISOString();
@@ -144,14 +140,17 @@ export function buildCodexAccountId(
144
140
  snapshot: CodexUsageSnapshot | null,
145
141
  oauthCreds: CodexOAuthCredentials | null,
146
142
  ): string | null {
147
- if (oauthCreds?.accountEmail) {
148
- return oauthCreds.accountEmail;
143
+ if (snapshot?.accountId) {
144
+ return snapshot.accountId;
145
+ }
146
+ if (snapshot?.accountEmail) {
147
+ return snapshot.accountEmail;
149
148
  }
150
149
  if (oauthCreds?.accountId) {
151
150
  return oauthCreds.accountId;
152
151
  }
153
- if (snapshot?.accountEmail) {
154
- return snapshot.accountEmail;
152
+ if (oauthCreds?.accountEmail) {
153
+ return oauthCreds.accountEmail;
155
154
  }
156
155
  if (snapshot?.planType) {
157
156
  return `codex-${snapshot.planType}`;
@@ -169,7 +168,7 @@ export function mapCodexToEntry(
169
168
  provider: "codex",
170
169
  accountId: buildCodexAccountId(snapshot, oauthCreds),
171
170
  available,
172
- status: snapshot ? (errors.length > 0 ? "error" : "ok") : available ? "error" : "unavailable",
171
+ status: snapshot ? "ok" : available ? "error" : "unavailable",
173
172
  };
174
173
 
175
174
  if (!snapshot) {
@@ -187,11 +186,21 @@ export function mapCodexToEntry(
187
186
  const secondary = mapRateWindowLegacyToWindow("weekly", "Weekly", snapshot.secondaryWindow);
188
187
  if (secondary) windows.push(secondary);
189
188
 
190
- // Codex usage API provides rate windows (percentages) and credits (balance/remaining).
191
- // It does not expose separate "used" vs "limit" absolute amounts, so a
192
- // ProviderCostSnapshot cannot be accurately constructed. Only rate windows are
193
- // mapped; credit balance is surfaced via metadata instead of cost.
194
- const cost: ProviderCostSnapshot | null = null;
189
+ const balance = snapshot.credits?.hasCredits && !snapshot.credits.unlimited
190
+ ? snapshot.credits.balance
191
+ : null;
192
+ const individualLimit = snapshot.individualLimit;
193
+ const cost: ProviderCostSnapshot | null = individualLimit || balance !== null
194
+ ? {
195
+ used: individualLimit?.used ?? 0,
196
+ limit: individualLimit?.limit ?? 0,
197
+ currency: "Credits",
198
+ period: "Extra usage",
199
+ ...(balance !== null ? { balance } : {}),
200
+ resetsAt: toIsoString(individualLimit?.resetsAt ?? null),
201
+ updatedAt: toIsoString(snapshot.updatedAt) ?? new Date().toISOString(),
202
+ }
203
+ : null;
195
204
 
196
205
  base.metadata = {
197
206
  dataSource: snapshot.source,