@scrylog/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +46 -7
  2. package/package.json +13 -10
  3. package/src/api/docs/sessions.ts +3 -2
  4. package/src/api/docs/system.ts +3 -0
  5. package/src/api/docs/usage.ts +2 -2
  6. package/src/api/routes/sessions.ts +6 -2
  7. package/src/api/routes/system.ts +4 -1
  8. package/src/api/routes/webhooks/claude.ts +8 -0
  9. package/src/api/routes/webhooks/opencode.ts +1 -0
  10. package/src/api/schemas/common.ts +8 -0
  11. package/src/api/schemas/sessions.ts +15 -1
  12. package/src/api/schemas/usage.ts +1 -0
  13. package/src/cli/commands.ts +85 -3
  14. package/src/cli/index.ts +18 -0
  15. package/src/cli/program.ts +175 -0
  16. package/src/daemon/collectors/claude/collector.ts +1 -1
  17. package/src/daemon/collectors/claude/discovery.ts +2 -23
  18. package/src/daemon/collectors/codex/collector.ts +3 -3
  19. package/src/daemon/collectors/git.ts +60 -2
  20. package/src/daemon/collectors/herdr/collector.ts +128 -0
  21. package/src/daemon/collectors/herdr/index.ts +2 -0
  22. package/src/daemon/collectors/herdr/normalization.ts +111 -0
  23. package/src/daemon/collectors/opencode-db.ts +17 -5
  24. package/src/daemon/collectors/tmux/collector.ts +21 -1
  25. package/src/daemon/collectors/tmux/enrichment.ts +1 -0
  26. package/src/daemon/collectors/usage.ts +115 -49
  27. package/src/daemon/index.ts +2 -0
  28. package/src/daemon/store.ts +44 -2
  29. package/src/postinstall.cjs +11 -0
  30. package/src/shared/config.ts +33 -0
  31. package/src/shared/session-exclusions.ts +5 -0
  32. package/src/shared/session-view.ts +67 -6
  33. package/src/shared/terminal-capabilities.ts +130 -0
  34. package/src/shared/types.ts +33 -2
  35. package/src/shared/version.ts +44 -0
  36. package/src/tui/App.tsx +240 -0
  37. package/src/tui/assets/providers/SOURCES.md +21 -0
  38. package/src/tui/assets/providers/claude-terminal.png +0 -0
  39. package/src/tui/assets/providers/claude-terminal.svg +62 -0
  40. package/src/tui/assets/providers/claude.png +0 -0
  41. package/src/tui/assets/providers/copilot.png +0 -0
  42. package/src/tui/assets/providers/copilot.svg +44 -0
  43. package/src/tui/assets/providers/openai-terminal.png +0 -0
  44. package/src/tui/assets/providers/openai-terminal.svg +62 -0
  45. package/src/tui/assets/providers/openai.png +0 -0
  46. package/src/tui/assets/providers/opencode-terminal.png +0 -0
  47. package/src/tui/assets/providers/opencode-terminal.svg +62 -0
  48. package/src/tui/assets/providers/opencode.png +0 -0
  49. package/src/tui/assets/providers/shell.png +0 -0
  50. package/src/tui/assets/providers/shell.svg +23 -0
  51. package/src/tui/assets/providers/unknown.png +0 -0
  52. package/src/tui/assets/providers/unknown.svg +23 -0
  53. package/src/tui/client.ts +22 -8
  54. package/src/tui/components/BindingsModal.tsx +82 -0
  55. package/src/tui/components/DashboardHeader.tsx +256 -0
  56. package/src/tui/components/KeyBar.tsx +67 -0
  57. package/src/tui/components/SessionDetailModal.tsx +169 -0
  58. package/src/tui/components/SessionsView.tsx +184 -0
  59. package/src/tui/components/TabBar.tsx +40 -0
  60. package/src/tui/components/UsageSummaryPane.tsx +272 -0
  61. package/src/tui/components/UsageView.tsx +242 -0
  62. package/src/tui/components/tool-icons.ts +24 -0
  63. package/src/tui/index.tsx +106 -0
  64. package/src/tui/keybindings.ts +49 -0
  65. package/src/tui/model.ts +295 -0
  66. package/src/tui/state.ts +77 -0
  67. package/src/tui/theme.ts +110 -0
  68. package/src/usage/README.md +3 -3
  69. package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
  70. package/src/usage/clients/anthropic-web-usage.ts +133 -49
  71. package/src/usage/clients/claude-cli-probe.ts +458 -132
  72. package/src/usage/clients/codex-app-server.ts +354 -0
  73. package/src/usage/clients/codex-oauth-usage.ts +143 -16
  74. package/src/usage/clients/opencode-go-api.ts +100 -0
  75. package/src/usage/clients/opencode-go-usage.ts +163 -27
  76. package/src/usage/errors.ts +33 -0
  77. package/src/usage/local/opencode-go-local.ts +194 -0
  78. package/src/usage/mappers.ts +24 -15
  79. package/src/usage/planner.ts +89 -25
  80. package/src/usage/providers/claude.ts +203 -83
  81. package/src/usage/providers/codex.ts +92 -11
  82. package/src/usage/providers/opencode-go.ts +94 -16
  83. package/src/usage/resolvers/browser-cookies.ts +407 -69
  84. package/src/usage/resolvers/claude-auth.ts +140 -40
  85. package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
  86. package/src/usage/resolvers/claude-web.ts +72 -6
  87. package/src/usage/resolvers/codex-auth.ts +74 -17
  88. package/src/usage/resolvers/keychain-coordinator.ts +397 -0
  89. package/src/usage/resolvers/opencode-go-web.ts +47 -6
  90. package/src/usage/types.ts +37 -2
  91. package/src/usage/web-session-cache.ts +37 -0
  92. package/src/neo-blessed.d.ts +0 -4
  93. package/src/tui/app.ts +0 -920
  94. package/src/tui/index.ts +0 -217
@@ -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
  }
@@ -124,6 +124,24 @@ export class DaemonStore extends EventEmitter {
124
124
  // enrichment fields. This prevents the opencode SDK/DB/cluade collectors from
125
125
  // wiping out tmux enrichment on every poll/SSE cycle.
126
126
  let session = incoming;
127
+ if (
128
+ existing?.source === "herdr"
129
+ && existing.herdr
130
+ && incoming.detectionSource !== "herdr"
131
+ && incoming.source !== "tmux"
132
+ && !incoming.tmux
133
+ && !incoming.legacyPaneId
134
+ ) {
135
+ session = {
136
+ ...incoming,
137
+ source: "herdr",
138
+ herdr: existing.herdr,
139
+ project: incoming.project ?? existing.project,
140
+ currentPath: incoming.currentPath ?? existing.currentPath,
141
+ currentCommand: incoming.currentCommand ?? existing.currentCommand,
142
+ paneTitle: incoming.paneTitle ?? existing.paneTitle,
143
+ };
144
+ }
127
145
  if (existing?.legacyPaneId && !incoming.legacyPaneId) {
128
146
  const stableForMs = existing.state !== incoming.state ? 0 : existing.stableForMs;
129
147
  session = {
@@ -419,6 +437,29 @@ export class DaemonStore extends EventEmitter {
419
437
  };
420
438
  }
421
439
 
440
+ /** Remove herdr location while retaining native collector session data. */
441
+ clearHerdrBinding(sessionId: string): void {
442
+ const existing = this.state.sessions.find((session) => session.id === sessionId);
443
+ if (!existing || existing.source !== "herdr" || !existing.herdr || existing.detectionSource === "herdr") {
444
+ return;
445
+ }
446
+
447
+ const session: SessionModel = {
448
+ ...existing,
449
+ source: "standalone",
450
+ herdr: undefined,
451
+ currentCommand: undefined,
452
+ paneTitle: undefined,
453
+ };
454
+ this.state = {
455
+ ...this.state,
456
+ sessions: this.state.sessions.map((item) => item.id === sessionId ? session : item),
457
+ refreshedAt: Date.now(),
458
+ };
459
+ this.emit("store:event", { type: "session:updated", session } satisfies StoreEvent);
460
+ this.emit("store:event", { type: "state:refreshed", timestamp: this.state.refreshedAt } satisfies StoreEvent);
461
+ }
462
+
422
463
  /** Update usage state and emit event */
423
464
  setUsage(usage: import("../usage/types").UsageState): void {
424
465
  this.state = { ...this.state, usage };
@@ -518,9 +559,10 @@ export class DaemonStore extends EventEmitter {
518
559
  const maxSessions = retention.maxSessions;
519
560
  if (maxSessions > 0) {
520
561
  const survivors = this.state.sessions.filter((session) => !removeIds.has(session.id));
521
- const overflow = survivors.length - maxSessions;
562
+ const counted = survivors.filter((session) => session.source === "standalone" && session.tool !== "unknown");
563
+ const overflow = counted.length - maxSessions;
522
564
  if (overflow > 0) {
523
- const candidates = survivors
565
+ const candidates = counted
524
566
  .filter((session) => !active(session))
525
567
  .sort((a, b) => a.lastActivity - b.lastActivity);
526
568
  for (const session of candidates.slice(0, overflow)) {
@@ -0,0 +1,11 @@
1
+ const message = [
2
+ "scrylog installed or updated.",
3
+ "If scrylogd is already running, activate this version with:",
4
+ " scrylog daemon restart",
5
+ "For a foreground or unsupported service, stop it and run:",
6
+ " scrylogd",
7
+ "Hooks use the installed binaries and do not need reinstallation.",
8
+ "If integrations misbehave, run: scrylog setup",
9
+ ].join("\n");
10
+
11
+ process.stdout.write(`${message}\n`);
@@ -91,8 +91,20 @@ export interface TuiConfig {
91
91
  standaloneMaxAgeHours: number;
92
92
  /** Debounce window in ms for coalescing push-event bursts into a single refresh (default: 120, 0 = disabled) */
93
93
  pushDebounceMs: number;
94
+ /**
95
+ * Provider icon rendering mode (default: blocks):
96
+ * - "blocks": terminal pixel art, works everywhere including tmux
97
+ * - "graphics": full artwork negotiated automatically (kitty → sixel → blocks)
98
+ * - "kitty" / "sixel": force a specific native graphics protocol, ignoring detection
99
+ * - "text": colored provider initials
100
+ */
101
+ providerIcons: "blocks" | "graphics" | "kitty" | "sixel" | "text";
94
102
  }
95
103
 
104
+ type ProviderIconsMode = TuiConfig["providerIcons"];
105
+
106
+ const PROVIDER_ICON_MODES: readonly ProviderIconsMode[] = ["blocks", "graphics", "kitty", "sixel", "text"];
107
+
96
108
  export interface McpConfig {
97
109
  /** Whether the local stdio MCP binary should be allowed (default: true) */
98
110
  stdioEnabled: boolean;
@@ -142,6 +154,14 @@ export interface UsageOpenCodeGoConfig {
142
154
  serverFnId: string;
143
155
  }
144
156
 
157
+ export interface UsageAuthConfig {
158
+ /**
159
+ * Whether background usage polls may read foreign Keychain secrets (macOS).
160
+ * `false` keeps the daemon prompt-free but disables Keychain-backed credential sources.
161
+ */
162
+ allowKeychainPrompts: boolean;
163
+ }
164
+
145
165
  export interface UsageConfig {
146
166
  /** Whether usage collection is enabled (default: true) */
147
167
  enabled: boolean;
@@ -152,6 +172,7 @@ export interface UsageConfig {
152
172
  claude: UsageClaudeConfig;
153
173
  codex: UsageCodexConfig;
154
174
  opencodeGo: UsageOpenCodeGoConfig;
175
+ auth: UsageAuthConfig;
155
176
  }
156
177
 
157
178
  export interface RetentionConfig {
@@ -233,6 +254,7 @@ export const CONFIG_DEFAULTS: ScrylogConfig = {
233
254
  usePushUpdates: true,
234
255
  standaloneMaxAgeHours: 6,
235
256
  pushDebounceMs: 120,
257
+ providerIcons: "blocks",
236
258
  },
237
259
  mcp: {
238
260
  stdioEnabled: true,
@@ -254,6 +276,9 @@ export const CONFIG_DEFAULTS: ScrylogConfig = {
254
276
  opencodeGo: {
255
277
  serverFnId: "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f",
256
278
  },
279
+ auth: {
280
+ allowKeychainPrompts: true,
281
+ },
257
282
  },
258
283
  retention: {
259
284
  maxSessions: 500,
@@ -456,10 +481,18 @@ function normalizeOperationalConfig(config: ScrylogConfig): ScrylogConfig {
456
481
  refreshMs: clampNumber(config.tui.refreshMs, 500, 300_000, CONFIG_DEFAULTS.tui.refreshMs),
457
482
  standaloneMaxAgeHours: clampNumber(config.tui.standaloneMaxAgeHours, 0, 8_760, CONFIG_DEFAULTS.tui.standaloneMaxAgeHours),
458
483
  pushDebounceMs: clampNumber(config.tui.pushDebounceMs, 0, 2_000, CONFIG_DEFAULTS.tui.pushDebounceMs),
484
+ providerIcons: PROVIDER_ICON_MODES.includes(config.tui.providerIcons)
485
+ ? config.tui.providerIcons
486
+ : CONFIG_DEFAULTS.tui.providerIcons,
459
487
  },
460
488
  usage: {
461
489
  ...config.usage,
462
490
  pollIntervalMs: clampNumber(config.usage.pollIntervalMs, 60_000, 3_600_000, CONFIG_DEFAULTS.usage.pollIntervalMs),
491
+ auth: {
492
+ allowKeychainPrompts: typeof config.usage.auth?.allowKeychainPrompts === "boolean"
493
+ ? config.usage.auth.allowKeychainPrompts
494
+ : CONFIG_DEFAULTS.usage.auth.allowKeychainPrompts,
495
+ },
463
496
  },
464
497
  retention: {
465
498
  ...config.retention,
@@ -0,0 +1,5 @@
1
+ const CLAUDE_PROBE_PATH = /(?:^|[\\/-])codexbar[\\/-]claudeprobe(?:$|[\\/-])/i;
2
+
3
+ export function isKnownClaudeProbePath(path?: string): boolean {
4
+ return typeof path === "string" && CLAUDE_PROBE_PATH.test(path);
5
+ }
@@ -15,6 +15,18 @@ export interface SessionListTmuxInfo {
15
15
  session: string;
16
16
  window: string;
17
17
  pane: string;
18
+ windowName?: string;
19
+ }
20
+
21
+ export interface SessionListHerdrInfo {
22
+ paneId: string;
23
+ terminalId?: string;
24
+ workspaceId?: string;
25
+ workspace?: string;
26
+ tabId?: string;
27
+ tab?: string;
28
+ name?: string;
29
+ agent?: string;
18
30
  }
19
31
 
20
32
  export interface SessionListItem {
@@ -28,8 +40,11 @@ export interface SessionListItem {
28
40
  hasAttention?: boolean;
29
41
  detectionSource?: SessionModel["detectionSource"];
30
42
  parentSessionId?: string;
43
+ /** Human label for the session row: pane title > window/tab name > tmux session name */
44
+ displayName?: string;
31
45
  project?: SessionListProjectInfo;
32
46
  tmux?: SessionListTmuxInfo;
47
+ herdr?: SessionListHerdrInfo;
33
48
  currentPath?: string;
34
49
  activeTool?: string;
35
50
  childCount?: number;
@@ -54,6 +69,10 @@ export interface SessionView {
54
69
  sessions: SessionTreeItem[];
55
70
  }
56
71
 
72
+ export interface SessionViewOptions {
73
+ includeUnknown?: boolean;
74
+ }
75
+
57
76
  function makeStateCounts(): Record<SessionState, number> {
58
77
  return {
59
78
  working: 0,
@@ -90,7 +109,16 @@ function isEphemeralCodexRootSession(session: SessionModel, now: number): boolea
90
109
  && now - session.lastActivity > CODEX_ROOT_EPHEMERAL_GRACE_MS;
91
110
  }
92
111
 
93
- function isCanonicallyVisible(session: SessionModel, config: ScrylogConfig, now: number): boolean {
112
+ function isCanonicallyVisible(
113
+ session: SessionModel,
114
+ config: ScrylogConfig,
115
+ now: number,
116
+ options: SessionViewOptions,
117
+ ): boolean {
118
+ if (session.tool === "unknown" && !options.includeUnknown) {
119
+ return false;
120
+ }
121
+
94
122
  if (session.parentSessionId) {
95
123
  return session.state === "working" || session.state === "waiting-input";
96
124
  }
@@ -125,6 +153,23 @@ function toProject(project?: ProjectInfo): SessionListProjectInfo | undefined {
125
153
 
126
154
  const GRACE_PERIOD_MS = 1500;
127
155
 
156
+ function resolveDisplayName(session: SessionModel): string | undefined {
157
+ if (session.tmux) {
158
+ return session.paneTitle?.trim()
159
+ || session.tmux.windowName?.trim()
160
+ || session.tmux.session?.trim()
161
+ || undefined;
162
+ }
163
+ if (session.herdr) {
164
+ return session.herdr.name?.trim()
165
+ || session.herdr.tab?.trim()
166
+ || session.herdr.agent?.trim()
167
+ || session.herdr.paneId
168
+ || undefined;
169
+ }
170
+ return undefined;
171
+ }
172
+
128
173
  function effectiveState(session: SessionModel, activeChildCount: number, now: number): SessionState {
129
174
  if (session.state === "idle" && !session.parentSessionId) {
130
175
  if (activeChildCount > 0) {
@@ -160,8 +205,19 @@ function toListItem(session: SessionModel, childCount: number, activeChildCount:
160
205
  hasAttention: session.hasAttention,
161
206
  detectionSource: session.detectionSource,
162
207
  parentSessionId: session.parentSessionId,
208
+ displayName: resolveDisplayName(session),
163
209
  project: toProject(session.project),
164
- tmux: session.tmux,
210
+ tmux: session.tmux ? { ...session.tmux } : undefined,
211
+ herdr: session.herdr ? {
212
+ paneId: session.herdr.paneId,
213
+ terminalId: session.herdr.terminalId,
214
+ workspaceId: session.herdr.workspaceId,
215
+ workspace: session.herdr.workspace,
216
+ tabId: session.herdr.tabId,
217
+ tab: session.herdr.tab,
218
+ name: session.herdr.name,
219
+ agent: session.herdr.agent,
220
+ } : undefined,
165
221
  currentPath: session.currentPath,
166
222
  activeTool: session.activeTool,
167
223
  childCount,
@@ -179,7 +235,12 @@ export function flattenSessionTree(sessions: SessionTreeItem[]): SessionListItem
179
235
  return flat;
180
236
  }
181
237
 
182
- export function buildSessionView(sessions: SessionModel[], config: ScrylogConfig, now = Date.now()): SessionView {
238
+ export function buildSessionView(
239
+ sessions: SessionModel[],
240
+ config: ScrylogConfig,
241
+ now = Date.now(),
242
+ options: SessionViewOptions = {},
243
+ ): SessionView {
183
244
  const byId = new Map(sessions.map((session) => [session.id, session]));
184
245
  const allChildren = new Map<string, SessionModel[]>();
185
246
  const visibleChildren = new Map<string, SessionModel[]>();
@@ -191,20 +252,20 @@ export function buildSessionView(sessions: SessionModel[], config: ScrylogConfig
191
252
  arr.push(session);
192
253
  allChildren.set(session.parentSessionId, arr);
193
254
 
194
- if (isCanonicallyVisible(session, config, now)) {
255
+ if (isCanonicallyVisible(session, config, now, options)) {
195
256
  const visibleArr = visibleChildren.get(session.parentSessionId) ?? [];
196
257
  visibleArr.push(session);
197
258
  visibleChildren.set(session.parentSessionId, visibleArr);
198
259
 
199
260
  const parent = byId.get(session.parentSessionId);
200
- if (parent) {
261
+ if (parent && isCanonicallyVisible(parent, config, now, options)) {
201
262
  visibleRoots.set(parent.id, parent);
202
263
  }
203
264
  }
204
265
  continue;
205
266
  }
206
267
 
207
- if (isCanonicallyVisible(session, config, now)) {
268
+ if (isCanonicallyVisible(session, config, now, options)) {
208
269
  visibleRoots.set(session.id, session);
209
270
  }
210
271
  }