@scrylog/cli 0.3.0 → 0.3.2

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 (195) hide show
  1. package/dist/assets/highlights-eq9cgrbb.scm +604 -0
  2. package/dist/assets/highlights-ghv9g403.scm +205 -0
  3. package/dist/assets/highlights-hk7bwhj4.scm +284 -0
  4. package/dist/assets/highlights-r812a2qc.scm +150 -0
  5. package/dist/assets/highlights-x6tmsnaa.scm +115 -0
  6. package/dist/assets/injections-73j83es3.scm +27 -0
  7. package/dist/assets/parser.worker-xyhf83rf.js +4563 -0
  8. package/dist/assets/tree-sitter-3jzf13jk.wasm +0 -0
  9. package/dist/assets/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  10. package/dist/assets/tree-sitter-markdown-411r6y9b.wasm +0 -0
  11. package/dist/assets/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  12. package/dist/assets/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  13. package/dist/assets/tree-sitter-zig-e78zbjpm.wasm +0 -0
  14. package/dist/cli/index.js +499 -0
  15. package/dist/cli/index.js.map +61 -0
  16. package/dist/daemon/index.js +670 -0
  17. package/dist/daemon/index.js.map +1475 -0
  18. package/dist/hooks/claude.js +6 -0
  19. package/dist/hooks/claude.js.map +11 -0
  20. package/dist/hooks/codex.js +10 -0
  21. package/dist/hooks/codex.js.map +11 -0
  22. package/dist/mcp/index.js +100 -0
  23. package/dist/mcp/index.js.map +231 -0
  24. package/dist/oc/index.js +21 -0
  25. package/dist/oc/index.js.map +10 -0
  26. package/package.json +16 -13
  27. package/src/api/docs/sessions.keys.ts +0 -6
  28. package/src/api/docs/sessions.ts +0 -82
  29. package/src/api/docs/system.keys.ts +0 -5
  30. package/src/api/docs/system.ts +0 -69
  31. package/src/api/docs/usage.keys.ts +0 -3
  32. package/src/api/docs/usage.ts +0 -24
  33. package/src/api/docs/webhooks.keys.ts +0 -5
  34. package/src/api/docs/webhooks.ts +0 -78
  35. package/src/api/index.ts +0 -18
  36. package/src/api/lib/auth.ts +0 -43
  37. package/src/api/lib/errors.ts +0 -28
  38. package/src/api/lib/route.ts +0 -18
  39. package/src/api/models/claude.ts +0 -4
  40. package/src/api/models/codex.ts +0 -4
  41. package/src/api/models/opencode.ts +0 -11
  42. package/src/api/routes/sessions.ts +0 -51
  43. package/src/api/routes/system.ts +0 -106
  44. package/src/api/routes/usage.ts +0 -15
  45. package/src/api/routes/webhooks/claude.ts +0 -149
  46. package/src/api/routes/webhooks/codex.ts +0 -175
  47. package/src/api/routes/webhooks/opencode.ts +0 -112
  48. package/src/api/routes.ts +0 -93
  49. package/src/api/schemas/common.ts +0 -29
  50. package/src/api/schemas/sessions.ts +0 -86
  51. package/src/api/schemas/usage.ts +0 -51
  52. package/src/api/schemas/webhooks/claude.ts +0 -32
  53. package/src/api/schemas/webhooks/codex.ts +0 -34
  54. package/src/api/schemas/webhooks/opencode.ts +0 -29
  55. package/src/api/services/claude-session.ts +0 -9
  56. package/src/api/services/codex-session.ts +0 -8
  57. package/src/api/services/opencode-session.ts +0 -73
  58. package/src/classifier.ts +0 -206
  59. package/src/cli/commands.ts +0 -714
  60. package/src/cli/index.ts +0 -18
  61. package/src/cli/program.ts +0 -175
  62. package/src/cli/prompt.ts +0 -44
  63. package/src/daemon/cli.ts +0 -27
  64. package/src/daemon/collectors/claude/README.md +0 -108
  65. package/src/daemon/collectors/claude/collector.ts +0 -423
  66. package/src/daemon/collectors/claude/discovery.ts +0 -127
  67. package/src/daemon/collectors/claude/hooks.ts +0 -181
  68. package/src/daemon/collectors/claude/index.ts +0 -8
  69. package/src/daemon/collectors/claude/jsonl.ts +0 -85
  70. package/src/daemon/collectors/claude/transcript.ts +0 -246
  71. package/src/daemon/collectors/codex/README.md +0 -147
  72. package/src/daemon/collectors/codex/collector.ts +0 -661
  73. package/src/daemon/collectors/codex/discovery.ts +0 -104
  74. package/src/daemon/collectors/codex/hooks.ts +0 -179
  75. package/src/daemon/collectors/codex/index.ts +0 -7
  76. package/src/daemon/collectors/codex/jsonl.ts +0 -108
  77. package/src/daemon/collectors/codex/transcript.ts +0 -299
  78. package/src/daemon/collectors/git.ts +0 -353
  79. package/src/daemon/collectors/herdr/collector.ts +0 -128
  80. package/src/daemon/collectors/herdr/index.ts +0 -2
  81. package/src/daemon/collectors/herdr/normalization.ts +0 -111
  82. package/src/daemon/collectors/opencode/README.md +0 -106
  83. package/src/daemon/collectors/opencode/collector.ts +0 -545
  84. package/src/daemon/collectors/opencode/discovery.ts +0 -257
  85. package/src/daemon/collectors/opencode/index.ts +0 -5
  86. package/src/daemon/collectors/opencode/session-builder.ts +0 -59
  87. package/src/daemon/collectors/opencode-db.ts +0 -294
  88. package/src/daemon/collectors/process.ts +0 -201
  89. package/src/daemon/collectors/tmux/README.md +0 -128
  90. package/src/daemon/collectors/tmux/collector.ts +0 -492
  91. package/src/daemon/collectors/tmux/enrichment.ts +0 -242
  92. package/src/daemon/collectors/tmux/index.ts +0 -4
  93. package/src/daemon/collectors/tmux/parse.ts +0 -128
  94. package/src/daemon/collectors/usage.ts +0 -259
  95. package/src/daemon/index.ts +0 -209
  96. package/src/daemon/scheduler.ts +0 -86
  97. package/src/daemon/store.ts +0 -630
  98. package/src/detect.ts +0 -143
  99. package/src/hooks/claude.ts +0 -596
  100. package/src/hooks/codex.ts +0 -517
  101. package/src/legacy/classifier.ts +0 -215
  102. package/src/legacy/detect.ts +0 -143
  103. package/src/mcp/client-server.ts +0 -21
  104. package/src/mcp/http-handler.ts +0 -126
  105. package/src/mcp/index.ts +0 -61
  106. package/src/mcp/resources/config-current.ts +0 -19
  107. package/src/mcp/resources/index.ts +0 -11
  108. package/src/mcp/resources/sessions-active.ts +0 -19
  109. package/src/mcp/resources/sessions-all.ts +0 -19
  110. package/src/mcp/schemas/sessions.ts +0 -3
  111. package/src/mcp/server.ts +0 -17
  112. package/src/mcp/services/api-adapter.ts +0 -48
  113. package/src/mcp/services/data-source.ts +0 -22
  114. package/src/mcp/services/store-adapter.ts +0 -40
  115. package/src/mcp/tools/get-active-sessions.ts +0 -16
  116. package/src/mcp/tools/get-session-children.ts +0 -24
  117. package/src/mcp/tools/get-session-messages.ts +0 -24
  118. package/src/mcp/tools/get-session.ts +0 -23
  119. package/src/mcp/tools/index.ts +0 -15
  120. package/src/mcp/tools/list-sessions.ts +0 -16
  121. package/src/oc/index.ts +0 -302
  122. package/src/service/manager.ts +0 -284
  123. package/src/shared/config.ts +0 -511
  124. package/src/shared/ipc.ts +0 -80
  125. package/src/shared/logger.ts +0 -248
  126. package/src/shared/paths.ts +0 -73
  127. package/src/shared/platform.ts +0 -110
  128. package/src/shared/session-exclusions.ts +0 -5
  129. package/src/shared/session-view.ts +0 -302
  130. package/src/shared/terminal-capabilities.ts +0 -130
  131. package/src/shared/types.ts +0 -330
  132. package/src/shared/utils.ts +0 -339
  133. package/src/shared/version.ts +0 -44
  134. package/src/tui/App.tsx +0 -240
  135. package/src/tui/client.ts +0 -163
  136. package/src/tui/components/BindingsModal.tsx +0 -82
  137. package/src/tui/components/DashboardHeader.tsx +0 -256
  138. package/src/tui/components/KeyBar.tsx +0 -67
  139. package/src/tui/components/SessionDetailModal.tsx +0 -169
  140. package/src/tui/components/SessionsView.tsx +0 -184
  141. package/src/tui/components/TabBar.tsx +0 -40
  142. package/src/tui/components/UsageSummaryPane.tsx +0 -272
  143. package/src/tui/components/UsageView.tsx +0 -242
  144. package/src/tui/components/tool-icons.ts +0 -24
  145. package/src/tui/index.tsx +0 -106
  146. package/src/tui/keybindings.ts +0 -49
  147. package/src/tui/model.ts +0 -295
  148. package/src/tui/state.ts +0 -77
  149. package/src/tui/theme.ts +0 -110
  150. package/src/types.ts +0 -113
  151. package/src/usage/README.md +0 -323
  152. package/src/usage/api.ts +0 -99
  153. package/src/usage/client.ts +0 -3
  154. package/src/usage/clients/anthropic-oauth-usage.ts +0 -258
  155. package/src/usage/clients/anthropic-web-usage.ts +0 -329
  156. package/src/usage/clients/claude-cli-probe.ts +0 -513
  157. package/src/usage/clients/codex-app-server.ts +0 -354
  158. package/src/usage/clients/codex-oauth-usage.ts +0 -240
  159. package/src/usage/clients/opencode-go-api.ts +0 -100
  160. package/src/usage/clients/opencode-go-usage.ts +0 -577
  161. package/src/usage/errors.ts +0 -33
  162. package/src/usage/index.ts +0 -20
  163. package/src/usage/local/opencode-go-local.ts +0 -194
  164. package/src/usage/mappers.ts +0 -282
  165. package/src/usage/planner.ts +0 -118
  166. package/src/usage/providers/claude.ts +0 -233
  167. package/src/usage/providers/codex.ts +0 -115
  168. package/src/usage/providers/opencode-go.ts +0 -115
  169. package/src/usage/resolver.ts +0 -4
  170. package/src/usage/resolvers/browser-cookies.ts +0 -808
  171. package/src/usage/resolvers/claude-auth.ts +0 -208
  172. package/src/usage/resolvers/claude-cli.ts +0 -24
  173. package/src/usage/resolvers/claude-desktop-auth.ts +0 -170
  174. package/src/usage/resolvers/claude-web.ts +0 -82
  175. package/src/usage/resolvers/codex-auth.ts +0 -174
  176. package/src/usage/resolvers/keychain-coordinator.ts +0 -397
  177. package/src/usage/resolvers/opencode-go-web.ts +0 -55
  178. package/src/usage/types.ts +0 -225
  179. package/src/usage/web-session-cache.ts +0 -37
  180. /package/{src/tui → dist/cli}/assets/providers/SOURCES.md +0 -0
  181. /package/{src/tui → dist/cli}/assets/providers/claude-terminal.png +0 -0
  182. /package/{src/tui → dist/cli}/assets/providers/claude-terminal.svg +0 -0
  183. /package/{src/tui → dist/cli}/assets/providers/claude.png +0 -0
  184. /package/{src/tui → dist/cli}/assets/providers/copilot.png +0 -0
  185. /package/{src/tui → dist/cli}/assets/providers/copilot.svg +0 -0
  186. /package/{src/tui → dist/cli}/assets/providers/openai-terminal.png +0 -0
  187. /package/{src/tui → dist/cli}/assets/providers/openai-terminal.svg +0 -0
  188. /package/{src/tui → dist/cli}/assets/providers/openai.png +0 -0
  189. /package/{src/tui → dist/cli}/assets/providers/opencode-terminal.png +0 -0
  190. /package/{src/tui → dist/cli}/assets/providers/opencode-terminal.svg +0 -0
  191. /package/{src/tui → dist/cli}/assets/providers/opencode.png +0 -0
  192. /package/{src/tui → dist/cli}/assets/providers/shell.png +0 -0
  193. /package/{src/tui → dist/cli}/assets/providers/shell.svg +0 -0
  194. /package/{src/tui → dist/cli}/assets/providers/unknown.png +0 -0
  195. /package/{src/tui → dist/cli}/assets/providers/unknown.svg +0 -0
@@ -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" && existingSession?.source === "standalone") {
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
- }
@@ -1,127 +0,0 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join, basename, dirname } from "node:path";
4
- import type { ScrylogConfig } from "../../../shared/config";
5
- import type { Logger } from "../../../shared/logger";
6
- import { maybeRunCommand } from "../../../shared/utils";
7
- import { IS_WINDOWS } from "../../../shared/platform";
8
- import { isKnownClaudeProbePath } from "../../../shared/session-exclusions";
9
-
10
- export interface DiscoveredTranscript {
11
- sessionId: string;
12
- path: string;
13
- lastModified: number;
14
- size: number;
15
- }
16
-
17
- export function getClaudeDir(config: ScrylogConfig): string {
18
- return config.claude.claudeDir ?? join(homedir(), ".claude");
19
- }
20
-
21
- /**
22
- * Decodes a Claude project slug (e.g. "-Volumes-Files-Workspace-Projects-other-scrylog-feature-daemon")
23
- * back to an absolute filesystem path ("/Volumes/Files/Workspace/Projects/other/scrylog-feature-daemon").
24
- *
25
- * Claude encodes project paths by replacing each "/" with "-", producing an ambiguous slug when
26
- * directory names themselves contain hyphens. This function resolves the ambiguity by greedily
27
- * walking the real filesystem: at each level it tries consuming 1, 2, 3… hyphen-separated parts
28
- * as a single directory name, choosing the first candidate that exists on disk.
29
- *
30
- * Returns null if the slug does not start with "-" or no matching path is found.
31
- */
32
- export function decodeClaudeProjectSlug(slug: string): string | null {
33
- if (!slug.startsWith("-")) return null;
34
- const parts = slug.slice(1).split("-");
35
-
36
- function walk(current: string, remaining: string[]): string | null {
37
- if (remaining.length === 0) return existsSync(current) ? current : null;
38
- for (let i = 1; i <= remaining.length; i++) {
39
- const name = remaining.slice(0, i).join("-");
40
- const candidate = join(current, name);
41
- if (i === remaining.length) {
42
- // Last possible split — accept if exists
43
- if (existsSync(candidate)) return candidate;
44
- } else if (existsSync(candidate) && statSync(candidate).isDirectory()) {
45
- const result = walk(candidate, remaining.slice(i));
46
- if (result !== null) return result;
47
- }
48
- }
49
- return null;
50
- }
51
-
52
- const root = IS_WINDOWS ? `${process.env.SYSTEMDRIVE ?? "C:"}\\` : "/";
53
- return walk(root, parts);
54
- }
55
-
56
- export function discoverTranscripts(claudeDir: string, hookManagedIds?: ReadonlySet<string>): DiscoveredTranscript[] {
57
- const projectsDir = join(claudeDir, "projects");
58
- const results: DiscoveredTranscript[] = [];
59
-
60
- if (!existsSync(projectsDir)) return results;
61
-
62
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
63
-
64
- try {
65
- for (const projectSlug of readdirSync(projectsDir)) {
66
- // Skip project directories emitted by known headless probe tools
67
- // (e.g. CodexBar's ClaudeProbe). Their transcripts are not interactive
68
- // sessions and would otherwise show as permanent "working" ghosts.
69
- if (isKnownClaudeProbePath(projectSlug)) continue;
70
- const projectPath = join(projectsDir, projectSlug);
71
- try {
72
- if (!statSync(projectPath).isDirectory()) continue;
73
- for (const file of readdirSync(projectPath)) {
74
- if (!file.endsWith(".jsonl")) continue;
75
- const fullPath = join(projectPath, file);
76
- try {
77
- const stat = statSync(fullPath);
78
- const sessionId = basename(file, ".jsonl");
79
- // Include transcripts beyond the cutoff if the session is hook-managed,
80
- // since hooks provide authoritative lifecycle and may keep a session
81
- // alive beyond 24 hours of inactivity in the transcript file.
82
- if (stat.mtimeMs < cutoff && !hookManagedIds?.has(sessionId)) continue;
83
- results.push({
84
- sessionId,
85
- path: fullPath,
86
- lastModified: stat.mtimeMs,
87
- size: stat.size,
88
- });
89
- } catch {
90
- // Skip unreadable files
91
- }
92
- }
93
- } catch {
94
- // Skip unreadable project directories
95
- }
96
- }
97
- } catch {
98
- // projects dir not accessible
99
- }
100
-
101
- return results;
102
- }
103
-
104
- export function getClaudeProcessPids(logger: Logger, timeoutMs?: number, processes?: { pid: number; command: string }[]): number[] {
105
- const result = maybeRunCommand(["pgrep", "-f", "claude"], timeoutMs);
106
- if (result.ok) {
107
- return result.output
108
- .split(/\r?\n/)
109
- .filter(Boolean)
110
- .map((s) => Number.parseInt(s, 10))
111
- .filter((n) => !Number.isNaN(n));
112
- }
113
-
114
- logger.debug("claude", "pgrep failed — falling back to process collector data", { error: result.error });
115
-
116
- if (processes && processes.length > 0) {
117
- return processes
118
- .filter((p) => isClaudeProcessCommand(p.command))
119
- .map((p) => p.pid);
120
- }
121
-
122
- return [];
123
- }
124
-
125
- export function isClaudeProcessCommand(command: string): boolean {
126
- return /(?:^|[\\/])claude(?:\.exe)?(?:\s|$)/i.test(command) && !/(scrylog|hook)/i.test(command);
127
- }