@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
@@ -0,0 +1,295 @@
1
+ import type { DetectionReason, ProcessInfo, SessionModel, SessionState, SessionTool } from "../shared/types";
2
+ import type { SessionListItem } from "../shared/session-view";
3
+ import { formatDuration, truncate } from "../shared/utils";
4
+ import { BRAND } from "./theme";
5
+
6
+ export type DashboardView = "sessions" | "usage";
7
+ export type DashboardMode = "list" | "search" | "detail" | "message" | "bindings";
8
+ export type SessionToolFilter = SessionTool | "all";
9
+ export type SessionStateFilter = SessionState | "all";
10
+
11
+ export interface DashboardFilters {
12
+ query: string;
13
+ actionableOnly: boolean;
14
+ tool: SessionToolFilter;
15
+ state: SessionStateFilter;
16
+ }
17
+
18
+ export type DashboardRow = ProjectHeaderRow | SessionRow | GapRow;
19
+
20
+ export interface ProjectHeaderRow {
21
+ kind: "project-header";
22
+ projectKey: string;
23
+ projectName: string;
24
+ }
25
+
26
+ export interface SessionRow {
27
+ kind: "session";
28
+ projectKey: string;
29
+ session: SessionListItem;
30
+ }
31
+
32
+ export interface GapRow {
33
+ kind: "gap";
34
+ projectKey: string;
35
+ }
36
+
37
+ export interface SessionColumnWidths {
38
+ session: number;
39
+ state: number;
40
+ tool: number;
41
+ summary: number;
42
+ }
43
+
44
+ export const TOOL_FILTERS: SessionToolFilter[] = ["all", "opencode", "claude", "codex", "copilotcli", "shell", "unknown"];
45
+ export const STATE_FILTERS: SessionStateFilter[] = ["all", "working", "waiting-input", "idle", "shell", "unknown"];
46
+ export const WORKING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
47
+
48
+ export const STATE_COLORS: Record<string, string> = {
49
+ working: BRAND.stateWorking,
50
+ idle: BRAND.stateIdle,
51
+ "waiting-input": BRAND.violet,
52
+ shell: BRAND.accentBlue,
53
+ error: BRAND.danger,
54
+ unknown: BRAND.grey,
55
+ };
56
+
57
+ export function applySessionFilters(
58
+ sessions: SessionListItem[],
59
+ filters: DashboardFilters,
60
+ standaloneMaxAgeHours: number,
61
+ ): SessionListItem[] {
62
+ const query = filters.query.toLowerCase();
63
+ const cutoff = standaloneMaxAgeHours > 0 ? Date.now() - standaloneMaxAgeHours * 3_600_000 : 0;
64
+ return sessions.filter((session) => {
65
+ if (session.parentSessionId) {
66
+ if (session.state !== "working" && session.state !== "waiting-input") return false;
67
+ } else if (cutoff > 0 && session.source === "standalone" && session.lastActivity < cutoff) {
68
+ return false;
69
+ }
70
+ if (filters.actionableOnly && !(session.hasAttention ?? session.state === "waiting-input")) return false;
71
+ if (filters.tool !== "all" && session.tool !== filters.tool) return false;
72
+ if (filters.state !== "all" && session.state !== filters.state) return false;
73
+ if (!query) return true;
74
+ const searchable = [
75
+ session.id,
76
+ session.tool,
77
+ session.state,
78
+ session.summary,
79
+ session.project?.displayName,
80
+ session.project?.compactLabel,
81
+ session.project?.branch,
82
+ session.displayName,
83
+ session.tmux?.session,
84
+ session.herdr?.workspace,
85
+ session.herdr?.name,
86
+ session.herdr?.tab,
87
+ session.herdr?.agent,
88
+ session.currentPath,
89
+ ].filter(Boolean).join(" ").toLowerCase();
90
+ return searchable.includes(query);
91
+ });
92
+ }
93
+
94
+ export function cycleFilters(filters: DashboardFilters): DashboardFilters {
95
+ const nextTool = TOOL_FILTERS[(TOOL_FILTERS.indexOf(filters.tool) + 1) % TOOL_FILTERS.length] ?? "all";
96
+ if (nextTool !== "all") return { ...filters, tool: nextTool, state: "all" };
97
+ const nextState = STATE_FILTERS[(STATE_FILTERS.indexOf(filters.state) + 1) % STATE_FILTERS.length] ?? "all";
98
+ return { ...filters, tool: "all", state: nextState };
99
+ }
100
+
101
+ function sessionSortWeight(session: SessionListItem): number {
102
+ // Active work first (working/waiting), then idle workloads.
103
+ const statePenalty = session.state === "working" ? 0 : session.state === "waiting-input" ? 20 : 60;
104
+ const toolPenalty = session.tool === "unknown" ? 30 : session.tool === "shell" ? 20 : 0;
105
+ return statePenalty + toolPenalty;
106
+ }
107
+
108
+ function groupSortWeight(group: { sessions: SessionListItem[] }): number {
109
+ const minWeight = Math.min(...group.sessions.map(sessionSortWeight));
110
+ return group.sessions.every((session) => !session.project) ? 1000 + minWeight : minWeight;
111
+ }
112
+
113
+ export function buildRows(sessions: SessionListItem[], narrow = false): DashboardRow[] {
114
+ const roots = sessions.filter((session) => !session.parentSessionId);
115
+ const childrenByParent = new Map<string, SessionListItem[]>();
116
+ for (const session of sessions) {
117
+ if (!session.parentSessionId) continue;
118
+ const children = childrenByParent.get(session.parentSessionId) ?? [];
119
+ children.push(session);
120
+ childrenByParent.set(session.parentSessionId, children);
121
+ }
122
+
123
+ const groups = new Map<string, { projectName: string; sessions: SessionListItem[] }>();
124
+ for (const session of roots) {
125
+ const key = getProjectGroupKey(session);
126
+ const group = groups.get(key);
127
+ if (group) {
128
+ group.sessions.push(session);
129
+ } else {
130
+ groups.set(key, {
131
+ projectName: session.project?.displayName ?? (session.source === "standalone"
132
+ ? (session.tool !== "unknown" ? session.tool : "standalone")
133
+ : session.source === "herdr"
134
+ ? session.herdr?.workspace ?? "herdr"
135
+ : session.tmux?.session ?? key),
136
+ sessions: [session],
137
+ });
138
+ }
139
+ }
140
+
141
+ const recency = (group: { sessions: SessionListItem[] }) => Math.max(...group.sessions.map((s) => s.lastActivity));
142
+ const sortedGroups = [...groups.entries()].sort(
143
+ ([, left], [, right]) => groupSortWeight(left) - groupSortWeight(right) || recency(right) - recency(left),
144
+ );
145
+ const rows: DashboardRow[] = sortedGroups.length > 0 ? [{ kind: "gap", projectKey: "__initial-gap__" }] : [];
146
+ sortedGroups.forEach(([projectKey, group], groupIndex) => {
147
+ if (groupIndex > 0) rows.push({ kind: "gap", projectKey });
148
+ group.sessions.sort((left, right) => sessionSortWeight(left) - sessionSortWeight(right) || right.lastActivity - left.lastActivity);
149
+ rows.push({ kind: "project-header", projectKey, projectName: group.projectName });
150
+ for (const session of group.sessions) {
151
+ rows.push({ kind: "session", projectKey, session });
152
+ if (!narrow) {
153
+ const children = (childrenByParent.get(session.id) ?? []).sort(
154
+ (left, right) => sessionSortWeight(left) - sessionSortWeight(right) || right.lastActivity - left.lastActivity,
155
+ );
156
+ for (const child of children) rows.push({ kind: "session", projectKey, session: child });
157
+ }
158
+ }
159
+ });
160
+ return rows;
161
+ }
162
+
163
+ function getProjectGroupKey(session: SessionListItem): string {
164
+ return session.project?.repositoryIdentityPath
165
+ ?? session.project?.gitRootPath
166
+ ?? session.project?.sourcePath
167
+ ?? session.project?.displayName
168
+ ?? (session.source === "standalone"
169
+ ? `standalone:${session.tool}`
170
+ : session.source === "herdr"
171
+ ? `herdr:${session.herdr?.workspaceId ?? session.herdr?.workspace ?? session.herdr?.paneId ?? session.id}`
172
+ : session.tmux?.session ?? session.id);
173
+ }
174
+
175
+ export function moveSelection(rows: DashboardRow[], selectedId: string | undefined, direction: 1 | -1): string | undefined {
176
+ if (rows.length === 0) return selectedId;
177
+ if (!selectedId) {
178
+ const candidates = direction > 0 ? rows : [...rows].reverse();
179
+ return candidates.find((row): row is SessionRow => row.kind === "session")?.session.id;
180
+ }
181
+ const current = rows.findIndex((row) => row.kind === "session" && row.session.id === selectedId);
182
+ if (current < 0) return undefined;
183
+ for (let index = current + direction; index >= 0 && index < rows.length; index += direction) {
184
+ const row = rows[index];
185
+ if (row?.kind === "session") return row.session.id;
186
+ }
187
+ return selectedId;
188
+ }
189
+
190
+ export function visibleRowWindow(rows: DashboardRow[], selectedId: string | undefined, limit: number): DashboardRow[] {
191
+ if (rows.length <= limit) return rows;
192
+ const selected = rows.findIndex((row) => row.kind === "session" && row.session.id === selectedId);
193
+ const start = Math.max(0, Math.min(selected - Math.floor(limit / 2), rows.length - limit));
194
+ return rows.slice(start, start + limit);
195
+ }
196
+
197
+ export function stateGlyph(state: string, spinnerFrame = 0): string {
198
+ if (state === "working") return WORKING_FRAMES[spinnerFrame % WORKING_FRAMES.length] ?? WORKING_FRAMES[0]!;
199
+ if (state === "idle") return "○";
200
+ if (state === "waiting-input") return "?";
201
+ if (state === "shell") return ">";
202
+ if (state === "error") return "✗";
203
+ return "?";
204
+ }
205
+
206
+ function formatBranchLabel(session: SessionListItem): string {
207
+ if (!session.project?.isGitRepository) return "";
208
+ return session.project.branch || "detached";
209
+ }
210
+
211
+ export function formatTargetLabel(session: SessionListItem, narrow = false): string {
212
+ let base: string;
213
+ if (session.parentSessionId) {
214
+ base = ` ↳ ${truncate(session.summary || session.id, narrow ? 22 : 26)}`;
215
+ } else if (session.tmux) {
216
+ const label = session.displayName?.trim()
217
+ || session.tmux.session?.trim()
218
+ || session.id;
219
+ base = ` ▸ ${label}`;
220
+ } else if (session.herdr) {
221
+ const label = session.displayName?.trim()
222
+ || session.herdr.tab?.trim()
223
+ || session.herdr.agent?.trim()
224
+ || session.herdr.paneId;
225
+ base = ` ▹ ${label}`;
226
+ } else {
227
+ const placeholder = session.activeTool
228
+ ? `[${session.activeTool}]`
229
+ : (session.id.includes(":") ? session.id.split(":")[1]!.slice(0, 8) : session.id.slice(0, 8));
230
+ base = ` ◇ ${placeholder}`;
231
+ }
232
+ const branch = formatBranchLabel(session);
233
+ return branch ? `${base}@${branch}` : base;
234
+ }
235
+
236
+ export type SessionToolMode = "text" | "monogram" | "icons";
237
+
238
+ export function sessionColumnWidths(width: number, toolMode: SessionToolMode = "text", toolContentWidth = 0): SessionColumnWidths {
239
+ const narrow = width < 110;
240
+ const available = Math.max(36, width - 9);
241
+ if (narrow) {
242
+ const enhanced = toolMode !== "text";
243
+ const state = 11;
244
+ // On very narrow widths the summary column is a few truncated characters;
245
+ // drop it entirely and hand its space to the session column. The budget
246
+ // matches the pane's real content width (borders + padding + gutter) so
247
+ // rows fill from border to border.
248
+ if (width < 100) {
249
+ const filledAvailable = Math.max(36, width - 6);
250
+ const tool = Math.max(5, (toolContentWidth || (toolMode === "text" ? 10 : 7)) + 1);
251
+ return { session: Math.max(14, filledAvailable - state - tool), state, tool, summary: 0 };
252
+ }
253
+ const tool = toolMode === "text"
254
+ ? Math.min(12, Math.max(9, Math.floor(available * 0.18)))
255
+ : toolMode === "icons" ? 7 : 8;
256
+ const session = Math.min(enhanced ? 32 : 28, Math.max(14, Math.floor(available * (enhanced ? 0.4 : 0.34))));
257
+ return { session, state, tool, summary: Math.max(8, available - session - state - tool) };
258
+ }
259
+ const state = 11;
260
+ const tool = toolMode === "text" ? 13 : toolMode === "icons" ? 3 : 5;
261
+ if (toolMode !== "text") {
262
+ const remaining = available - state - tool;
263
+ const session = Math.min(72, Math.max(30, Math.floor(remaining * 0.55)));
264
+ return { session, state, tool, summary: Math.max(8, available - session - state - tool) };
265
+ }
266
+ const session = Math.min(40, Math.max(30, available - state - tool - 39));
267
+ return { session, state, tool, summary: Math.max(8, available - session - state - tool) };
268
+ }
269
+
270
+ export function formatStatusCell(session: SessionListItem, spinnerFrame: number): string {
271
+ const glyph = stateGlyph(session.state, spinnerFrame);
272
+ return `${glyph} (${formatDuration(session.stableForMs ?? 0)})`;
273
+ }
274
+
275
+ export function activeChildCounts(sessions: SessionListItem[]): Map<string, number> {
276
+ const counts = new Map<string, number>();
277
+ for (const session of sessions) {
278
+ if (!session.parentSessionId || (session.state !== "working" && session.state !== "waiting-input")) continue;
279
+ counts.set(session.parentSessionId, (counts.get(session.parentSessionId) ?? 0) + 1);
280
+ }
281
+ return counts;
282
+ }
283
+
284
+ export function sessionAttachCommand(session: SessionListItem, insideTmux: boolean): string[] | null {
285
+ if (session.source === "tmux" && session.tmux) {
286
+ return insideTmux
287
+ ? ["tmux", "switch-client", "-t", session.tmux.session]
288
+ : ["tmux", "attach-session", "-t", session.tmux.session];
289
+ }
290
+ if (session.source === "herdr" && session.herdr?.terminalId) {
291
+ return ["herdr", "terminal", "attach", session.herdr.terminalId];
292
+ }
293
+ return null;
294
+ }
295
+
@@ -0,0 +1,77 @@
1
+ import type { SessionModel } from "../shared/types";
2
+ import type { SessionListItem, SessionViewCounts } from "../shared/session-view";
3
+ import type { UsageState } from "../usage/types";
4
+ import { cycleFilters, type DashboardFilters, type DashboardMode, type DashboardView } from "./model";
5
+
6
+ export interface DashboardState {
7
+ view: DashboardView;
8
+ mode: DashboardMode;
9
+ sessions: SessionListItem[];
10
+ counts: SessionViewCounts | null;
11
+ usage: UsageState;
12
+ selectedSessionId?: string;
13
+ detail?: SessionModel;
14
+ message?: string;
15
+ daemonConnected: boolean;
16
+ filters: DashboardFilters;
17
+ }
18
+
19
+ export const INITIAL_DASHBOARD_STATE: DashboardState = {
20
+ view: "sessions",
21
+ mode: "list",
22
+ sessions: [],
23
+ counts: null,
24
+ usage: { providers: [], lastRefreshedAt: 0 },
25
+ daemonConnected: true,
26
+ filters: { query: "", actionableOnly: false, tool: "all", state: "all" },
27
+ };
28
+
29
+ export type DashboardAction =
30
+ | { type: "sessions-loaded"; sessions: SessionListItem[]; counts: SessionViewCounts }
31
+ | { type: "usage-loaded"; usage: UsageState }
32
+ | { type: "connection"; connected: boolean }
33
+ | { type: "select"; id?: string }
34
+ | { type: "view"; view: DashboardView }
35
+ | { type: "search" }
36
+ | { type: "query"; query: string }
37
+ | { type: "toggle-actionable" }
38
+ | { type: "cycle-filters" }
39
+ | { type: "detail"; session: SessionModel }
40
+ | { type: "message"; message: string }
41
+ | { type: "bindings" }
42
+ | { type: "close-overlay" };
43
+
44
+ export function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState {
45
+ switch (action.type) {
46
+ case "sessions-loaded": {
47
+ const selectedSessionId = action.sessions.some((session) => session.id === state.selectedSessionId)
48
+ ? state.selectedSessionId
49
+ : undefined;
50
+ return { ...state, sessions: action.sessions, counts: action.counts, selectedSessionId, daemonConnected: true };
51
+ }
52
+ case "usage-loaded":
53
+ return { ...state, usage: action.usage };
54
+ case "connection":
55
+ return { ...state, daemonConnected: action.connected };
56
+ case "select":
57
+ return { ...state, selectedSessionId: action.id };
58
+ case "view":
59
+ return { ...state, view: action.view, mode: "list", detail: undefined, message: undefined };
60
+ case "search":
61
+ return { ...state, mode: "search" };
62
+ case "query":
63
+ return { ...state, filters: { ...state.filters, query: action.query } };
64
+ case "toggle-actionable":
65
+ return { ...state, filters: { ...state.filters, actionableOnly: !state.filters.actionableOnly } };
66
+ case "cycle-filters":
67
+ return { ...state, filters: cycleFilters(state.filters) };
68
+ case "detail":
69
+ return { ...state, mode: "detail", detail: action.session, message: undefined };
70
+ case "message":
71
+ return { ...state, mode: "message", message: action.message, detail: undefined };
72
+ case "bindings":
73
+ return { ...state, mode: "bindings", detail: undefined, message: undefined };
74
+ case "close-overlay":
75
+ return { ...state, mode: "list", detail: undefined, message: undefined };
76
+ }
77
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Scrylog brand palette (source: brand guide, OpenTUI-compatible ANSI sheet).
3
+ *
4
+ * Single source of truth for every color used by the TUI: change a value here
5
+ * (or swap the whole object for an alternate theme) and the whole UI follows.
6
+ */
7
+ export const BRAND = {
8
+ /** Main Teal/Cyan — primary brand color (xterm 80). */
9
+ primary: "#5CCCCC",
10
+ /** Accent Light Blue (xterm 159). */
11
+ accentBlue: "#A1E6FF",
12
+ /** Highlight Violet (xterm 177). */
13
+ violet: "#CFA6FF",
14
+ /** Detail Grey — secondary text (xterm 246). */
15
+ grey: "#A0A0A0",
16
+ /** Background Dark (xterm 234). */
17
+ bgDark: "#1A1A1A",
18
+ /** Modal / floating panel surface (near-black, kept from legacy scheme). */
19
+ surface: "#11151c",
20
+ /** Functional colors kept unchanged (no palette equivalent). */
21
+ danger: "#f05252",
22
+ warning: "#f5a524",
23
+
24
+ /** Legacy chrome visuals preserved by design: pane borders and separators. */
25
+ border: "#495365",
26
+ /** Selected session row background (legacy slate). */
27
+ selection: "#263244",
28
+ /** Fallback foreground for rows whose state has no mapping. */
29
+ fallback: "#d8dee9",
30
+
31
+ /** Session state colors (palette-family tints). */
32
+ stateWorking: "#E8BC5D",
33
+ stateIdle: "#6BD9A4",
34
+ /** Usage semaphore ≥90% (soft coral, danger family). */
35
+ usageHigh: "#E28686",
36
+ } as const;
37
+
38
+ export interface ProviderVisual {
39
+ name: string;
40
+ short: string;
41
+ brandColor: string;
42
+ borderColor: string;
43
+ /** Alternative brand color readable on dark terminal backgrounds. */
44
+ onDarkColor?: string;
45
+ tileColor?: string;
46
+ terminalAsset?: URL;
47
+ graphicsAsset?: URL;
48
+ }
49
+
50
+ /** Third-party provider brand colors: data, not brand palette. */
51
+ export const PROVIDER_BRANDS: Record<string, ProviderVisual> = {
52
+ claude: {
53
+ name: "Claude",
54
+ short: "CL",
55
+ brandColor: "#d97757",
56
+ borderColor: "#d97757",
57
+ terminalAsset: new URL("./assets/providers/claude-terminal.png", import.meta.url),
58
+ graphicsAsset: new URL("./assets/providers/claude.png", import.meta.url),
59
+ },
60
+ codex: {
61
+ name: "Codex",
62
+ short: "CX",
63
+ brandColor: "#539af8",
64
+ borderColor: "#539af8",
65
+ terminalAsset: new URL("./assets/providers/openai-terminal.png", import.meta.url),
66
+ graphicsAsset: new URL("./assets/providers/openai.png", import.meta.url),
67
+ },
68
+ "opencode-go": {
69
+ name: "OpenCode Go",
70
+ short: "OC",
71
+ brandColor: "#111111",
72
+ onDarkColor: "#c8cdd8",
73
+ borderColor: BRAND.grey,
74
+ tileColor: "#f3f3f3",
75
+ terminalAsset: new URL("./assets/providers/opencode-terminal.png", import.meta.url),
76
+ graphicsAsset: new URL("./assets/providers/opencode.png", import.meta.url),
77
+ },
78
+ copilot: {
79
+ name: "Copilot",
80
+ short: "CP",
81
+ brandColor: "#c8cdd8",
82
+ borderColor: BRAND.grey,
83
+ graphicsAsset: new URL("./assets/providers/copilot.png", import.meta.url),
84
+ },
85
+ shell: {
86
+ name: "Shell",
87
+ short: "SH",
88
+ brandColor: "#c8cdd8",
89
+ borderColor: BRAND.grey,
90
+ graphicsAsset: new URL("./assets/providers/shell.png", import.meta.url),
91
+ },
92
+ unknown: {
93
+ name: "Unknown",
94
+ short: "?",
95
+ brandColor: BRAND.grey,
96
+ borderColor: BRAND.grey,
97
+ graphicsAsset: new URL("./assets/providers/unknown.png", import.meta.url),
98
+ },
99
+ };
100
+
101
+ export const BRAND_MARK = {
102
+ /** Blocks: front-view crystal ball + prompt + circuit pedestal, brand palette. */
103
+ art: [
104
+ { fg: BRAND.accentBlue, content: " ▗▄▄▄▄▄▄▖" },
105
+ { fg: BRAND.primary, content: " ▐▓▒░░░░▒▓▌" },
106
+ { fg: BRAND.primary, content: " ░ ◉◈◉ ›_ ░" },
107
+ { fg: BRAND.violet, content: " ▜▓▒░░░░▒▓▛" },
108
+ { fg: BRAND.violet, content: " ▐█╿██╵█▌" },
109
+ ],
110
+ } as const;
@@ -297,7 +297,7 @@ These clients use the global `fetch` and have no Node.js or platform dependencie
297
297
  |---|---|---|
298
298
  | `enabled` | `true` | Enable usage collection |
299
299
  | `pollIntervalMs` | `300000` | Polling interval (5 minutes) |
300
- | `providers` | `["claude", "opencode-go"]` | Which providers to poll |
300
+ | `providers` | `["claude", "opencode-go", "codex"]` | Which providers to poll |
301
301
  | `claude.dataSource` | `"auto"` | `"auto"`, `"oauth"`, `"web"`, or `"cli"` |
302
302
  | `claude.webExtrasEnabled` | `true` | Enrich OAuth/CLI with Web API extras |
303
303
  | `codex.dataSource` | `"auto"` | `"auto"` or `"oauth"` |
@@ -311,7 +311,7 @@ These clients use the global `fetch` and have no Node.js or platform dependencie
311
311
  | Claude OAuth (`~/.claude/.credentials.json`) | ⚠️ (fallback only) | ✅ | ✅ | ✅ |
312
312
  | Claude Web (browser cookies) | ✅ (Chrome + Safari) | ✅ (Chrome/Chromium) | ✅ (Chrome/Chromium) | ❌ (Chrome: DPAPI not implemented) |
313
313
  | Claude CLI (PTY probe) | ✅ (`script` wrapper) | ⚠️ (no PTY wrapper) | ⚠️ (no PTY wrapper) | ⚠️ (no PTY wrapper) |
314
- | Codex OAuth (`~/.codex/auth.json`) | ✅ | ✅ | ✅ | ✅ (filepath) |
314
+ | Codex OAuth + owner CLI recovery | ✅ | ✅ | ✅ | ⚠️ (CLI launch is experimental) |
315
315
  | OpenCode Go (browser cookies) | ✅ | ✅ | ✅ | ❌ (Chrome: DPAPI not implemented) |
316
316
 
317
317
  ## Adding a new provider
@@ -319,5 +319,5 @@ These clients use the global `fetch` and have no Node.js or platform dependencie
319
319
  1. Create resolver + client in `src/usage/resolvers/` and `src/usage/clients/`
320
320
  2. Create provider in `src/usage/providers/<name>.ts` that returns `{ entry: UsageProviderEntry, snapshot: any }`
321
321
  3. Add mapper in `src/usage/mappers.ts`: `map<Name>ToEntry(snapshot, errors, available)` — include `accountId` computation
322
- 4. Register in `UsageCollector.poll()` with availability pre-check
322
+ 4. Register in `UsageCollector.poll()`; the provider returns an unavailable entry when credentials are absent
323
323
  5. No changes needed to `UsageState`, Zod schemas, or API route
@@ -1,27 +1,91 @@
1
1
  import type { UsageClient } from "../client";
2
2
  import type { ClaudeOAuthCredentials, ClaudeUsageSnapshot, NamedRateWindowLegacy, ProviderCostSnapshotLegacy, RateWindowLegacy } from "../types";
3
+ import { classifyUsageError, UsageError } from "../errors";
3
4
 
4
- export class AuthenticationError extends Error {
5
+ export class AuthenticationError extends UsageError {
5
6
  constructor(message: string) {
6
- super(message);
7
+ super("invalid-credentials", message);
7
8
  this.name = "AuthenticationError";
8
9
  }
9
10
  }
10
11
 
11
- export class ScopeError extends Error {
12
+ export class ScopeError extends UsageError {
12
13
  constructor(message: string) {
13
- super(message);
14
+ super("forbidden", message);
14
15
  this.name = "ScopeError";
15
16
  }
16
17
  }
17
18
 
18
- export class ParseError extends Error {
19
+ export class ParseError extends UsageError {
19
20
  constructor(message: string) {
20
- super(message);
21
+ super("invalid-response", message);
21
22
  this.name = "ParseError";
22
23
  }
23
24
  }
24
25
 
26
+ export interface OAuthProfileIdentity {
27
+ email: string | null;
28
+ organizationId: string | null;
29
+ }
30
+
31
+ function readTrimmedString(record: Record<string, unknown> | undefined | null, keys: string[]): string | null {
32
+ if (!record || typeof record !== "object" || Array.isArray(record)) return null;
33
+ for (const key of keys) {
34
+ const value = record[key];
35
+ if (typeof value === "string" && value.trim()) return value.trim();
36
+ }
37
+ return null;
38
+ }
39
+
40
+ export function parseOAuthProfile(data: unknown): OAuthProfileIdentity {
41
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
42
+ return { email: null, organizationId: null };
43
+ }
44
+ const root = data as Record<string, unknown>;
45
+ const account = root["account"] as Record<string, unknown> | null | undefined;
46
+ const organization = root["organization"] as Record<string, unknown> | null | undefined;
47
+ const email = readTrimmedString(account, ["emailAddress", "email_address", "email"])
48
+ ?? readTrimmedString(root, ["emailAddress", "email_address", "email"]);
49
+ const organizationId = readTrimmedString(organization, ["uuid"])
50
+ ?? readTrimmedString(root, ["organizationUuid", "organization_uuid"]);
51
+ return { email, organizationId };
52
+ }
53
+
54
+ export async function fetchOAuthProfile(
55
+ accessToken: string,
56
+ options?: { signal?: AbortSignal },
57
+ ): Promise<OAuthProfileIdentity> {
58
+ let res: Response;
59
+ try {
60
+ res = await fetch("https://api.anthropic.com/api/oauth/profile", {
61
+ signal: options?.signal,
62
+ headers: {
63
+ Authorization: `Bearer ${accessToken}`,
64
+ Accept: "application/json",
65
+ "Content-Type": "application/json",
66
+ },
67
+ });
68
+ } catch (error) {
69
+ throw classifyUsageError(error);
70
+ }
71
+ if (res.status === 401) {
72
+ throw new AuthenticationError("OAuth profile unauthorized");
73
+ }
74
+ if (res.status >= 500) {
75
+ throw new UsageError("transient", `OAuth profile server error: ${res.status}`);
76
+ }
77
+ if (!res.ok) {
78
+ throw new UsageError("invalid-response", `Unexpected OAuth profile response: ${res.status}`);
79
+ }
80
+ let data: unknown;
81
+ try {
82
+ data = await res.json();
83
+ } catch {
84
+ throw new ParseError("OAuth profile response was not valid JSON");
85
+ }
86
+ return parseOAuthProfile(data);
87
+ }
88
+
25
89
  interface OAuthUsageResponse {
26
90
  five_hour?: { utilization?: number | null; resets_at?: string | null } | null;
27
91
  seven_day?: { utilization?: number | null; resets_at?: string | null } | null;
@@ -89,15 +153,20 @@ function toRateWindow(
89
153
 
90
154
  export class AnthropicOAuthUsageClient implements UsageClient<ClaudeOAuthCredentials, ClaudeUsageSnapshot> {
91
155
  async fetch(creds: ClaudeOAuthCredentials, options?: { signal?: AbortSignal }): Promise<ClaudeUsageSnapshot> {
92
- const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
93
- signal: options?.signal,
94
- headers: {
95
- Authorization: `Bearer ${creds.accessToken}`,
96
- Accept: "application/json",
97
- "Content-Type": "application/json",
98
- "anthropic-beta": "oauth-2025-04-20",
99
- },
100
- });
156
+ let res: Response;
157
+ try {
158
+ res = await fetch("https://api.anthropic.com/api/oauth/usage", {
159
+ signal: options?.signal,
160
+ headers: {
161
+ Authorization: `Bearer ${creds.accessToken}`,
162
+ Accept: "application/json",
163
+ "Content-Type": "application/json",
164
+ "anthropic-beta": "oauth-2025-04-20",
165
+ },
166
+ });
167
+ } catch (error) {
168
+ throw classifyUsageError(error);
169
+ }
101
170
 
102
171
  if (res.status === 401) {
103
172
  throw new AuthenticationError("OAuth token expired or invalid");
@@ -107,13 +176,23 @@ export class AnthropicOAuthUsageClient implements UsageClient<ClaudeOAuthCredent
107
176
  if (body.includes("user:profile")) {
108
177
  throw new ScopeError("Token missing required scope user:profile");
109
178
  }
110
- throw new Error(`Server error: ${res.status}`);
179
+ throw new ScopeError(`OAuth access forbidden (${res.status})`);
111
180
  }
181
+ if (res.status === 429) {
182
+ const retryAfter = Number(res.headers.get("retry-after"));
183
+ throw new UsageError("rate-limited", "OAuth usage request rate limited", Number.isFinite(retryAfter) ? retryAfter * 1000 : undefined);
184
+ }
185
+ if (res.status >= 500) throw new UsageError("transient", `OAuth usage server error: ${res.status}`);
112
186
  if (!res.ok) {
113
- throw new Error(`Server error: ${res.status}`);
187
+ throw new UsageError("invalid-response", `Unexpected OAuth usage response: ${res.status}`);
114
188
  }
115
189
 
116
- const data = (await res.json()) as OAuthUsageResponse;
190
+ let data: OAuthUsageResponse;
191
+ try {
192
+ data = (await res.json()) as OAuthUsageResponse;
193
+ } catch {
194
+ throw new ParseError("OAuth usage response was not valid JSON");
195
+ }
117
196
 
118
197
  if (!data.five_hour || typeof data.five_hour.utilization !== "number") {
119
198
  throw new ParseError("Missing five_hour.utilization");
@@ -170,9 +249,10 @@ export class AnthropicOAuthUsageClient implements UsageClient<ClaudeOAuthCredent
170
249
  updatedAt: new Date(),
171
250
  accountEmail: null,
172
251
  accountOrganization: null,
252
+ accountOrganizationId: null,
173
253
  loginMethod: null,
174
- subscriptionType: null,
175
- rateLimitTier: null,
254
+ subscriptionType: creds.subscriptionType ?? null,
255
+ rateLimitTier: creds.rateLimitTier ?? null,
176
256
  };
177
257
  }
178
258
  }