@scrylog/cli 0.2.0 → 0.3.1

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 (171) hide show
  1. package/README.md +46 -7
  2. package/dist/assets/highlights-eq9cgrbb.scm +604 -0
  3. package/dist/assets/highlights-ghv9g403.scm +205 -0
  4. package/dist/assets/highlights-hk7bwhj4.scm +284 -0
  5. package/dist/assets/highlights-r812a2qc.scm +150 -0
  6. package/dist/assets/highlights-x6tmsnaa.scm +115 -0
  7. package/dist/assets/injections-73j83es3.scm +27 -0
  8. package/dist/assets/parser.worker-xyhf83rf.js +4563 -0
  9. package/dist/assets/tree-sitter-3jzf13jk.wasm +0 -0
  10. package/dist/assets/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  11. package/dist/assets/tree-sitter-markdown-411r6y9b.wasm +0 -0
  12. package/dist/assets/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  13. package/dist/assets/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  14. package/dist/assets/tree-sitter-zig-e78zbjpm.wasm +0 -0
  15. package/dist/cli/assets/providers/SOURCES.md +21 -0
  16. package/dist/cli/assets/providers/claude-terminal.png +0 -0
  17. package/dist/cli/assets/providers/claude-terminal.svg +62 -0
  18. package/dist/cli/assets/providers/claude.png +0 -0
  19. package/dist/cli/assets/providers/copilot.png +0 -0
  20. package/dist/cli/assets/providers/copilot.svg +44 -0
  21. package/dist/cli/assets/providers/openai-terminal.png +0 -0
  22. package/dist/cli/assets/providers/openai-terminal.svg +62 -0
  23. package/dist/cli/assets/providers/openai.png +0 -0
  24. package/dist/cli/assets/providers/opencode-terminal.png +0 -0
  25. package/dist/cli/assets/providers/opencode-terminal.svg +62 -0
  26. package/dist/cli/assets/providers/opencode.png +0 -0
  27. package/dist/cli/assets/providers/shell.png +0 -0
  28. package/dist/cli/assets/providers/shell.svg +23 -0
  29. package/dist/cli/assets/providers/unknown.png +0 -0
  30. package/dist/cli/assets/providers/unknown.svg +23 -0
  31. package/dist/cli/index.js +499 -0
  32. package/dist/cli/index.js.map +61 -0
  33. package/dist/daemon/index.js +670 -0
  34. package/dist/daemon/index.js.map +1475 -0
  35. package/dist/hooks/claude.js +6 -0
  36. package/dist/hooks/claude.js.map +11 -0
  37. package/dist/hooks/codex.js +10 -0
  38. package/dist/hooks/codex.js.map +11 -0
  39. package/dist/mcp/index.js +100 -0
  40. package/dist/mcp/index.js.map +231 -0
  41. package/dist/oc/index.js +21 -0
  42. package/dist/oc/index.js.map +10 -0
  43. package/package.json +26 -20
  44. package/src/postinstall.cjs +11 -0
  45. package/src/api/docs/sessions.keys.ts +0 -6
  46. package/src/api/docs/sessions.ts +0 -81
  47. package/src/api/docs/system.keys.ts +0 -5
  48. package/src/api/docs/system.ts +0 -66
  49. package/src/api/docs/usage.keys.ts +0 -3
  50. package/src/api/docs/usage.ts +0 -24
  51. package/src/api/docs/webhooks.keys.ts +0 -5
  52. package/src/api/docs/webhooks.ts +0 -78
  53. package/src/api/index.ts +0 -18
  54. package/src/api/lib/auth.ts +0 -43
  55. package/src/api/lib/errors.ts +0 -28
  56. package/src/api/lib/route.ts +0 -18
  57. package/src/api/models/claude.ts +0 -4
  58. package/src/api/models/codex.ts +0 -4
  59. package/src/api/models/opencode.ts +0 -11
  60. package/src/api/routes/sessions.ts +0 -47
  61. package/src/api/routes/system.ts +0 -103
  62. package/src/api/routes/usage.ts +0 -15
  63. package/src/api/routes/webhooks/claude.ts +0 -141
  64. package/src/api/routes/webhooks/codex.ts +0 -175
  65. package/src/api/routes/webhooks/opencode.ts +0 -111
  66. package/src/api/routes.ts +0 -93
  67. package/src/api/schemas/common.ts +0 -21
  68. package/src/api/schemas/sessions.ts +0 -72
  69. package/src/api/schemas/usage.ts +0 -50
  70. package/src/api/schemas/webhooks/claude.ts +0 -32
  71. package/src/api/schemas/webhooks/codex.ts +0 -34
  72. package/src/api/schemas/webhooks/opencode.ts +0 -29
  73. package/src/api/services/claude-session.ts +0 -9
  74. package/src/api/services/codex-session.ts +0 -8
  75. package/src/api/services/opencode-session.ts +0 -73
  76. package/src/classifier.ts +0 -206
  77. package/src/cli/commands.ts +0 -632
  78. package/src/cli/prompt.ts +0 -44
  79. package/src/daemon/cli.ts +0 -27
  80. package/src/daemon/collectors/claude/README.md +0 -108
  81. package/src/daemon/collectors/claude/collector.ts +0 -423
  82. package/src/daemon/collectors/claude/discovery.ts +0 -148
  83. package/src/daemon/collectors/claude/hooks.ts +0 -181
  84. package/src/daemon/collectors/claude/index.ts +0 -8
  85. package/src/daemon/collectors/claude/jsonl.ts +0 -85
  86. package/src/daemon/collectors/claude/transcript.ts +0 -246
  87. package/src/daemon/collectors/codex/README.md +0 -147
  88. package/src/daemon/collectors/codex/collector.ts +0 -661
  89. package/src/daemon/collectors/codex/discovery.ts +0 -104
  90. package/src/daemon/collectors/codex/hooks.ts +0 -179
  91. package/src/daemon/collectors/codex/index.ts +0 -7
  92. package/src/daemon/collectors/codex/jsonl.ts +0 -108
  93. package/src/daemon/collectors/codex/transcript.ts +0 -299
  94. package/src/daemon/collectors/git.ts +0 -295
  95. package/src/daemon/collectors/opencode/README.md +0 -106
  96. package/src/daemon/collectors/opencode/collector.ts +0 -545
  97. package/src/daemon/collectors/opencode/discovery.ts +0 -257
  98. package/src/daemon/collectors/opencode/index.ts +0 -5
  99. package/src/daemon/collectors/opencode/session-builder.ts +0 -59
  100. package/src/daemon/collectors/opencode-db.ts +0 -282
  101. package/src/daemon/collectors/process.ts +0 -201
  102. package/src/daemon/collectors/tmux/README.md +0 -128
  103. package/src/daemon/collectors/tmux/collector.ts +0 -472
  104. package/src/daemon/collectors/tmux/enrichment.ts +0 -241
  105. package/src/daemon/collectors/tmux/index.ts +0 -4
  106. package/src/daemon/collectors/tmux/parse.ts +0 -128
  107. package/src/daemon/collectors/usage.ts +0 -193
  108. package/src/daemon/index.ts +0 -207
  109. package/src/daemon/scheduler.ts +0 -86
  110. package/src/daemon/store.ts +0 -588
  111. package/src/detect.ts +0 -143
  112. package/src/hooks/claude.ts +0 -596
  113. package/src/hooks/codex.ts +0 -517
  114. package/src/legacy/classifier.ts +0 -215
  115. package/src/legacy/detect.ts +0 -143
  116. package/src/mcp/client-server.ts +0 -21
  117. package/src/mcp/http-handler.ts +0 -126
  118. package/src/mcp/index.ts +0 -61
  119. package/src/mcp/resources/config-current.ts +0 -19
  120. package/src/mcp/resources/index.ts +0 -11
  121. package/src/mcp/resources/sessions-active.ts +0 -19
  122. package/src/mcp/resources/sessions-all.ts +0 -19
  123. package/src/mcp/schemas/sessions.ts +0 -3
  124. package/src/mcp/server.ts +0 -17
  125. package/src/mcp/services/api-adapter.ts +0 -48
  126. package/src/mcp/services/data-source.ts +0 -22
  127. package/src/mcp/services/store-adapter.ts +0 -40
  128. package/src/mcp/tools/get-active-sessions.ts +0 -16
  129. package/src/mcp/tools/get-session-children.ts +0 -24
  130. package/src/mcp/tools/get-session-messages.ts +0 -24
  131. package/src/mcp/tools/get-session.ts +0 -23
  132. package/src/mcp/tools/index.ts +0 -15
  133. package/src/mcp/tools/list-sessions.ts +0 -16
  134. package/src/neo-blessed.d.ts +0 -4
  135. package/src/oc/index.ts +0 -302
  136. package/src/service/manager.ts +0 -284
  137. package/src/shared/config.ts +0 -478
  138. package/src/shared/ipc.ts +0 -80
  139. package/src/shared/logger.ts +0 -248
  140. package/src/shared/paths.ts +0 -73
  141. package/src/shared/platform.ts +0 -110
  142. package/src/shared/session-view.ts +0 -241
  143. package/src/shared/types.ts +0 -299
  144. package/src/shared/utils.ts +0 -339
  145. package/src/tui/app.ts +0 -920
  146. package/src/tui/client.ts +0 -149
  147. package/src/tui/index.ts +0 -217
  148. package/src/types.ts +0 -113
  149. package/src/usage/README.md +0 -323
  150. package/src/usage/api.ts +0 -99
  151. package/src/usage/client.ts +0 -3
  152. package/src/usage/clients/anthropic-oauth-usage.ts +0 -178
  153. package/src/usage/clients/anthropic-web-usage.ts +0 -245
  154. package/src/usage/clients/claude-cli-probe.ts +0 -187
  155. package/src/usage/clients/codex-oauth-usage.ts +0 -113
  156. package/src/usage/clients/opencode-go-usage.ts +0 -441
  157. package/src/usage/index.ts +0 -20
  158. package/src/usage/mappers.ts +0 -273
  159. package/src/usage/planner.ts +0 -54
  160. package/src/usage/providers/claude.ts +0 -113
  161. package/src/usage/providers/codex.ts +0 -34
  162. package/src/usage/providers/opencode-go.ts +0 -37
  163. package/src/usage/resolver.ts +0 -4
  164. package/src/usage/resolvers/browser-cookies.ts +0 -470
  165. package/src/usage/resolvers/claude-auth.ts +0 -108
  166. package/src/usage/resolvers/claude-cli.ts +0 -24
  167. package/src/usage/resolvers/claude-desktop-auth.ts +0 -160
  168. package/src/usage/resolvers/claude-web.ts +0 -16
  169. package/src/usage/resolvers/codex-auth.ts +0 -117
  170. package/src/usage/resolvers/opencode-go-web.ts +0 -14
  171. package/src/usage/types.ts +0 -190
package/src/daemon/cli.ts DELETED
@@ -1,27 +0,0 @@
1
- export interface DaemonCliOptions {
2
- help: boolean;
3
- configPath?: string;
4
- logLevel?: string;
5
- logFormat?: "pretty" | "json";
6
- logFileEnabled: boolean;
7
- }
8
-
9
- export function parseDaemonArgs(args: string[]): DaemonCliOptions {
10
- const configFlagIndex = args.indexOf("--config");
11
- const logLevelFlagIndex = args.indexOf("--log-level");
12
- const logFormatFlagIndex = args.indexOf("--log-format");
13
- const rawLogFormat = logFormatFlagIndex >= 0 ? args[logFormatFlagIndex + 1] : undefined;
14
- const logFormat = rawLogFormat === "pretty" || rawLogFormat === "json" ? rawLogFormat : undefined;
15
-
16
- if (logFormatFlagIndex >= 0 && rawLogFormat !== "pretty" && rawLogFormat !== "json") {
17
- throw new Error("--log-format must be 'pretty' or 'json'");
18
- }
19
-
20
- return {
21
- help: args.includes("--help") || args.includes("-h"),
22
- configPath: configFlagIndex >= 0 ? args[configFlagIndex + 1] : undefined,
23
- logLevel: logLevelFlagIndex >= 0 ? args[logLevelFlagIndex + 1] : undefined,
24
- logFormat,
25
- logFileEnabled: !args.includes("--no-log-file"),
26
- };
27
- }
@@ -1,108 +0,0 @@
1
- # Claude Collector
2
-
3
- Monitors Claude Code interactive sessions by reading JSONL transcript files and
4
- optional hook files emitted by the `scrylog-claude-hook` binary.
5
-
6
- ## Data Flow
7
-
8
- ```
9
- ~/.claude/projects/<slug>/<sessionId>.jsonl ~/.claude/hooks/<sessionId>.json
10
- │ │
11
- │ │
12
- ▼ ▼
13
- ┌──────────────┐ ┌──────────────┐
14
- │ discovery │ discoverTranscripts() │ hooks │ readHookData()
15
- │ │ │ │
16
- └──────┬───────┘ └──────┬───────┘
17
- │ │
18
- │ │
19
- ▼ │
20
- ┌──────────────┐ │
21
- │ jsonl │ parseJsonlFile() / parseJsonlHead() │
22
- └──────┬───────┘ │
23
- │ │
24
- ▼ │
25
- ┌──────────────┐ │
26
- │ transcript │ inferState() │
27
- │ │ extractSummary() │
28
- │ │ extractTitle() │
29
- │ │ isLocalCommandTranscript() │
30
- └──────┬───────┘ │
31
- │ │
32
- ▼ ▼
33
- ┌──────────────────────────────────────────────────────────────┐
34
- │ collector.ts │
35
- │ │
36
- │ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
37
- │ │ 30s poll │ │ watchFile │ │ fs.watch │ │
38
- │ │ run() │ │ (2s interval)│ │ hooksDir │ │
39
- │ └──────┬───────┘ └──────┬───────┘ └────┬─────┘ │
40
- │ │ │ │ │
41
- │ │ │ │ │
42
- │ └────────────┬────────┴────────────────────┘ │
43
- │ │ │
44
- │ ▼ │
45
- │ processTranscript() │
46
- │ │ │
47
- │ │ ┌───────────────────────────────┐ │
48
- │ └──│ mergeHookIntoModel() │ │
49
- │ │ (if hook newer than transcript)│ │
50
- │ └───────────────────────────────┘ │
51
- │ │ │
52
- │ ▼ │
53
- │ store.upsertSession() │
54
- │ store.syncChildSessionIds() │
55
- └──────────────────────────────────────────────────────────────┘
56
- │
57
- ▼
58
- DaemonStore ──► /api/events SSE ──► TUI
59
- ```
60
-
61
- ## Modules
62
-
63
- | File | Responsibility | Key Exports |
64
- |------|-------------|-------------|
65
- | `discovery.ts` | Scan `~/.claude/projects/<slug>/` for `.jsonl` files; decode slug back to absolute path | `discoverTranscripts()`, `decodeClaudeProjectSlug()`, `getClaudeDir()`, `getClaudeProcessPids()` |
66
- | `jsonl.ts` | Read JSONL tail/head with byte-size limits; parse lines into typed entries | `parseJsonlFile()`, `parseJsonlHead()`, `readTextTail()`, `readTextHead()`, `JsonlEntry`, `ContentBlock` |
67
- | `transcript.ts` | Infer session state from the last meaningful turn; extract summary/title; detect headless `claude -p` transcripts | `inferState()`, `extractSummary()`, `extractTitle()`, `isLocalCommandTranscript()`, `isQuestionText()` |
68
- | `hooks.ts` | Read/validate hook JSON files; merge hook data into a session model; merge claude metadata without overwriting existing fields | `readHookData()`, `mergeHookIntoModel()`, `mergeClaudeMeta()`, `isHookNewer()`, `getHookTime()`, `isValidHookData()`, `hasClaudePermissionPrompt()`, `ClaudeHookFileData` |
69
- | `collector.ts` | Orchestrate discovery, transcript watching, hook watching, deduplication, and store writes | `ClaudeCollector` (implements `Collector`) |
70
- | `index.ts` | Public re-exports | `ClaudeCollector`, test-accessible pure functions |
71
-
72
- ## State Inference Rules (`transcript.ts`)
73
-
74
- `inferState()` walks the JSONL entries in reverse order looking for the last
75
- **meaningful** turn, skipping:
76
- 1. Synthetic user entries (tool results, "[Request interrupted by user]")
77
- 2. Thinking-only assistant entries
78
- 3. System/tool events without a role
79
-
80
- | Last meaningful turn | State | Reason |
81
- |----------------------|-------|--------|
82
- | `assistant` with question text | `waiting-input` | Claude is asking the user something |
83
- | `assistant` with visible text, no tool_use | `idle` | Claude has replied |
84
- | `assistant` with tool_use, no visible text | `working` | Claude is executing a tool |
85
- | `user` (non-synthetic) | `working` | Waiting for Claude's reply |
86
- | Empty / only synthetic entries | `idle` | Nothing meaningful happened |
87
-
88
- ## Hook Merge Priority
89
-
90
- When both transcript and hook data exist, the collector uses this priority:
91
-
92
- 1. **Hook newer** (`hookSeq` or `hookEventTime` >= existing): hook data wins for `state`, `hasAttention`, `summary`, `activeTool`
93
- 2. **Hook older**: transcript data wins (except `claude` metadata merged via `mergeClaudeMeta`)
94
- 3. **Hook-managed session** (`detectionSource === "claude-hook"`): transcript can only fill missing metadata (e.g. `transcriptPath`), never override state
95
-
96
- ## Interactions
97
-
98
- | Component | How |
99
- |-----------|-----|
100
- | `GitCollector` | Resolves `currentPath` → `project` when hooks/transcript provide a directory |
101
- | `DaemonStore` | Upserts sessions; clears old bindings via `syncChildSessionIds()` |
102
- | `Scheduler` | Calls `start()` → sets up `30s` discovery interval + `5s` fallback re-read |
103
- | `TUI` | Receives updates via SSE from the store |
104
-
105
- ## Detection Sources Produced
106
-
107
- - `"claude-transcript"` — session discovered from `.jsonl` file, state inferred from transcript content
108
- - `"claude-hook"` — session discovered/enriched from hook JSON, state is authoritative from Claude Code directly
@@ -1,423 +0,0 @@
1
- import { existsSync, statSync, unwatchFile, watch, watchFile } from "node:fs";
2
- import type { FSWatcher } from "node:fs";
3
- import { join, basename, dirname } from "node:path";
4
- import type { ScrylogConfig } from "../../../shared/config";
5
- import type { SessionModel } from "../../../shared/types";
6
- import type { DaemonStore } from "../../store";
7
- import type { Collector } from "../../scheduler";
8
- import type { Logger } from "../../../shared/logger";
9
- import { NOOP_LOGGER } from "../../../shared/logger";
10
- import { ensureHooksDirSafe } from "../../../shared/platform";
11
- import type { GitCollector } from "../git";
12
- import { parseJsonlFile, parseJsonlHead } from "./jsonl";
13
- import { inferState, extractSummary, extractTitle, isLocalCommandTranscript } from "./transcript";
14
- import { ClaudeHookFileData, isValidHookData, readHookData, isHookNewer, getHookTime, getExistingHookData, mergeHookIntoModel, mergeClaudeMeta, hasClaudePermissionPrompt } from "./hooks";
15
- import { discoverTranscripts, getClaudeDir, decodeClaudeProjectSlug, getClaudeProcessPids } from "./discovery";
16
-
17
- interface WatchedTranscript {
18
- sessionId: string;
19
- path: string;
20
- lastModified: number;
21
- size: number;
22
- processedLastModified?: number;
23
- processedSize?: number;
24
- }
25
-
26
- export class ClaudeCollector implements Collector {
27
- name = "claude";
28
- private store?: DaemonStore;
29
- private config?: ScrylogConfig;
30
- private logger: Logger = NOOP_LOGGER;
31
- private watched = new Map<string, WatchedTranscript>();
32
- private hookWatched = new Set<string>();
33
- private hookDirWatcher?: FSWatcher;
34
- private lastProcessedSeq = new Map<string, number>();
35
- private discoveryTimer?: ReturnType<typeof setInterval>;
36
- private fallbackTimer?: ReturnType<typeof setInterval>;
37
-
38
- constructor(private readonly gitCollector?: GitCollector) {}
39
-
40
- async start(store: DaemonStore, config: ScrylogConfig, logger: Logger): Promise<void> {
41
- this.store = store;
42
- this.config = config;
43
- this.logger = logger;
44
-
45
- const claudeDir = getClaudeDir(config);
46
- logger.info("claude", "Collector started", { event: "collector.started", collector: this.name, claudeDir, hooksEnabled: config.claude.hooksEnabled });
47
-
48
- if (config.claude.hooksEnabled) {
49
- this.startHooksDirWatcher(config.claude.hooksDir);
50
- }
51
-
52
- this.run();
53
- this.discoveryTimer = setInterval(() => this.run(), 30_000);
54
- this.fallbackTimer = setInterval(() => this.rereadWatched(), 5_000);
55
- }
56
-
57
- async stop(): Promise<void> {
58
- if (this.discoveryTimer) clearInterval(this.discoveryTimer);
59
- if (this.fallbackTimer) clearInterval(this.fallbackTimer);
60
-
61
- for (const { path } of this.watched.values()) {
62
- try { unwatchFile(path); } catch { /* ignore */ }
63
- }
64
- this.watched.clear();
65
-
66
- for (const hookFile of this.hookWatched) {
67
- try { unwatchFile(hookFile); } catch { /* ignore */ }
68
- }
69
- this.hookWatched.clear();
70
- this.lastProcessedSeq.clear();
71
-
72
- if (this.hookDirWatcher) {
73
- try { this.hookDirWatcher.close(); } catch { /* ignore */ }
74
- this.hookDirWatcher = undefined;
75
- }
76
-
77
- this.logger.info("claude", "Collector stopped", { event: "collector.stopped", collector: this.name });
78
- }
79
-
80
- // ── Internal ───────────────────────────────────────────────────────────────
81
-
82
- private run(): void {
83
- const store = this.store!;
84
- const config = this.config!;
85
- const claudeDir = getClaudeDir(config);
86
-
87
- // Build set of session IDs that are hook-managed, so discoverTranscripts
88
- // can include their transcripts even past the 24h cutoff.
89
- const hookManagedIds = new Set<string>();
90
- for (const session of store.getSessions()) {
91
- if (session.detectionSource === "claude-hook" && session.tool === "claude") {
92
- hookManagedIds.add(session.claude?.sessionId ?? session.id.replace(/^claude:/, ""));
93
- }
94
- }
95
-
96
- const transcripts = discoverTranscripts(claudeDir, hookManagedIds);
97
- const processSnapshot = this.store?.getCollectorProcessSnapshot?.() ?? [];
98
- const runningPids = getClaudeProcessPids(this.logger, config.daemon?.commandTimeoutMs, processSnapshot);
99
- const hasRunning = runningPids.length > 0;
100
-
101
- this.logger.debug("claude", "Discovery cycle", {
102
- event: "collector.discovery_completed",
103
- collector: this.name,
104
- claudeDir,
105
- transcripts: transcripts.length,
106
- runningPids: runningPids.length,
107
- hasRunning,
108
- });
109
-
110
- for (const transcript of transcripts) {
111
- this.processTranscript(transcript, hasRunning);
112
- }
113
-
114
- const trackedPaths = new Set(transcripts.map((t) => t.path));
115
- for (const path of trackedPaths) {
116
- if (!this.watched.has(path)) {
117
- const transcript = transcripts.find((t) => t.path === path);
118
- if (!transcript) continue;
119
- this.watched.set(path, transcript);
120
- this.logger.debug("claude", "Watching transcript", { sessionId: transcript.sessionId, path });
121
- watchFile(path, { interval: 2000 }, () => {
122
- const t = this.watched.get(path);
123
- if (t) this.processTranscript(t, true);
124
- });
125
- }
126
- }
127
-
128
- for (const [path, transcript] of this.watched) {
129
- if (!trackedPaths.has(path)) {
130
- try { unwatchFile(path); } catch { /* ignore */ }
131
- this.watched.delete(path);
132
- this.logger.debug("claude", "Stopped watching transcript", { sessionId: transcript.sessionId });
133
- // Don't remove hook-managed sessions — they have an authoritative lifecycle
134
- // managed by hooks (SessionEnd). Only remove transcript-sourced sessions.
135
- const existingSession = store.getSession(`claude:${transcript.sessionId}`);
136
- if (existingSession?.detectionSource !== "claude-hook") {
137
- store.removeSession(`claude:${transcript.sessionId}`);
138
- }
139
- }
140
- }
141
-
142
- store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now() });
143
- }
144
-
145
- private processTranscript(transcript: import("./discovery").DiscoveredTranscript, _hasRunning: boolean): void {
146
- const store = this.store!;
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
-
158
- const existingSession = store.getSession(`claude:${transcript.sessionId}`);
159
- const watched = this.watched.get(transcript.path);
160
- if (
161
- watched?.processedLastModified === transcript.lastModified
162
- && watched.processedSize === transcript.size
163
- && existingSession
164
- ) {
165
- return;
166
- }
167
-
168
- const transcriptMaxBytes = config.retention?.transcriptMaxBytes;
169
- const entries = parseJsonlFile(transcript.path, transcriptMaxBytes);
170
- const localCommandEntries = transcriptMaxBytes && transcript.size > transcriptMaxBytes
171
- ? parseJsonlHead(transcript.path, Math.min(transcriptMaxBytes, 64 * 1024))
172
- : entries;
173
-
174
- // Skip headless batch runs — they are not interactive sessions
175
- if (isLocalCommandTranscript(localCommandEntries)) {
176
- this.logger.debug("claude", "Skipping local-command transcript", { sessionId: transcript.sessionId });
177
- store.removeSession(`claude:${transcript.sessionId}`);
178
- store.markLocalCommand(transcript.sessionId);
179
- this.lastProcessedSeq.delete(transcript.sessionId);
180
- return;
181
- }
182
-
183
- const state = inferState(entries);
184
- const summary = extractSummary(entries);
185
- const title = extractTitle(entries);
186
-
187
- let model: SessionModel = {
188
- id: `claude:${transcript.sessionId}`,
189
- source: "standalone",
190
- tool: "claude",
191
- state,
192
- // Use first user message as title when available, falling back to summary
193
- summary: title ?? summary,
194
- lastActivity: transcript.lastModified,
195
- claude: {
196
- sessionId: transcript.sessionId,
197
- transcriptPath: transcript.path,
198
- },
199
- hasAttention: state === "waiting-input",
200
- detectionSource: "claude-transcript",
201
- };
202
-
203
- // If the session is already managed by hooks, the transcript must never
204
- // overwrite state, hasAttention, or activeTool. It can only fill missing
205
- // metadata (e.g. transcriptPath).
206
- if (existingSession?.detectionSource === "claude-hook") {
207
- model = {
208
- ...model,
209
- state: existingSession.state,
210
- hasAttention: existingSession.hasAttention,
211
- summary: existingSession.summary || model.summary,
212
- activeTool: existingSession.activeTool,
213
- detectionSource: "claude-hook",
214
- claude: mergeClaudeMeta(
215
- {
216
- sessionId: existingSession.claude?.sessionId ?? transcript.sessionId,
217
- transcriptPath: existingSession.claude?.transcriptPath || transcript.path,
218
- },
219
- existingSession.claude ?? {},
220
- ),
221
- lastActivity: existingSession.lastActivity,
222
- };
223
- } else if (config.claude.hooksEnabled) {
224
- // Hooks are authoritative for live session state. The transcript is only
225
- // used for discovery and to fill metadata hooks do not provide.
226
- const fileHookData = readHookData(config.claude.hooksDir, transcript.sessionId);
227
- const storeHookData = getExistingHookData(existingSession);
228
- const preferredHookData = [fileHookData, storeHookData]
229
- .filter((value): value is Partial<SessionModel> => value !== null)
230
- .sort((a, b) => getHookTime(b) - getHookTime(a))[0];
231
-
232
- if (preferredHookData && getHookTime(preferredHookData) >= transcript.lastModified && isHookNewer(existingSession, preferredHookData)) {
233
- model = mergeHookIntoModel(model, preferredHookData, transcript.path);
234
- }
235
- }
236
-
237
- // Apply tmux-based permission prompt detection only for sessions NOT
238
- // managed by hooks, since hook-managed sessions get authoritative state
239
- // from the PermissionRequest event.
240
- if (model.detectionSource !== "claude-hook" && hasClaudePermissionPrompt(existingSession?.sample)) {
241
- model = { ...model, state: "waiting-input", hasAttention: true, summary: "Do you want to proceed?" };
242
- }
243
-
244
- // Resolve git project info from currentPath (populated by hooks or transcript)
245
- // Fallback: decode the project path from the transcript directory slug when hooks
246
- // have not provided a currentPath (e.g. transcript-only sessions or sessions whose
247
- // hook file is absent).
248
- if (!model.currentPath) {
249
- const slug = basename(dirname(transcript.path));
250
- const decoded = decodeClaudeProjectSlug(slug);
251
- if (decoded) model = { ...model, currentPath: decoded };
252
- }
253
- if (this.gitCollector && model.currentPath && !model.project) {
254
- const project = this.gitCollector.resolve(model.currentPath);
255
- if (project) model = { ...model, project };
256
- }
257
-
258
- store.upsertSession(model);
259
- const currentWatched = this.watched.get(transcript.path);
260
- if (currentWatched) {
261
- this.watched.set(transcript.path, {
262
- ...currentWatched,
263
- lastModified: transcript.lastModified,
264
- size: transcript.size,
265
- processedLastModified: transcript.lastModified,
266
- processedSize: transcript.size,
267
- });
268
- }
269
- this.logger.debug("claude", "Processed transcript", { event: "transcript.processed", sessionId: transcript.sessionId, state, detectionSource: model.detectionSource, path: transcript.path });
270
-
271
- this.syncChildSessionIds();
272
- }
273
-
274
- /** Re-process all watched transcripts (fallback polling) */
275
- private rereadWatched(): void {
276
- for (const transcript of this.watched.values()) {
277
- try {
278
- const stat = statSync(transcript.path);
279
- this.processTranscript({ ...transcript, lastModified: stat.mtimeMs, size: stat.size }, true);
280
- } catch {
281
- // File may have been removed
282
- }
283
- }
284
- }
285
-
286
- /** Watch the hooks directory for new/changed files so state updates are near-immediate */
287
- private startHooksDirWatcher(hooksDir: string): void {
288
- try {
289
- ensureHooksDirSafe(hooksDir);
290
- } catch {
291
- // Directory may be unwritable or fail ownership/permission validation — handled below
292
- }
293
-
294
- if (!existsSync(hooksDir)) {
295
- this.logger.debug("claude", "Hooks dir not found — skipping watcher", { hooksDir });
296
- return;
297
- }
298
-
299
- // Watch for new files appearing in the hooks dir
300
- try {
301
- this.hookDirWatcher = watch(hooksDir, (eventType, filename) => {
302
- if (!filename || !filename.endsWith(".json")) return;
303
- const sessionId = basename(filename, ".json");
304
- this.onHookFileChanged(hooksDir, sessionId, filename);
305
- });
306
- } catch {
307
- this.logger.debug("claude", "Could not watch hooks dir", { hooksDir });
308
- }
309
- }
310
-
311
- /** Called when a hook file is created or modified */
312
- private onHookFileChanged(hooksDir: string, sessionId: string, filename: string): void {
313
- const hookFile = join(hooksDir, filename);
314
-
315
- // Start watching this specific file if not already
316
- if (!this.hookWatched.has(hookFile)) {
317
- this.hookWatched.add(hookFile);
318
- watchFile(hookFile, { interval: 500 }, () => {
319
- this.onHookFileChanged(hooksDir, sessionId, filename);
320
- });
321
- }
322
-
323
- // Skip duplicate processing: if the hookSeq hasn't changed, this is a
324
- // redundant event from the directory watcher and per-file watchFile.
325
- const hookData = readHookData(hooksDir, sessionId);
326
- if (!hookData) {
327
- // Hook file was deleted (SessionEnd) — clean up watcher
328
- this.unwatchHookFile(hookFile);
329
- return;
330
- }
331
- const incomingSeq = hookData.claude?.hookSeq ?? 0;
332
- const lastSeq = this.lastProcessedSeq.get(sessionId) ?? -1;
333
- if (incomingSeq <= lastSeq) return;
334
- this.lastProcessedSeq.set(sessionId, incomingSeq);
335
-
336
- this.logger.debug("claude", "Hook received — refreshing session", { event: "integration.event_received", sessionId, hookSeq: incomingSeq });
337
-
338
- // Find the corresponding transcript to re-process
339
- const transcriptEntry = [...this.watched.values()].find((t) => t.sessionId === sessionId);
340
- if (transcriptEntry) {
341
- try {
342
- const stat = statSync(transcriptEntry.path);
343
- this.processTranscript({ ...transcriptEntry, lastModified: stat.mtimeMs, size: stat.size }, true);
344
- } catch {
345
- // Transcript may have been removed
346
- }
347
- return;
348
- }
349
-
350
- // The transcript may not be watched yet (e.g. a sub-agent session created by SubagentStop hook).
351
- // hookData was already read above for dedup — use it to create a minimal session entry.
352
-
353
- const existing = this.store!.getSession(`claude:${sessionId}`);
354
- if (existing && !isHookNewer(existing, hookData)) {
355
- return;
356
- }
357
- if (existing) {
358
- // Merge hook data into existing session
359
- const mergedSummary = hookData.summary ?? existing.summary;
360
- this.store!.upsertSession({
361
- ...existing,
362
- ...hookData,
363
- id: existing.id,
364
- source: existing.source,
365
- summary: mergedSummary,
366
- lastActivity: (hookData.lastActivity as number | undefined) ?? existing.lastActivity,
367
- activeTool: hookData.activeTool ?? existing.activeTool,
368
- claude: mergeClaudeMeta(
369
- {
370
- sessionId,
371
- transcriptPath: (hookData.claude?.transcriptPath || existing.claude?.transcriptPath || "") as string,
372
- },
373
- hookData.claude ?? {},
374
- ),
375
- });
376
- this.logger.debug("claude", "Hook merged into session (no transcript)", { event: "session.upsert_from_hook", sessionId, state: hookData.state });
377
- } else {
378
- // Minimal stub for a sub-agent session discovered only via hook (e.g. SubagentStop)
379
- const currentPath = hookData.currentPath as string | undefined;
380
- const project = (this.gitCollector && currentPath)
381
- ? this.gitCollector.resolve(currentPath) ?? undefined
382
- : undefined;
383
- const stub: SessionModel = {
384
- id: `claude:${sessionId}`,
385
- source: "standalone",
386
- tool: "claude",
387
- state: (hookData.state as SessionModel["state"]) ?? "idle",
388
- summary: hookData.summary ?? "Claude sub-agent",
389
- lastActivity: (hookData.lastActivity as number) ?? Date.now(),
390
- hasAttention: hookData.hasAttention,
391
- activeTool: hookData.activeTool,
392
- currentPath,
393
- project,
394
- parentSessionId: hookData.parentSessionId,
395
- detectionSource: "claude-hook",
396
- claude: mergeClaudeMeta(
397
- {
398
- sessionId,
399
- transcriptPath: (hookData.claude?.transcriptPath ?? "") as string,
400
- },
401
- hookData.claude ?? {},
402
- ),
403
- };
404
- this.store!.upsertSession(stub);
405
- this.logger.debug("claude", "Sub-agent session created from hook", { event: "session.upsert_from_hook", sessionId, parentSessionId: hookData.parentSessionId, state: stub.state });
406
- }
407
-
408
- this.syncChildSessionIds();
409
- }
410
-
411
- /** Stop watching a per-file watcher and clean up tracking state */
412
- private unwatchHookFile(hookFile: string): void {
413
- if (this.hookWatched.has(hookFile)) {
414
- this.hookWatched.delete(hookFile);
415
- try { unwatchFile(hookFile); } catch { /* ignore */ }
416
- }
417
- }
418
-
419
- /** Rebuild childSessionIds on parent sessions after any session upsert */
420
- private syncChildSessionIds(): void {
421
- this.store!.syncChildSessionIds();
422
- }
423
- }