@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,128 @@
1
+ import { listSessions } from "@joeldmtz/prtl-core";
2
+ import type { ListSessionsOptions, PrtlListResult } from "@joeldmtz/prtl-core";
3
+ import type { ScrylogConfig } from "../../../shared/config";
4
+ import type { SessionModel } from "../../../shared/types";
5
+ import type { Logger } from "../../../shared/logger";
6
+ import { NOOP_LOGGER } from "../../../shared/logger";
7
+ import type { Collector } from "../../scheduler";
8
+ import type { DaemonStore } from "../../store";
9
+ import type { GitCollector } from "../git";
10
+ import { herdrPaneToSession } from "./normalization";
11
+
12
+ type HerdrList = (options?: ListSessionsOptions) => Promise<PrtlListResult>;
13
+
14
+ export class HerdrCollector implements Collector {
15
+ name = "herdr";
16
+ private timer: ReturnType<typeof setInterval> | null = null;
17
+ private inFlight = false;
18
+ private generation = 0;
19
+ private logger: Logger = NOOP_LOGGER;
20
+
21
+ constructor(
22
+ private readonly gitCollector: GitCollector,
23
+ private readonly list: HerdrList = listSessions,
24
+ ) {}
25
+
26
+ start(store: DaemonStore, config: ScrylogConfig, logger: Logger): void {
27
+ this.logger = logger;
28
+ const generation = ++this.generation;
29
+ const run = () => void this.collect(store, config.daemon.commandTimeoutMs, generation);
30
+ logger.info("herdr", "Collector started", { event: "collector.started", collector: this.name, intervalMs: config.daemon.pollIntervalMs });
31
+ run();
32
+ this.timer = setInterval(run, config.daemon.pollIntervalMs);
33
+ }
34
+
35
+ stop(): void {
36
+ this.generation += 1;
37
+ if (this.timer) {
38
+ clearInterval(this.timer);
39
+ this.timer = null;
40
+ }
41
+ this.logger.info("herdr", "Collector stopped", { event: "collector.stopped", collector: this.name });
42
+ }
43
+
44
+ async collect(store: DaemonStore, timeoutMs: number, generation = this.generation): Promise<void> {
45
+ if (this.inFlight) {
46
+ this.logger.debug("herdr", "Skipping overlapping poll", { event: "collector.poll_skipped", collector: this.name });
47
+ return;
48
+ }
49
+
50
+ this.inFlight = true;
51
+ const startedAt = Date.now();
52
+ try {
53
+ const result = await this.list({ provider: "herdr", timeoutMs });
54
+ if (generation !== this.generation) return;
55
+ const provider = result.metadata.providers.herdr;
56
+ const liveHerdrIds = new Set<string>();
57
+
58
+ if (provider?.available) {
59
+ for (const pane of result.items.filter((item) => item.provider === "herdr")) {
60
+ const path = pane.foregroundCwd ?? pane.cwd;
61
+ const session = herdrPaneToSession(pane, path ? this.gitCollector.resolve(path) : undefined);
62
+ liveHerdrIds.add(session.id);
63
+ const existing = store.getSession(session.id);
64
+ if (existing && existing.detectionSource !== "herdr") {
65
+ if (existing.source !== "tmux") {
66
+ store.upsertSession({
67
+ ...existing,
68
+ source: "herdr",
69
+ herdr: session.herdr,
70
+ project: session.project ?? existing.project,
71
+ currentPath: existing.currentPath ?? session.currentPath,
72
+ currentCommand: session.currentCommand,
73
+ paneTitle: session.paneTitle,
74
+ });
75
+ }
76
+ } else {
77
+ // herdr-native sessions: prtl-core refreshes metadata.collectedAt on
78
+ // every collection, so a pane doing nothing would report a fresh
79
+ // lastActivity each poll and constantly reorder idle groups. Only
80
+ // real changes (state, agent status, summary) count as activity.
81
+ const previous = existing?.detectionSource === "herdr" ? existing : undefined;
82
+ const activityChanged = Boolean(
83
+ previous
84
+ && (previous.state !== session.state
85
+ || previous.herdr?.agentStatus !== session.herdr?.agentStatus
86
+ || previous.summary !== session.summary),
87
+ );
88
+ const merged: SessionModel = previous && !activityChanged
89
+ ? { ...session, lastActivity: previous.lastActivity }
90
+ : { ...session, lastActivity: Date.now() };
91
+ store.upsertSession(merged);
92
+ }
93
+ }
94
+
95
+ for (const session of store.getSessions()) {
96
+ if (session.source !== "herdr" || liveHerdrIds.has(session.id)) continue;
97
+ if (session.detectionSource === "herdr") {
98
+ store.removeSession(session.id);
99
+ } else {
100
+ store.clearHerdrBinding(session.id);
101
+ }
102
+ }
103
+ }
104
+
105
+ const errors = provider?.errors?.map((error) => `${error.source}: ${error.message}`) ?? [];
106
+ const lastError = provider?.available ? undefined : (provider?.reason ?? errors.at(-1) ?? "herdr_unavailable");
107
+ store.setCollectorStatus(this.name, {
108
+ running: true,
109
+ lastRunAt: Date.now(),
110
+ lastDurationMs: Date.now() - startedAt,
111
+ lastError,
112
+ });
113
+ this.logger.debug("herdr", "Collection completed", {
114
+ event: "collector.poll_completed",
115
+ collector: this.name,
116
+ available: provider?.available ?? false,
117
+ panes: liveHerdrIds.size,
118
+ });
119
+ } catch (error) {
120
+ if (generation !== this.generation) return;
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now(), lastDurationMs: Date.now() - startedAt, lastError: message });
123
+ this.logger.warn("herdr", "Collection failed", { event: "collector.poll_failed", collector: this.name, errorMessage: message });
124
+ } finally {
125
+ this.inFlight = false;
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,2 @@
1
+ export { HerdrCollector } from "./collector";
2
+ export { herdrPaneToSession, herdrSessionId, herdrState, herdrSummary, herdrTool } from "./normalization";
@@ -0,0 +1,111 @@
1
+ import type { PrtlPane } from "@joeldmtz/prtl-core";
2
+ import type { ProjectInfo, SessionModel, SessionState, SessionTool } from "../../../shared/types";
3
+
4
+ const SESSION_TOOLS = new Set<SessionTool>(["opencode", "claude", "codex"]);
5
+
6
+ function normalizeAgentName(value: string): string {
7
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
8
+ }
9
+
10
+ export function herdrTool(pane: PrtlPane): SessionTool {
11
+ const candidates = [pane.agent?.session?.agent, pane.agent?.name, pane.agent?.session?.source?.split(":").at(-1)];
12
+ for (const candidate of candidates) {
13
+ if (!candidate) continue;
14
+ const normalized = normalizeAgentName(candidate);
15
+ if (normalized === "opencode") return "opencode";
16
+ if (normalized === "claude" || normalized === "claudecode") return "claude";
17
+ if (normalized === "codex" || normalized === "codexcli") return "codex";
18
+ if (normalized === "copilot" || normalized === "copilotcli" || normalized === "githubcopilot") return "copilotcli";
19
+ }
20
+ if (!pane.agent && pane.command) return "shell";
21
+ return "unknown";
22
+ }
23
+
24
+ export function herdrState(pane: PrtlPane, tool = herdrTool(pane)): SessionState {
25
+ if (!pane.agent) return tool === "shell" ? "shell" : "unknown";
26
+ switch (pane.agent.status?.toLowerCase()) {
27
+ case "working": return "working";
28
+ case "idle":
29
+ case "done": return "idle";
30
+ case "blocked": return "waiting-input";
31
+ default: return "unknown";
32
+ }
33
+ }
34
+
35
+ export function herdrSessionId(pane: PrtlPane, tool = herdrTool(pane)): string {
36
+ const agentSessionId = pane.agent?.sessionId ?? pane.agent?.session?.value;
37
+ return agentSessionId && SESSION_TOOLS.has(tool)
38
+ ? `${tool}:${agentSessionId}`
39
+ : `herdr:${pane.id}`;
40
+ }
41
+
42
+ export function herdrSummary(pane: PrtlPane): string {
43
+ const statusLabel = pane.agent?.status ? pane.stateLabels?.[pane.agent.status] : undefined;
44
+ return pane.customStatus
45
+ ?? statusLabel
46
+ ?? pane.title
47
+ ?? pane.terminalTitleStripped
48
+ ?? pane.displayAgent
49
+ ?? pane.command
50
+ ?? pane.agent?.name
51
+ ?? pane.tab
52
+ ?? pane.workspace
53
+ ?? "Herdr pane";
54
+ }
55
+
56
+ /**
57
+ * Explicit pane name only. prtl-core's `pane.name` falls back to the agent
58
+ * name when the pane has no label; in that case the pane is unnamed and the
59
+ * label logic must fall back to the tab name instead. The raw herdr record
60
+ * carries the original pane fields, which is the only way to distinguish an
61
+ * explicit name from prtl-core's agent-derived fallback.
62
+ */
63
+ export function herdrPaneName(pane: PrtlPane): string | undefined {
64
+ const raw = (pane.metadata?.raw as { pane?: { title?: unknown; label?: unknown; name?: unknown } } | undefined)?.pane;
65
+ if (raw) {
66
+ const explicit = [raw.name, raw.title, raw.label].find(
67
+ (value) => typeof value === "string" && value.trim().length > 0,
68
+ );
69
+ return typeof explicit === "string" ? explicit.trim() : undefined;
70
+ }
71
+ // No raw record to discriminate: only trust an explicitly-typed PrtlPane
72
+ // name field (raw pane title/title/label from the provider), never render
73
+ // the agent-derived fallback as a pane name.
74
+ return pane.title?.trim() || undefined;
75
+ }
76
+
77
+ export function herdrPaneToSession(pane: PrtlPane, project?: ProjectInfo): SessionModel {
78
+ const tool = herdrTool(pane);
79
+ const state = herdrState(pane, tool);
80
+ const agentSessionId = pane.agent?.sessionId ?? pane.agent?.session?.value;
81
+ const collectedAt = Date.parse(pane.metadata.collectedAt);
82
+ return {
83
+ id: herdrSessionId(pane, tool),
84
+ source: "herdr",
85
+ tool,
86
+ state,
87
+ project,
88
+ summary: herdrSummary(pane),
89
+ lastActivity: Number.isFinite(collectedAt) ? collectedAt : Date.now(),
90
+ hasAttention: state === "waiting-input",
91
+ activeTool: pane.agent?.name,
92
+ detectionSource: "herdr",
93
+ currentCommand: pane.command,
94
+ currentPath: pane.foregroundCwd ?? pane.cwd,
95
+ paneTitle: pane.title,
96
+ herdr: {
97
+ paneId: pane.id,
98
+ terminalId: pane.terminalId,
99
+ workspaceId: pane.workspaceId,
100
+ workspace: pane.workspace,
101
+ tabId: pane.tabId,
102
+ tab: pane.tab,
103
+ name: herdrPaneName(pane),
104
+ focused: pane.focused,
105
+ providerSource: pane.metadata.source,
106
+ agent: pane.agent?.name,
107
+ agentStatus: pane.agent?.status,
108
+ agentSessionId,
109
+ },
110
+ };
111
+ }
@@ -258,8 +258,9 @@ export class OpenCodeCollector implements Collector {
258
258
  inst.url,
259
259
  inst.pendingPermissions,
260
260
  inst.pendingQuestions,
261
+ this.gitCollector,
261
262
  );
262
- const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention };
263
+ const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention, project: updated.project ?? existing.project };
263
264
  inst.sessions.set(props.sessionID, merged);
264
265
  store.upsertSession(merged);
265
266
  if (prevState !== merged.state) {
@@ -385,8 +386,9 @@ export class OpenCodeCollector implements Collector {
385
386
  inst.url,
386
387
  inst.pendingPermissions,
387
388
  inst.pendingQuestions,
389
+ this.gitCollector,
388
390
  );
389
- const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention };
391
+ const merged: SessionModel = { ...existing, state: updated.state, hasAttention: updated.hasAttention, project: updated.project ?? existing.project };
390
392
  inst.sessions.set(sessionId, merged);
391
393
  store.upsertSession(merged);
392
394
  if (prevState !== merged.state) {
@@ -5,7 +5,7 @@
5
5
  * existing sessions in the store. Runs every 30 seconds.
6
6
  *
7
7
  * Priority rules:
8
- * - NEVER overwrites `state` — only metadata fields are written.
8
+ * - Preserves state from native collectors; a matching herdr placeholder is claimed as idle.
9
9
  * - If a session already exists (from SSE/webhook), merges title/directory/lastActivity.
10
10
  * - If a session does NOT exist yet, adds it with state="idle" and detectionSource="opencode-db".
11
11
  */
@@ -39,6 +39,16 @@ export function inferChildSessionState(
39
39
  return "working";
40
40
  }
41
41
 
42
+ export function claimHerdrSessionForOpenCodeDb(existing: SessionModel, sessionId: string): SessionModel {
43
+ if (existing.detectionSource !== "herdr") return existing;
44
+ return {
45
+ ...existing,
46
+ state: "idle",
47
+ detectionSource: "opencode-db",
48
+ opencode: existing.opencode ?? { sessionId, instanceUrl: "" },
49
+ };
50
+ }
51
+
42
52
  // ── DB path resolution ────────────────────────────────────────────────────────
43
53
 
44
54
  /**
@@ -202,6 +212,8 @@ export class OpenCodeDbCollector implements Collector {
202
212
  const existing = store.getSession(storeId);
203
213
 
204
214
  if (existing) {
215
+ const replacingHerdr = existing.detectionSource === "herdr";
216
+ const owned = claimHerdrSessionForOpenCodeDb(existing, row.id);
205
217
  // Merge-only: preserve state, just update metadata
206
218
  const newParentSessionId = row.parent_id ? `opencode:${row.parent_id}` : undefined;
207
219
  const effectiveParentSessionId = newParentSessionId ?? existing.parentSessionId;
@@ -211,19 +223,19 @@ export class OpenCodeDbCollector implements Collector {
211
223
  existing.lastActivity !== row.time_updated ||
212
224
  existing.parentSessionId !== newParentSessionId;
213
225
  const targetState = inferChildSessionState(
214
- existing,
226
+ owned,
215
227
  effectiveParentSessionId ? store.getSession(effectiveParentSessionId) : undefined,
216
228
  config,
217
229
  now,
218
230
  );
219
231
 
220
- if (needsUpdate || (targetState !== undefined && targetState !== existing.state)) {
232
+ if (replacingHerdr || needsUpdate || (targetState !== undefined && targetState !== existing.state)) {
221
233
  const project = row.directory
222
234
  ? (this.gitCollector?.resolve(row.directory) ?? existing.project)
223
235
  : existing.project;
224
236
  const merged: SessionModel = {
225
- ...existing,
226
- state: targetState ?? existing.state,
237
+ ...owned,
238
+ state: targetState ?? owned.state,
227
239
  summary: row.title || existing.summary,
228
240
  currentPath: row.directory ?? existing.currentPath,
229
241
  lastActivity: Math.max(existing.lastActivity, row.time_updated),
@@ -169,6 +169,7 @@ export class TmuxCollector implements Collector {
169
169
  tmux: {
170
170
  session: pane.sessionName,
171
171
  window: String(pane.windowIndex),
172
+ windowName: pane.windowName,
172
173
  pane: pane.paneId,
173
174
  },
174
175
  legacyPaneId: pane.paneId,
@@ -230,6 +231,7 @@ export class TmuxCollector implements Collector {
230
231
  tmux: {
231
232
  session: pane.sessionName,
232
233
  window: String(pane.windowIndex),
234
+ windowName: pane.windowName,
233
235
  pane: pane.paneId,
234
236
  },
235
237
  legacyPaneId: pane.paneId,
@@ -295,6 +297,7 @@ export class TmuxCollector implements Collector {
295
297
  tmux: {
296
298
  session: pane.sessionName,
297
299
  window: String(pane.windowIndex),
300
+ windowName: pane.windowName,
298
301
  pane: pane.paneId,
299
302
  },
300
303
  legacyPaneId: pane.paneId,
@@ -365,6 +368,7 @@ export class TmuxCollector implements Collector {
365
368
  tmux: {
366
369
  session: pane.sessionName,
367
370
  window: String(pane.windowIndex),
371
+ windowName: pane.windowName,
368
372
  pane: pane.paneId,
369
373
  },
370
374
  legacyPaneId: pane.paneId,
@@ -416,6 +420,7 @@ export class TmuxCollector implements Collector {
416
420
  tmux: {
417
421
  session: pane.sessionName,
418
422
  window: String(pane.windowIndex),
423
+ windowName: pane.windowName,
419
424
  pane: pane.paneId,
420
425
  },
421
426
  legacyPaneId: pane.paneId,
@@ -467,6 +472,21 @@ export class TmuxCollector implements Collector {
467
472
  (s) => !(s.source === "tmux" && s.detectionSource === "fallback-detection"),
468
473
  );
469
474
 
470
- store.setSessions([...nonFallback, ...newFallbackSessions]);
475
+ // Fallback sessions are rebuilt from scratch every poll. Git project
476
+ // resolution can transiently fail (or currentPath can come through empty),
477
+ // so inherit the previously-resolved project/currentPath to keep the TUI
478
+ // group membership stable instead of oscillating between polls.
479
+ const previousFallback = new Map(
480
+ current
481
+ .filter((s) => s.source === "tmux" && s.detectionSource === "fallback-detection")
482
+ .map((s) => [s.id, s]),
483
+ );
484
+ const mergedFallbacks = newFallbackSessions.map((session) => {
485
+ const prev = previousFallback.get(session.id);
486
+ if (!prev || session.project) return session;
487
+ return { ...session, project: prev.project, currentPath: session.currentPath || prev.currentPath };
488
+ });
489
+
490
+ store.setSessions([...nonFallback, ...mergedFallbacks]);
471
491
  }
472
492
  }
@@ -218,6 +218,7 @@ export function paneModelToSession(pane: ReturnType<typeof classifyPane>["pane"]
218
218
  tmux: {
219
219
  session: pane.sessionName,
220
220
  window: String(pane.windowIndex),
221
+ windowName: pane.windowName,
221
222
  pane: pane.paneId,
222
223
  },
223
224
  summary: codexPrompt?.summary ?? claudePrompt?.summary ?? pane.summary,
@@ -10,9 +10,16 @@ import { ClaudeWebResolver } from "../../usage/resolvers/claude-web";
10
10
  import { ClaudeCliResolver } from "../../usage/resolvers/claude-cli";
11
11
  import { CodexAuthResolver } from "../../usage/resolvers/codex-auth";
12
12
  import { OpenCodeGoWebResolver } from "../../usage/resolvers/opencode-go-web";
13
+ import { keychainCoordinator } from "../../usage/resolvers/keychain-coordinator";
13
14
  import type { DaemonStore } from "../store";
14
15
  import type { Collector } from "../scheduler";
15
16
 
17
+ interface UsageResolverSet {
18
+ claudeProvider: ClaudeUsageProvider;
19
+ codexProvider: CodexUsageProvider;
20
+ opencodeGoProvider: OpenCodeGoUsageProvider;
21
+ }
22
+
16
23
  export class UsageCollector implements Collector {
17
24
  name = "usage";
18
25
  private timer?: ReturnType<typeof setInterval>;
@@ -22,11 +29,24 @@ export class UsageCollector implements Collector {
22
29
  private polling = false;
23
30
  private skippedPolls = 0;
24
31
  private errorCount = 0;
32
+ private pollController?: AbortController;
33
+ private resolverSet?: UsageResolverSet;
25
34
 
26
35
  async start(store: DaemonStore, config: ScrylogConfig, logger: Logger): Promise<void> {
27
36
  this.store = store;
28
37
  this.config = config;
29
38
  this.logger = logger;
39
+ keychainCoordinator.configure({
40
+ allowPrompts: () => config.usage.auth.allowKeychainPrompts,
41
+ onFailure: (failure) =>
42
+ logger.warn("usage", "Keychain read failed", {
43
+ event: "keychain_read_failed",
44
+ code: failure.code,
45
+ service: failure.service,
46
+ account: failure.account ?? null,
47
+ cooldownUntil: failure.cooldownUntil ? new Date(failure.cooldownUntil).toISOString() : null,
48
+ }),
49
+ });
30
50
 
31
51
  logger.info("usage", "Collector started", { event: "collector.started", collector: this.name, intervalMs: config.usage.pollIntervalMs, providers: config.usage.providers });
32
52
 
@@ -40,6 +60,10 @@ export class UsageCollector implements Collector {
40
60
  clearInterval(this.timer);
41
61
  this.timer = undefined;
42
62
  }
63
+ this.pollController?.abort();
64
+ // Drop resolver/provider instances so a later start() rebuilds them from
65
+ // the current configuration instead of reusing stale constructor args.
66
+ this.resolverSet = undefined;
43
67
  this.logger?.info("usage", "Collector stopped", { event: "collector.stopped", collector: this.name });
44
68
  }
45
69
 
@@ -53,49 +77,43 @@ export class UsageCollector implements Collector {
53
77
  }
54
78
 
55
79
  this.polling = true;
80
+ const pollController = new AbortController();
81
+ this.pollController = pollController;
56
82
  const startedAt = Date.now();
57
83
  try {
58
- await this.poll();
84
+ await this.poll(pollController.signal);
59
85
  store.setCollectorStatus(this.name, { lastDurationMs: Date.now() - startedAt, skippedPolls: this.skippedPolls, errorCount: this.errorCount });
86
+ } catch (error) {
87
+ if (!pollController.signal.aborted) {
88
+ this.errorCount += 1;
89
+ this.logger?.warn("usage", "Poll failed", {
90
+ event: "collector.poll_failed",
91
+ collector: this.name,
92
+ errorMessage: error instanceof Error ? error.message : String(error),
93
+ });
94
+ }
60
95
  } finally {
96
+ if (this.pollController === pollController) this.pollController = undefined;
61
97
  this.polling = false;
62
98
  }
63
99
  }
64
100
 
65
- private async poll(): Promise<void> {
101
+ private async poll(signal: AbortSignal): Promise<void> {
66
102
  const store = this.store!;
67
103
  const config = this.config!;
68
104
  const logger = this.logger!;
69
105
 
106
+ const resolvers = this.ensureResolvers(config);
70
107
  const providers: UsageProviderEntry[] = [];
71
108
  const attemptedAt = Date.now();
72
109
 
73
110
  for (const providerName of config.usage.providers) {
74
111
  if (providerName === "claude") {
75
- const oauthResolver = new ClaudeAuthResolver();
76
- const webResolver = new ClaudeWebResolver();
77
- const cliResolver = new ClaudeCliResolver();
78
- const planner = new ClaudeSourcePlanner(
79
- {
80
- dataSource: config.usage.claude.dataSource,
81
- webExtrasEnabled: config.usage.claude.webExtrasEnabled,
82
- },
83
- oauthResolver,
84
- webResolver,
85
- cliResolver,
86
- );
87
-
88
- const decision = await planner.decide();
89
- if (decision.primary === "auto") {
90
- logger.debug("usage", "Claude not available on this machine, omitting from response", { event: "provider.unavailable", provider: "claude" });
91
- continue;
92
- }
93
-
94
- const claudeProvider = new ClaudeUsageProvider(planner, oauthResolver, webResolver, cliResolver);
95
112
  try {
96
- const result = await withAbort((signal) => claudeProvider.fetch({ signal }), config.daemon.networkTimeoutMs, "Claude usage fetch timed out");
113
+ const result = await withAbort((requestSignal) => resolvers.claudeProvider.fetch({ signal: requestSignal }), config.daemon.networkTimeoutMs, "Claude usage fetch timed out", signal);
97
114
  providers.push(result.entry);
98
115
  } catch (err) {
116
+ if (signal.aborted) throw err;
99
117
  this.errorCount += 1;
100
118
  const message = err instanceof Error ? err.message : String(err);
101
119
  logger.warn("usage", "Claude provider unexpected error", { event: "provider.fetch_failed", provider: "claude", errorMessage: message });
@@ -108,19 +126,11 @@ export class UsageCollector implements Collector {
108
126
  });
109
127
  }
110
128
  } else if (providerName === "codex") {
111
- const resolver = new CodexAuthResolver();
112
-
113
- if (!(await resolver.isAvailable())) {
114
- logger.debug("usage", "Codex not available on this machine, omitting from response", { event: "provider.unavailable", provider: "codex" });
115
- continue;
116
- }
117
-
118
- const provider = new CodexUsageProvider(resolver);
119
-
120
129
  try {
121
- const result = await withAbort((signal) => provider.fetch({ signal }), config.daemon.networkTimeoutMs, "Codex usage fetch timed out");
130
+ const result = await withAbort((requestSignal) => resolvers.codexProvider.fetch({ signal: requestSignal }), config.daemon.networkTimeoutMs, "Codex usage fetch timed out", signal);
122
131
  providers.push(result.entry);
123
132
  } catch (err) {
133
+ if (signal.aborted) throw err;
124
134
  this.errorCount += 1;
125
135
  const message = err instanceof Error ? err.message : String(err);
126
136
  logger.warn("usage", "Codex provider unexpected error", { event: "provider.fetch_failed", provider: "codex", errorMessage: message });
@@ -133,19 +143,11 @@ export class UsageCollector implements Collector {
133
143
  });
134
144
  }
135
145
  } else if (providerName === "opencode-go") {
136
- const resolver = new OpenCodeGoWebResolver();
137
-
138
- if (!(await resolver.isAvailable())) {
139
- logger.debug("usage", "OpenCode Go not available on this machine, omitting from response", { event: "provider.unavailable", provider: "opencode-go" });
140
- continue;
141
- }
142
-
143
- const provider = new OpenCodeGoUsageProvider(resolver, config.usage.opencodeGo.serverFnId);
144
-
145
146
  try {
146
- const result = await withAbort((signal) => provider.fetch({ signal }), config.daemon.networkTimeoutMs, "OpenCode Go usage fetch timed out");
147
+ const result = await withAbort((requestSignal) => resolvers.opencodeGoProvider.fetch({ signal: requestSignal }), config.daemon.networkTimeoutMs, "OpenCode Go usage fetch timed out", signal);
147
148
  providers.push(result.entry);
148
149
  } catch (err) {
150
+ if (signal.aborted) throw err;
149
151
  this.errorCount += 1;
150
152
  const message = err instanceof Error ? err.message : String(err);
151
153
  logger.warn("usage", "OpenCode Go provider unexpected error", { event: "provider.fetch_failed", provider: "opencode-go", errorMessage: message });
@@ -169,23 +171,87 @@ export class UsageCollector implements Collector {
169
171
  store.setCollectorStatus(this.name, { running: true, lastRunAt: attemptedAt, skippedPolls: this.skippedPolls, errorCount: this.errorCount });
170
172
  logger.debug("usage", "Poll complete", { event: "collector.poll_completed", collector: this.name, providerCount: providers.length });
171
173
  }
174
+
175
+ /**
176
+ * Resolvers and providers are created once per collector lifetime so
177
+ * resolved credentials stay cached across polls instead of re-reading
178
+ * credential sources on every cycle.
179
+ */
180
+ private ensureResolvers(config: ScrylogConfig): UsageResolverSet {
181
+ if (!this.resolverSet) {
182
+ const oauthResolver = new ClaudeAuthResolver();
183
+ const webResolver = new ClaudeWebResolver();
184
+ const cliResolver = new ClaudeCliResolver();
185
+ const planner = new ClaudeSourcePlanner(
186
+ {
187
+ dataSource: config.usage.claude.dataSource,
188
+ webExtrasEnabled: config.usage.claude.webExtrasEnabled,
189
+ },
190
+ oauthResolver,
191
+ webResolver,
192
+ cliResolver,
193
+ );
194
+
195
+ this.resolverSet = {
196
+ claudeProvider: new ClaudeUsageProvider(planner, oauthResolver, webResolver, cliResolver),
197
+ codexProvider: new CodexUsageProvider(
198
+ new CodexAuthResolver(config.usage.codex.chatgptBaseUrl),
199
+ config.usage.codex.dataSource,
200
+ ),
201
+ opencodeGoProvider: new OpenCodeGoUsageProvider(
202
+ new OpenCodeGoWebResolver(),
203
+ config.usage.opencodeGo.serverFnId,
204
+ ),
205
+ };
206
+ }
207
+ return this.resolverSet;
208
+ }
172
209
  }
173
210
 
174
- function withAbort<T>(run: (signal: AbortSignal) => Promise<T>, timeoutMs: number, message: string): Promise<T> {
211
+ export function withAbort<T>(
212
+ run: (signal: AbortSignal) => Promise<T>,
213
+ timeoutMs: number,
214
+ message: string,
215
+ parentSignal?: AbortSignal,
216
+ ): Promise<T> {
175
217
  const controller = new AbortController();
176
- const promise = run(controller.signal);
177
- return new Promise((resolve, reject) => {
178
- const timer = setTimeout(() => {
218
+ if (parentSignal?.aborted) {
219
+ controller.abort();
220
+ return Promise.reject(new Error("Usage poll cancelled"));
221
+ }
222
+ return new Promise<T>((resolve, reject) => {
223
+ let timer: ReturnType<typeof setTimeout> | undefined;
224
+ const cleanup = () => {
225
+ if (timer) clearTimeout(timer);
226
+ parentSignal?.removeEventListener("abort", onParentAbort);
227
+ };
228
+ const onParentAbort = () => {
229
+ controller.abort();
230
+ reject(new Error("Usage poll cancelled"));
231
+ };
232
+ parentSignal?.addEventListener("abort", onParentAbort, { once: true });
233
+ // Register the deadline before starting the request so the first
234
+ // synchronous step is already bounded.
235
+ timer = setTimeout(() => {
179
236
  controller.abort();
180
237
  reject(new Error(message));
181
238
  }, timeoutMs);
239
+
240
+ let promise: Promise<T>;
241
+ try {
242
+ promise = run(controller.signal);
243
+ } catch (error) {
244
+ cleanup();
245
+ reject(error instanceof Error ? error : new Error(String(error)));
246
+ return;
247
+ }
182
248
  promise.then(
183
249
  (value) => {
184
- clearTimeout(timer);
250
+ cleanup();
185
251
  resolve(value);
186
252
  },
187
253
  (error) => {
188
- clearTimeout(timer);
254
+ cleanup();
189
255
  reject(error);
190
256
  },
191
257
  );
@@ -6,6 +6,7 @@ import { IS_WINDOWS } from "../shared/platform";
6
6
  import { DaemonStore } from "./store";
7
7
  import { Scheduler } from "./scheduler";
8
8
  import { TmuxCollector } from "./collectors/tmux";
9
+ import { HerdrCollector } from "./collectors/herdr";
9
10
  import { ProcessCollector } from "./collectors/process";
10
11
  import { GitCollector } from "./collectors/git";
11
12
  import { OpenCodeCollector } from "./collectors/opencode";
@@ -78,6 +79,7 @@ const gitCollector = new GitCollector();
78
79
  scheduler.register(processCollector);
79
80
  scheduler.register(gitCollector);
80
81
  scheduler.register(new TmuxCollector(processCollector, gitCollector));
82
+ scheduler.register(new HerdrCollector(gitCollector));
81
83
  if (config.opencode.mode === "sse") {
82
84
  scheduler.register(new OpenCodeCollector(gitCollector));
83
85
  }