@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
@@ -9,12 +9,27 @@ import { getDaemonLogPath } from "../shared/paths";
9
9
  import { createPromptSession } from "./prompt";
10
10
  import { getServiceManagerOrThrow } from "../service/manager";
11
11
  import { createDaemonFetch, getSocketPath } from "../shared/ipc";
12
- import { IS_WINDOWS, whichCommand } from "../shared/platform";
12
+ import { adviseProviderIcons, probeTerminalGraphics } from "../shared/terminal-capabilities";
13
+ import { IS_MAC, IS_WINDOWS, whichCommand } from "../shared/platform";
14
+ import { formatVersionDiagnostics, getInstalledVersion } from "../shared/version";
15
+ import { listSessions } from "@joeldmtz/prtl-core";
16
+ import type { ListSessionsOptions, PrtlListResult } from "@joeldmtz/prtl-core";
13
17
 
14
18
  export interface GlobalCliOptions {
15
19
  configPath?: string;
16
20
  }
17
21
 
22
+ type HerdrList = (options?: ListSessionsOptions) => Promise<PrtlListResult>;
23
+
24
+ export async function isHerdrAvailable(timeoutMs: number, list: HerdrList = listSessions): Promise<boolean> {
25
+ try {
26
+ const result = await list({ provider: "herdr", timeoutMs });
27
+ return result.metadata.providers.herdr?.available === true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
18
33
  export async function runDaemonCommand(action: string, options: GlobalCliOptions): Promise<void> {
19
34
  const manager = getServiceManagerOrThrow();
20
35
  if (action === "install") {
@@ -89,7 +104,7 @@ export function runTokenCommand(target: "mcp" | "api" | "both", action: string,
89
104
  throw new Error(`Unknown token action: ${action}`);
90
105
  }
91
106
 
92
- export async function runLogsCommand(): Promise<void> {
107
+ export async function runLogsCommand(_options: GlobalCliOptions): Promise<void> {
93
108
  const logPath = getDaemonLogPath();
94
109
  process.stdout.write(`Log path: ${logPath}\n`);
95
110
  if (!existsSync(logPath)) {
@@ -102,8 +117,32 @@ export async function runLogsCommand(): Promise<void> {
102
117
  process.stdout.write(`${recent.join("\n")}\n`);
103
118
  }
104
119
 
105
- export async function runDoctor(options: GlobalCliOptions): Promise<void> {
106
- const configPath = getResolvedConfigPath(options.configPath);
120
+ interface ClaudeCredentialDiagnostics {
121
+ state: "missing" | "expired" | "valid";
122
+ }
123
+
124
+ /**
125
+ * Claude credential state for doctor diagnostics, derived from the running
126
+ * daemon's usage state. Reports only the state — never prints token material.
127
+ * Returns null when the daemon is unreachable (no line is printed).
128
+ */
129
+ async function claudeCredentialState(daemonFetch: Awaited<ReturnType<typeof createDaemonFetch>>): Promise<ClaudeCredentialDiagnostics | null> {
130
+ try {
131
+ const usage = await daemonFetch("/api/usage") as { providers?: Array<{ provider: string; status: string; errors?: string[] }> };
132
+ const claude = usage.providers?.find((provider) => provider.provider === "claude");
133
+ if (!claude) return null;
134
+ if (claude.status === "ok") return { state: "valid" };
135
+ if (claude.status === "unavailable") {
136
+ const expired = (claude.errors ?? []).some((message) => message.includes("expired") || message.includes("re-login"));
137
+ return { state: expired ? "expired" : "missing" };
138
+ }
139
+ return null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ export async function runDoctor(options: GlobalCliOptions): Promise<void> { const configPath = getResolvedConfigPath(options.configPath);
107
146
  const config = loadConfig(options.configPath);
108
147
  const daemonFetch = createDaemonFetch({
109
148
  host: config.api.host,
@@ -121,12 +160,33 @@ export async function runDoctor(options: GlobalCliOptions): Promise<void> {
121
160
  process.stdout.write(`Retention max sessions: ${config.retention.maxSessions}\n`);
122
161
  process.stdout.write(`Retention standalone max age hours: ${config.retention.standaloneMaxAgeHours}\n`);
123
162
  process.stdout.write(`Usage enabled: ${config.usage.enabled}\n`);
163
+ if (IS_MAC && config.usage.enabled) {
164
+ process.stdout.write(`Keychain prompts allowed: ${config.usage.auth.allowKeychainPrompts}\n`);
165
+ process.stdout.write("Keychain items the daemon may read: claude-api-oauth, Claude Code-credentials, Claude Safe Storage, per-browser Safe Storage (Chrome, Brave, Edge, Arc, Vivaldi, Chromium)\n");
166
+ process.stdout.write("Keychain note: macOS authorization is granted per item — you have about 90 seconds to answer each dialog; click \"Always Allow\" once per item. Successful reads are memoized for the daemon's lifetime, so each item prompts at most once per daemon run, and an unanswered dialog retries after 10 minutes. Set usage.auth.allowKeychainPrompts to false to suppress all keychain prompts.\n");
167
+
168
+ const claudeCredential = await claudeCredentialState(daemonFetch);
169
+ if (claudeCredential?.state === "valid") {
170
+ process.stdout.write("Claude credential: valid (OAuth session active)\n");
171
+ } else if (claudeCredential?.state === "expired") {
172
+ process.stdout.write("Claude credential: expired — run \"claude\" and log in to refresh, then restart the daemon\n");
173
+ } else if (claudeCredential?.state === "missing") {
174
+ process.stdout.write("Claude credential: not found — run \"claude\" and log in to enable the Claude OAuth usage source\n");
175
+ }
176
+ }
124
177
 
125
178
  try {
126
179
  const health = await daemonFetch("/api/health");
127
180
  process.stdout.write(`Local daemon reachable: ${health.ok}\n`);
181
+ if (health.ok) {
182
+ const body = await health.json().catch(() => ({})) as { version?: string };
183
+ process.stdout.write(formatVersionDiagnostics(getInstalledVersion(), body.version));
184
+ } else {
185
+ process.stdout.write(`CLI version: ${getInstalledVersion()}\n`);
186
+ }
128
187
  } catch {
129
188
  process.stdout.write("Local daemon reachable: false\n");
189
+ process.stdout.write(`CLI version: ${getInstalledVersion()}\n`);
130
190
  }
131
191
 
132
192
  process.stdout.write(`TCP API enabled: ${config.api.tcpEnabled}\n`);
@@ -155,6 +215,27 @@ export async function runDoctor(options: GlobalCliOptions): Promise<void> {
155
215
  process.stdout.write(`CORS origins: ${config.cors.origins.join(", ")}\n`);
156
216
  }
157
217
 
218
+ const graphics = await probeTerminalGraphics();
219
+ const advice = adviseProviderIcons(graphics);
220
+ process.stdout.write("\nTerminal graphics:\n");
221
+ if (graphics.probeFailed) {
222
+ const reason = graphics.probeFailedReason === "raw-unavailable"
223
+ ? "raw mode is unavailable in this stdin (try the interactive scrylog binary)"
224
+ : graphics.probeFailedReason === "no-answer"
225
+ ? "the terminal did not answer any probe (graphics detection is unknown)"
226
+ : "stdin is not an interactive TTY — attach one to test";
227
+ process.stdout.write(` Probe: unavailable (${reason})\n`);
228
+ } else {
229
+ process.stdout.write(` Terminal: ${graphics.terminalName ?? "(no XTVERSION response)"}\n`);
230
+ process.stdout.write(` TERM: ${graphics.term ?? "(unset)"}\n`);
231
+ process.stdout.write(` Multiplexer: ${graphics.multiplexer}\n`);
232
+ process.stdout.write(` Kitty graphics: ${graphics.kittyGraphics}\n`);
233
+ process.stdout.write(` Sixel: ${graphics.sixel}\n`);
234
+ process.stdout.write(` Window pixel resolution: ${graphics.pixelResolution ? `${graphics.pixelResolution.width}x${graphics.pixelResolution.height}` : "(not reported)"}\n`);
235
+ }
236
+ process.stdout.write(` providerIcons config: ${config.tui.providerIcons}\n`);
237
+ process.stdout.write(` Recommended providerIcons: ${graphics.probeFailed ? `run again from an interactive terminal (${advice} is the safe default)` : advice}\n`);
238
+
158
239
  process.stdout.write(`MCP HTTP enabled: ${config.mcp.httpEnabled}\n`);
159
240
  if (config.mcp.httpEnabled) {
160
241
  try {
@@ -184,6 +265,7 @@ export async function runDoctor(options: GlobalCliOptions): Promise<void> {
184
265
 
185
266
  const tmux = Bun.spawnSync(["tmux", "-V"], { stdout: "pipe", stderr: "pipe" });
186
267
  process.stdout.write(`tmux available: ${tmux.exitCode === 0}\n`);
268
+ process.stdout.write(`herdr available: ${await isHerdrAvailable(config.daemon.commandTimeoutMs)}\n`);
187
269
 
188
270
  if (config.opencode.mode === "hooks") {
189
271
  process.stdout.write("OpenCode mode: hooks\n");
@@ -247,6 +329,24 @@ export async function runSetupWizard(options: GlobalCliOptions): Promise<void> {
247
329
  }
248
330
  }
249
331
 
332
+ if (isClaudeHooksInstalled()) {
333
+ process.stdout.write("Claude hooks already installed.\n");
334
+ } else {
335
+ const installClaude = await prompt.yesNo("Install Claude hooks now?", false);
336
+ if (installClaude) {
337
+ await runClaudeHooksInstall(options);
338
+ }
339
+ }
340
+
341
+ if (isCodexHooksInstalled()) {
342
+ process.stdout.write("Codex hooks already installed.\n");
343
+ } else {
344
+ const installCodex = await prompt.yesNo("Install Codex hooks now?", false);
345
+ if (installCodex) {
346
+ await runCodexHooksInstall(options);
347
+ }
348
+ }
349
+
250
350
  const pollInterval = await prompt.text("Daemon poll interval (ms)", String(current.daemon.pollIntervalMs));
251
351
  const logLevel = await prompt.choose("Daemon log level", ["debug", "info", "warn", "error"], ["debug", "info", "warn", "error"].indexOf(current.daemon.logLevel));
252
352
  next.daemon.pollIntervalMs = parseOptionalInt(pollInterval, current.daemon.pollIntervalMs);
@@ -326,6 +426,52 @@ function parseOptionalInt(value: string, fallback: number): number {
326
426
 
327
427
  const OPENCODE_PLUGIN_NAME = "@scrylog/opencode-plugin";
328
428
 
429
+ export function isClaudeHooksInstalled(homeDir = homedir()): boolean {
430
+ const settingsPath = join(homeDir, ".claude", "settings.json");
431
+ if (!existsSync(settingsPath)) return false;
432
+ try {
433
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as ClaudeSettings;
434
+ const hooks = settings.hooks ?? {};
435
+ return Object.values(hooks).some((rules) =>
436
+ rules.some((rule) =>
437
+ rule.hooks.some((h) => typeof h.command === "string" && h.command.includes("scrylog-claude-hook")),
438
+ ),
439
+ );
440
+ } catch {
441
+ return false;
442
+ }
443
+ }
444
+
445
+ export function isCodexHooksInstalled(homeDir = homedir()): boolean {
446
+ const codexDir = process.env.CODEX_HOME ? process.env.CODEX_HOME : join(homeDir, ".codex");
447
+ const hooksPath = join(codexDir, "hooks.json");
448
+ if (!existsSync(hooksPath)) return false;
449
+ try {
450
+ const config = JSON.parse(readFileSync(hooksPath, "utf8")) as CodexHooksConfig;
451
+ const hooks = config.hooks ?? {};
452
+ return Object.values(hooks).some((rules) =>
453
+ rules.some((rule) =>
454
+ rule.hooks.some((h) => typeof h.command === "string" && h.command.includes("scrylog-codex-hook")),
455
+ ),
456
+ );
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+
462
+ export function isOpencodePluginRegistered(homeDir = homedir()): boolean {
463
+ const settingsPath = join(homeDir, ".config", "opencode", "opencode.json");
464
+ if (!existsSync(settingsPath)) return false;
465
+ try {
466
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as OpenCodeSettings;
467
+ return (settings.plugin ?? []).some(
468
+ (plugin) => typeof plugin === "string" && plugin.includes(OPENCODE_PLUGIN_NAME),
469
+ );
470
+ } catch {
471
+ return false;
472
+ }
473
+ }
474
+
329
475
  function backupBeforeWrite(path: string): void {
330
476
  if (!existsSync(path)) return;
331
477
  const bak = `${path}.bak`;
@@ -397,21 +543,21 @@ export async function runClaudeHooksInstall(options: GlobalCliOptions): Promise<
397
543
  mkdirSync(claudeDir, { recursive: true });
398
544
  }
399
545
 
546
+ if (isClaudeHooksInstalled()) {
547
+ process.stdout.write("Claude hooks are already installed.\n");
548
+ return;
549
+ }
550
+
400
551
  // Merge hooks — add scrylog entries without removing existing ones
401
552
  const existingHooks = settings.hooks ?? {};
402
553
  const updatedHooks: Record<string, ClaudeSettingsHookRule[]> = { ...existingHooks };
403
554
 
404
555
  for (const event of CLAUDE_HOOK_EVENTS) {
405
556
  const existing = updatedHooks[event] ?? [];
406
- const alreadyInstalled = existing.some((rule) =>
407
- rule.hooks.some((h) => typeof h.command === "string" && h.command.includes("scrylog-claude-hook")),
408
- );
409
- if (!alreadyInstalled) {
410
- updatedHooks[event] = [
411
- ...existing,
412
- { matcher: "", hooks: [{ type: "command", command: hookBin }] },
413
- ];
414
- }
557
+ updatedHooks[event] = [
558
+ ...existing,
559
+ { matcher: "", hooks: [{ type: "command", command: hookBin }] },
560
+ ];
415
561
  }
416
562
 
417
563
  settings.hooks = updatedHooks;
@@ -490,22 +636,22 @@ export async function runCodexHooksInstall(options: GlobalCliOptions): Promise<v
490
636
  mkdirSync(codexDir, { recursive: true });
491
637
  }
492
638
 
639
+ if (isCodexHooksInstalled()) {
640
+ process.stdout.write("Codex hooks are already installed.\n");
641
+ return;
642
+ }
643
+
493
644
  // Merge hooks — add scrylog entries without removing existing ones
494
645
  const existingHooks = config.hooks ?? {};
495
646
  const updatedHooks: Record<string, CodexHookRule[]> = { ...existingHooks };
496
647
 
497
648
  for (const event of CODEX_HOOK_EVENTS) {
498
649
  const existing = updatedHooks[event] ?? [];
499
- const alreadyInstalled = existing.some((rule) =>
500
- rule.hooks.some((h) => typeof h.command === "string" && h.command.includes("scrylog-codex-hook")),
501
- );
502
- if (!alreadyInstalled) {
503
- const entry: CodexHookRule = {
504
- matcher: event === "UserPromptSubmit" || event === "Stop" ? undefined : "",
505
- hooks: [{ type: "command", command: hookBin, ...(event === "Stop" ? { timeout: 30 } : {}) }],
506
- };
507
- updatedHooks[event] = [...existing, entry];
508
- }
650
+ const entry: CodexHookRule = {
651
+ matcher: event === "UserPromptSubmit" || event === "Stop" ? undefined : "",
652
+ hooks: [{ type: "command", command: hookBin, ...(event === "Stop" ? { timeout: 30 } : {}) }],
653
+ };
654
+ updatedHooks[event] = [...existing, entry];
509
655
  }
510
656
 
511
657
  config.hooks = updatedHooks;
@@ -549,9 +695,7 @@ export async function runOpenCodeHooksInstall(options: GlobalCliOptions): Promis
549
695
 
550
696
  // Merge plugin entry without removing existing ones
551
697
  const existingPlugins = settings.plugin ?? [];
552
- const alreadyInstalled = existingPlugins.some(
553
- (plugin) => typeof plugin === "string" && plugin.includes(OPENCODE_PLUGIN_NAME),
554
- );
698
+ const alreadyInstalled = isOpencodePluginRegistered();
555
699
 
556
700
  if (!alreadyInstalled) {
557
701
  settings.plugin = [...existingPlugins, OPENCODE_PLUGIN_NAME];
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bun
2
+ import { CommanderError } from "commander";
3
+ import { createProgram } from "./program";
4
+
5
+ export async function main(argv = process.argv): Promise<void> {
6
+ await createProgram().parseAsync(argv);
7
+ }
8
+
9
+ if (import.meta.main) {
10
+ main().catch((error) => {
11
+ if (error instanceof CommanderError) {
12
+ process.exitCode = error.exitCode;
13
+ return;
14
+ }
15
+ process.stderr.write(`scrylog: fatal: ${error instanceof Error ? error.message : String(error)}\n`);
16
+ process.exitCode = 1;
17
+ });
18
+ }
@@ -0,0 +1,175 @@
1
+ import { Command } from "commander";
2
+ import { getInstalledVersion } from "../shared/version";
3
+ import {
4
+ runClaudeHooksInstall,
5
+ runCodexHooksInstall,
6
+ runConfigCommand,
7
+ runDaemonCommand,
8
+ runDoctor,
9
+ runLogsCommand,
10
+ runOpenCodeHooksInstall,
11
+ runSetupWizard,
12
+ runTokenCommand,
13
+ } from "./commands";
14
+ import type { GlobalCliOptions } from "./commands";
15
+
16
+ export interface CliIO {
17
+ writeOut(message: string): void;
18
+ writeErr(message: string): void;
19
+ }
20
+
21
+ export type TuiLauncher = (configPath?: string, includeUnknown?: boolean) => void | Promise<void>;
22
+
23
+ interface CliHandlers {
24
+ setup(options: GlobalCliOptions): void | Promise<void>;
25
+ doctor(options: GlobalCliOptions): void | Promise<void>;
26
+ daemon(action: string, options: GlobalCliOptions): void | Promise<void>;
27
+ config(action: string, options: GlobalCliOptions): void | Promise<void>;
28
+ token(target: "mcp" | "api" | "both", action: string, options: GlobalCliOptions): void | Promise<void>;
29
+ logs(options: GlobalCliOptions): void | Promise<void>;
30
+ claudeHooksInstall(options: GlobalCliOptions): void | Promise<void>;
31
+ codexHooksInstall(options: GlobalCliOptions): void | Promise<void>;
32
+ openCodeHooksInstall(options: GlobalCliOptions): void | Promise<void>;
33
+ }
34
+
35
+ export interface CreateProgramOptions {
36
+ io?: CliIO;
37
+ launchTui?: TuiLauncher;
38
+ handlers?: Partial<CliHandlers>;
39
+ }
40
+
41
+ interface ProgramOptions {
42
+ config?: string;
43
+ includeUnknown?: boolean;
44
+ }
45
+
46
+ const HELP_TEXT = `scrylog
47
+
48
+ Usage:
49
+ scrylog [--config <path>] [--include-unknown]
50
+ scrylog setup [--config <path>]
51
+ scrylog doctor [--config <path>]
52
+ scrylog daemon <install|uninstall|start|stop|restart|status> [--config <path>]
53
+ scrylog config <show|path> [--config <path>]
54
+ scrylog token <mcp|api|both> <generate|rotate|clear> [--config <path>]
55
+ scrylog claude hooks install [--config <path>]
56
+ scrylog codex hooks install [--config <path>]
57
+ scrylog opencode hooks install [--config <path>]
58
+ scrylog logs [--config <path>]
59
+
60
+ Options:
61
+ --config <path> Load config from an explicit path
62
+ --include-unknown Include sessions whose tool is unidentified
63
+ -h, --help Show this help text
64
+ -V, --version Show the installed scrylog version
65
+
66
+ See also:
67
+ scrylogd Background daemon (collects sessions and serves API/MCP)
68
+ scrylog-mcp stdio MCP server that proxies daemon data to AI agents
69
+ scrylog-claude-hook Claude Code lifecycle hook binary
70
+ scrylog-codex-hook Codex CLI lifecycle hook binary
71
+
72
+ Notes:
73
+ Running scrylog with no subcommand launches the TUI dashboard.
74
+ 'scrylog claude hooks install' configures Claude Code to emit lifecycle
75
+ hooks that scrylog uses for real-time session state tracking.
76
+ 'scrylog codex hooks install' configures Codex CLI to emit lifecycle
77
+ hooks that scrylog uses for real-time session state tracking.
78
+ 'scrylog opencode hooks install' registers the scrylog plugin in
79
+ OpenCode configuration for real-time session state tracking.
80
+ `;
81
+
82
+ export const TOKEN_USAGE = "Usage: scrylog token <mcp|api|both> <generate|rotate|clear>";
83
+
84
+ export function parseTokenArgs(args: Array<string | undefined>): { target: "mcp" | "api" | "both"; action: string } {
85
+ const [target, action] = args;
86
+ if (!target) throw new Error(`Missing token target (mcp, api, or both). ${TOKEN_USAGE}`);
87
+ if (!action) throw new Error(`Missing token action (generate, rotate, or clear). ${TOKEN_USAGE}`);
88
+ if (target !== "mcp" && target !== "api" && target !== "both") {
89
+ throw new Error(`Invalid token target: ${target}. Must be mcp, api, or both. ${TOKEN_USAGE}`);
90
+ }
91
+ return { target, action };
92
+ }
93
+
94
+ function defaultIO(): CliIO {
95
+ return {
96
+ writeOut: (message) => process.stdout.write(message),
97
+ writeErr: (message) => process.stderr.write(message),
98
+ };
99
+ }
100
+
101
+ async function defaultLaunchTui(configPath?: string, includeUnknown = false): Promise<void> {
102
+ const tui = await import("../tui/index") as { launchTui: TuiLauncher };
103
+ await tui.launchTui(configPath, includeUnknown);
104
+ }
105
+
106
+ function globalOptions(command: Command): GlobalCliOptions {
107
+ const options = command.optsWithGlobals<ProgramOptions>();
108
+ return { configPath: options.config };
109
+ }
110
+
111
+ export function createProgram(options: CreateProgramOptions = {}): Command {
112
+ const io = options.io ?? defaultIO();
113
+ const launchTui = options.launchTui ?? defaultLaunchTui;
114
+ const handlers: CliHandlers = {
115
+ setup: runSetupWizard,
116
+ doctor: runDoctor,
117
+ daemon: runDaemonCommand,
118
+ config: runConfigCommand,
119
+ token: runTokenCommand,
120
+ logs: runLogsCommand,
121
+ claudeHooksInstall: runClaudeHooksInstall,
122
+ codexHooksInstall: runCodexHooksInstall,
123
+ openCodeHooksInstall: runOpenCodeHooksInstall,
124
+ ...options.handlers,
125
+ };
126
+
127
+ const program = new Command();
128
+ program
129
+ .name("scrylog")
130
+ .option("--config <path>", "Load config from an explicit path")
131
+ .option("--include-unknown", "Include sessions whose tool is unidentified")
132
+ .version(`scrylog ${getInstalledVersion()}`, "-V, --version", "Show the installed scrylog version")
133
+ .helpOption("-h, --help", "Show this help text")
134
+ .configureOutput({ writeOut: io.writeOut, writeErr: io.writeErr })
135
+ .configureHelp({ formatHelp: () => HELP_TEXT })
136
+ .showHelpAfterError("(see --help for usage)")
137
+ .exitOverride()
138
+ .argument("[command]")
139
+ .action(async (unknownCommand?: string) => {
140
+ if (unknownCommand) {
141
+ program.error(`error: unknown command '${unknownCommand}'`, { code: "commander.unknownCommand" });
142
+ }
143
+ const rootOptions = program.opts<ProgramOptions>();
144
+ await launchTui(rootOptions.config ?? process.env.SCRYLOG_CONFIG, rootOptions.includeUnknown ?? false);
145
+ });
146
+
147
+ const setup = program.command("setup").action(() => handlers.setup(globalOptions(setup)));
148
+ const doctor = program.command("doctor").action(() => handlers.doctor(globalOptions(doctor)));
149
+ const daemon = program
150
+ .command("daemon")
151
+ .argument("<action>", "install, uninstall, start, stop, restart, or status")
152
+ .action((action: string) => handlers.daemon(action, globalOptions(daemon)));
153
+ const config = program
154
+ .command("config")
155
+ .argument("<action>", "show or path")
156
+ .action((action: string) => handlers.config(action, globalOptions(config)));
157
+ const token = program
158
+ .command("token")
159
+ .argument("[target]", "mcp, api, or both")
160
+ .argument("[action]", "generate, rotate, or clear")
161
+ .action((target: string | undefined, action: string | undefined) => {
162
+ const parsed = parseTokenArgs([target, action]);
163
+ return handlers.token(parsed.target, parsed.action, globalOptions(token));
164
+ });
165
+ const logs = program.command("logs").action(() => handlers.logs(globalOptions(logs)));
166
+
167
+ const claudeInstall = program.command("claude").command("hooks").command("install");
168
+ claudeInstall.action(() => handlers.claudeHooksInstall(globalOptions(claudeInstall)));
169
+ const codexInstall = program.command("codex").command("hooks").command("install");
170
+ codexInstall.action(() => handlers.codexHooksInstall(globalOptions(codexInstall)));
171
+ const openCodeInstall = program.command("opencode").command("hooks").command("install");
172
+ openCodeInstall.action(() => handlers.openCodeHooksInstall(globalOptions(openCodeInstall)));
173
+
174
+ return program;
175
+ }
@@ -133,7 +133,7 @@ export class ClaudeCollector implements Collector {
133
133
  // Don't remove hook-managed sessions — they have an authoritative lifecycle
134
134
  // managed by hooks (SessionEnd). Only remove transcript-sourced sessions.
135
135
  const existingSession = store.getSession(`claude:${transcript.sessionId}`);
136
- if (existingSession?.detectionSource !== "claude-hook") {
136
+ if (existingSession?.detectionSource !== "claude-hook" && existingSession?.source === "standalone") {
137
137
  store.removeSession(`claude:${transcript.sessionId}`);
138
138
  }
139
139
  }
@@ -145,6 +145,16 @@ export class ClaudeCollector implements Collector {
145
145
  private processTranscript(transcript: import("./discovery").DiscoveredTranscript, _hasRunning: boolean): void {
146
146
  const store = this.store!;
147
147
  const config = this.config!;
148
+
149
+ // Do not re-add sessions that were explicitly ended via SessionEnd hook.
150
+ // The transcript file persists on disk after the session ends; without this
151
+ // guard, the discovery cycle would recreate the session immediately after
152
+ // the webhook removed it.
153
+ if (store.isLocalCommand(transcript.sessionId)) {
154
+ store.removeSession(`claude:${transcript.sessionId}`);
155
+ return;
156
+ }
157
+
148
158
  const existingSession = store.getSession(`claude:${transcript.sessionId}`);
149
159
  const watched = this.watched.get(transcript.path);
150
160
  if (
@@ -5,6 +5,7 @@ import type { ScrylogConfig } from "../../../shared/config";
5
5
  import type { Logger } from "../../../shared/logger";
6
6
  import { maybeRunCommand } from "../../../shared/utils";
7
7
  import { IS_WINDOWS } from "../../../shared/platform";
8
+ import { isKnownClaudeProbePath } from "../../../shared/session-exclusions";
8
9
 
9
10
  export interface DiscoveredTranscript {
10
11
  sessionId: string;
@@ -17,28 +18,6 @@ export function getClaudeDir(config: ScrylogConfig): string {
17
18
  return config.claude.claudeDir ?? join(homedir(), ".claude");
18
19
  }
19
20
 
20
- // Internal: project slugs known to generate headless probe transcripts.
21
- // Third-party tools (e.g. CodexBar's ClaudeProbe on macOS) launch `claude`
22
- // periodically to run slash commands like `/usage` for their own telemetry.
23
- // Each run leaves a tiny transcript that scrylog misreads as an interactive
24
- // "working" session, cluttering the TUI with ghosts.
25
- // Not user-configurable — extend here when new probe sources are identified.
26
- // Patterns are matched case-insensitively with `*` wildcards; on tiers where
27
- // these tools do not run (Linux/WSL 2, Windows native) the list is a no-op.
28
- const KNOWN_PROBE_SLUG_GLOBS: readonly string[] = ["*CodexBar-ClaudeProbe*"];
29
-
30
- function matchesGlob(value: string, pattern: string): boolean {
31
- const re = new RegExp(
32
- "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
33
- "i",
34
- );
35
- return re.test(value);
36
- }
37
-
38
- function isKnownProbeSlug(slug: string): boolean {
39
- return KNOWN_PROBE_SLUG_GLOBS.some((pattern) => matchesGlob(slug, pattern));
40
- }
41
-
42
21
  /**
43
22
  * Decodes a Claude project slug (e.g. "-Volumes-Files-Workspace-Projects-other-scrylog-feature-daemon")
44
23
  * back to an absolute filesystem path ("/Volumes/Files/Workspace/Projects/other/scrylog-feature-daemon").
@@ -87,7 +66,7 @@ export function discoverTranscripts(claudeDir: string, hookManagedIds?: Readonly
87
66
  // Skip project directories emitted by known headless probe tools
88
67
  // (e.g. CodexBar's ClaudeProbe). Their transcripts are not interactive
89
68
  // sessions and would otherwise show as permanent "working" ghosts.
90
- if (isKnownProbeSlug(projectSlug)) continue;
69
+ if (isKnownClaudeProbePath(projectSlug)) continue;
91
70
  const projectPath = join(projectsDir, projectSlug);
92
71
  try {
93
72
  if (!statSync(projectPath).isDirectory()) continue;
@@ -122,14 +122,14 @@ function hasAnsweredRequestUserInputWork(entries: TranscriptEntry[]): boolean {
122
122
  return false;
123
123
  }
124
124
 
125
- function preserveLocationMetadata(model: SessionModel, existing?: SessionModel): SessionModel {
125
+ export function preserveLocationMetadata(model: SessionModel, existing?: SessionModel): SessionModel {
126
126
  if (!existing) return model;
127
-
128
127
  return {
129
128
  ...model,
130
129
  source: existing.source,
131
130
  project: existing.project ?? model.project,
132
131
  tmux: existing.tmux ?? model.tmux,
132
+ herdr: existing.herdr ?? model.herdr,
133
133
  legacyPaneId: existing.legacyPaneId ?? model.legacyPaneId,
134
134
  processTree: existing.processTree ?? model.processTree,
135
135
  processChain: existing.processChain ?? model.processChain,
@@ -267,7 +267,7 @@ export class CodexCollector implements Collector {
267
267
  // Don't remove hook-managed sessions — they have an authoritative lifecycle
268
268
  // managed by hooks. Only remove transcript-sourced sessions.
269
269
  const existingSession = store.getSession(`codex:${transcript.sessionId}`);
270
- if (existingSession?.detectionSource !== "codex-hook") {
270
+ if (existingSession?.detectionSource !== "codex-hook" && existingSession?.source === "standalone") {
271
271
  store.removeSession(`codex:${transcript.sessionId}`);
272
272
  }
273
273
  }
@@ -18,12 +18,70 @@ interface GitCommandContext {
18
18
  workTree?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Freshness tracking for cached ProjectInfo entries. The branch (and only the
23
+ * branch) goes stale when the repo's HEAD file changes; everything else
24
+ * (displayName, remote, worktree status) is stable per cwd. We fingerprint the
25
+ * resolved HEAD file — works for regular repos, worktrees, and WSL-backed
26
+ * Windows checkouts, and never spawns a git subprocess.
27
+ */
28
+ interface FreshnessMeta {
29
+ headPath?: string;
30
+ headMtimeMs?: number;
31
+ }
32
+
33
+ const freshnessByProject = new WeakMap<ProjectInfo, FreshnessMeta>();
34
+
35
+ function freshenProject(project: ProjectInfo, gitMarkerDirectory: string | null): ProjectInfo {
36
+ if (!gitMarkerDirectory) return project;
37
+ const headPath = resolveHeadPath(gitMarkerDirectory);
38
+ if (!headPath) return project;
39
+ const mtime = readHeadMtime(headPath);
40
+ if (mtime === undefined) return project;
41
+ freshnessByProject.set(project, { headPath, headMtimeMs: mtime });
42
+ return project;
43
+ }
44
+
45
+ function resolveHeadPath(gitMarkerDirectory: string): string | undefined {
46
+ const context = resolveGitCommandContext(gitMarkerDirectory);
47
+ const gitDir = context.gitDir
48
+ ? resolve(context.cwd, context.gitDir)
49
+ : join(gitMarkerDirectory, ".git");
50
+ return join(gitDir, "HEAD");
51
+ }
52
+
53
+ function readHeadMtime(headPath: string | undefined): number | undefined {
54
+ if (!headPath) return undefined;
55
+ try {
56
+ return statSync(headPath).mtimeMs;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ function isFreshnessCurrent(project: ProjectInfo): boolean {
63
+ if (!project.isGitRepository) return true;
64
+ const meta = freshnessByProject.get(project);
65
+ if (!meta?.headPath || meta.headMtimeMs === undefined) return false;
66
+ return readHeadMtime(meta.headPath) === meta.headMtimeMs;
67
+ }
68
+
69
+ function invalidateCachedProject(cache: Map<string, ProjectInfo>, project: ProjectInfo): void {
70
+ for (const [key, value] of [...cache]) {
71
+ if (value === project) cache.delete(key);
72
+ }
73
+ freshnessByProject.delete(project);
74
+ }
75
+
21
76
  export function resolveProjectInfo(currentPath: string, cache = new Map<string, ProjectInfo>(), timeoutMs?: number): ProjectInfo {
22
77
  const normalizedPath = normalizePathForLookup(currentPath);
23
78
  const cached = lookupCachedProject(normalizedPath, cache);
24
- if (cached) {
79
+ if (cached && isFreshnessCurrent(cached)) {
25
80
  return cached;
26
81
  }
82
+ if (cached) {
83
+ invalidateCachedProject(cache, cached);
84
+ }
27
85
 
28
86
  const visited: string[] = [];
29
87
  const searchStart = resolveSearchStart(normalizedPath);
@@ -41,7 +99,7 @@ export function resolveProjectInfo(currentPath: string, cache = new Map<string,
41
99
  cache.set(cacheKey, project);
42
100
  }
43
101
 
44
- return project;
102
+ return project.isGitRepository ? freshenProject(project, gitMarkerDirectory) : project;
45
103
  }
46
104
 
47
105
  export function buildCompactProjectLabel(project: Pick<ProjectInfo, "displayName" | "isGitRepository" | "branch" | "isWorktree">): string {