@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,7 +1,14 @@
|
|
|
1
1
|
import type { UsageClient } from "../client";
|
|
2
2
|
import type { OpenCodeGoUsageSnapshot } from "../types";
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
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<
|
|
361
|
-
async fetch(creds:
|
|
362
|
-
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
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(
|
|
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
|
|
413
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/usage/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/usage/mappers.ts
CHANGED
|
@@ -83,7 +83,7 @@ export function mapClaudeToEntry(
|
|
|
83
83
|
provider: "claude",
|
|
84
84
|
accountId: buildClaudeAccountId(snapshot, oauthCreds),
|
|
85
85
|
available,
|
|
86
|
-
status: snapshot ?
|
|
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 (
|
|
148
|
-
return
|
|
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 (
|
|
154
|
-
return
|
|
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 ?
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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,
|