@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
@@ -1,148 +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
-
9
- export interface DiscoveredTranscript {
10
- sessionId: string;
11
- path: string;
12
- lastModified: number;
13
- size: number;
14
- }
15
-
16
- export function getClaudeDir(config: ScrylogConfig): string {
17
- return config.claude.claudeDir ?? join(homedir(), ".claude");
18
- }
19
-
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
- /**
43
- * Decodes a Claude project slug (e.g. "-Volumes-Files-Workspace-Projects-other-scrylog-feature-daemon")
44
- * back to an absolute filesystem path ("/Volumes/Files/Workspace/Projects/other/scrylog-feature-daemon").
45
- *
46
- * Claude encodes project paths by replacing each "/" with "-", producing an ambiguous slug when
47
- * directory names themselves contain hyphens. This function resolves the ambiguity by greedily
48
- * walking the real filesystem: at each level it tries consuming 1, 2, 3… hyphen-separated parts
49
- * as a single directory name, choosing the first candidate that exists on disk.
50
- *
51
- * Returns null if the slug does not start with "-" or no matching path is found.
52
- */
53
- export function decodeClaudeProjectSlug(slug: string): string | null {
54
- if (!slug.startsWith("-")) return null;
55
- const parts = slug.slice(1).split("-");
56
-
57
- function walk(current: string, remaining: string[]): string | null {
58
- if (remaining.length === 0) return existsSync(current) ? current : null;
59
- for (let i = 1; i <= remaining.length; i++) {
60
- const name = remaining.slice(0, i).join("-");
61
- const candidate = join(current, name);
62
- if (i === remaining.length) {
63
- // Last possible split — accept if exists
64
- if (existsSync(candidate)) return candidate;
65
- } else if (existsSync(candidate) && statSync(candidate).isDirectory()) {
66
- const result = walk(candidate, remaining.slice(i));
67
- if (result !== null) return result;
68
- }
69
- }
70
- return null;
71
- }
72
-
73
- const root = IS_WINDOWS ? `${process.env.SYSTEMDRIVE ?? "C:"}\\` : "/";
74
- return walk(root, parts);
75
- }
76
-
77
- export function discoverTranscripts(claudeDir: string, hookManagedIds?: ReadonlySet<string>): DiscoveredTranscript[] {
78
- const projectsDir = join(claudeDir, "projects");
79
- const results: DiscoveredTranscript[] = [];
80
-
81
- if (!existsSync(projectsDir)) return results;
82
-
83
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
84
-
85
- try {
86
- for (const projectSlug of readdirSync(projectsDir)) {
87
- // Skip project directories emitted by known headless probe tools
88
- // (e.g. CodexBar's ClaudeProbe). Their transcripts are not interactive
89
- // sessions and would otherwise show as permanent "working" ghosts.
90
- if (isKnownProbeSlug(projectSlug)) continue;
91
- const projectPath = join(projectsDir, projectSlug);
92
- try {
93
- if (!statSync(projectPath).isDirectory()) continue;
94
- for (const file of readdirSync(projectPath)) {
95
- if (!file.endsWith(".jsonl")) continue;
96
- const fullPath = join(projectPath, file);
97
- try {
98
- const stat = statSync(fullPath);
99
- const sessionId = basename(file, ".jsonl");
100
- // Include transcripts beyond the cutoff if the session is hook-managed,
101
- // since hooks provide authoritative lifecycle and may keep a session
102
- // alive beyond 24 hours of inactivity in the transcript file.
103
- if (stat.mtimeMs < cutoff && !hookManagedIds?.has(sessionId)) continue;
104
- results.push({
105
- sessionId,
106
- path: fullPath,
107
- lastModified: stat.mtimeMs,
108
- size: stat.size,
109
- });
110
- } catch {
111
- // Skip unreadable files
112
- }
113
- }
114
- } catch {
115
- // Skip unreadable project directories
116
- }
117
- }
118
- } catch {
119
- // projects dir not accessible
120
- }
121
-
122
- return results;
123
- }
124
-
125
- export function getClaudeProcessPids(logger: Logger, timeoutMs?: number, processes?: { pid: number; command: string }[]): number[] {
126
- const result = maybeRunCommand(["pgrep", "-f", "claude"], timeoutMs);
127
- if (result.ok) {
128
- return result.output
129
- .split(/\r?\n/)
130
- .filter(Boolean)
131
- .map((s) => Number.parseInt(s, 10))
132
- .filter((n) => !Number.isNaN(n));
133
- }
134
-
135
- logger.debug("claude", "pgrep failed — falling back to process collector data", { error: result.error });
136
-
137
- if (processes && processes.length > 0) {
138
- return processes
139
- .filter((p) => isClaudeProcessCommand(p.command))
140
- .map((p) => p.pid);
141
- }
142
-
143
- return [];
144
- }
145
-
146
- export function isClaudeProcessCommand(command: string): boolean {
147
- return /(?:^|[\\/])claude(?:\.exe)?(?:\s|$)/i.test(command) && !/(scrylog|hook)/i.test(command);
148
- }
@@ -1,181 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import type { SessionModel, SessionState } from "../../../shared/types";
4
- import type { ClaudeSessionMeta } from "../../../shared/types";
5
-
6
- export interface ClaudeHookFileData {
7
- eventType?: string;
8
- eventTime?: number;
9
- hookSeq?: number;
10
- state?: SessionState;
11
- hasAttention?: boolean;
12
- attentionKind?: "permission" | "question" | "turn" | "none";
13
- summary?: string;
14
- activeTool?: string;
15
- toolName?: string;
16
- toolInputSummary?: string;
17
- toolResultStatus?: "started" | "completed" | "error" | "blocked" | "denied" | "unknown";
18
- permissionMode?: string;
19
- startSource?: string;
20
- endReason?: string;
21
- errorType?: string;
22
- agentId?: string;
23
- agentType?: string;
24
- transcriptPath?: string;
25
- currentPath?: string;
26
- lastActivity?: number;
27
- parentSessionId?: string;
28
- detectionSource?: "claude-hook";
29
- }
30
-
31
- export function hasClaudePermissionPrompt(sample: SessionModel["sample"] | null | undefined): boolean {
32
- if (!sample) return false;
33
- return /Do you want to proceed\?/i.test(sample.lines.join("\n"));
34
- }
35
-
36
- export function isValidHookData(data: ClaudeHookFileData): boolean {
37
- return typeof data.eventType === "string"
38
- && data.eventType.length > 0
39
- && typeof data.eventTime === "number"
40
- && Number.isFinite(data.eventTime)
41
- && typeof data.hookSeq === "number"
42
- && Number.isFinite(data.hookSeq)
43
- && data.state !== undefined
44
- && typeof data.hasAttention === "boolean"
45
- && typeof data.attentionKind === "string";
46
- }
47
-
48
- export function readHookData(hooksDir: string, sessionId: string): Partial<SessionModel> | null {
49
- if (!existsSync(hooksDir)) return null;
50
- const hookFile = join(hooksDir, `${sessionId}.json`);
51
- if (!existsSync(hookFile)) return null;
52
- try {
53
- const raw = readFileSync(hookFile, "utf8");
54
- const data = JSON.parse(raw) as ClaudeHookFileData;
55
- if (!isValidHookData(data)) return null;
56
- return {
57
- ...(data.state !== undefined ? { state: data.state } : {}),
58
- ...(data.hasAttention !== undefined ? { hasAttention: data.hasAttention } : {}),
59
- ...(data.summary !== undefined ? { summary: data.summary } : {}),
60
- ...(data.activeTool !== undefined ? { activeTool: data.activeTool } : {}),
61
- ...(data.currentPath !== undefined ? { currentPath: data.currentPath } : {}),
62
- ...(data.lastActivity !== undefined ? { lastActivity: data.lastActivity } : {}),
63
- ...(data.parentSessionId !== undefined ? { parentSessionId: data.parentSessionId } : {}),
64
- ...(data.detectionSource !== undefined ? { detectionSource: data.detectionSource } : {}),
65
- claude: {
66
- sessionId,
67
- transcriptPath: data.transcriptPath ?? "",
68
- ...(data.eventType !== undefined ? { hookEventType: data.eventType } : {}),
69
- ...(data.eventTime !== undefined ? { hookEventTime: data.eventTime } : {}),
70
- ...(data.hookSeq !== undefined ? { hookSeq: data.hookSeq } : {}),
71
- ...(data.permissionMode !== undefined ? { permissionMode: data.permissionMode } : {}),
72
- ...(data.startSource !== undefined ? { startSource: data.startSource } : {}),
73
- ...(data.endReason !== undefined ? { endReason: data.endReason } : {}),
74
- ...(data.errorType !== undefined ? { errorType: data.errorType } : {}),
75
- ...(data.attentionKind !== undefined ? { attentionKind: data.attentionKind } : {}),
76
- ...(data.toolName !== undefined ? { toolName: data.toolName } : {}),
77
- ...(data.toolInputSummary !== undefined ? { toolInputSummary: data.toolInputSummary } : {}),
78
- ...(data.toolResultStatus !== undefined ? { toolResultStatus: data.toolResultStatus } : {}),
79
- ...(data.agentId !== undefined ? { agentId: data.agentId } : {}),
80
- ...(data.agentType !== undefined ? { agentType: data.agentType } : {}),
81
- },
82
- };
83
- } catch {
84
- return null;
85
- }
86
- }
87
-
88
- export function isHookNewer(existing: SessionModel | undefined, hookData: Partial<SessionModel>): boolean {
89
- const incomingSeq = hookData.claude?.hookSeq;
90
- const existingSeq = existing?.claude?.hookSeq;
91
- if (typeof incomingSeq === "number" && typeof existingSeq === "number") {
92
- return incomingSeq >= existingSeq;
93
- }
94
-
95
- const incomingTime = hookData.claude?.hookEventTime ?? hookData.lastActivity ?? 0;
96
- const existingTime = existing?.claude?.hookEventTime ?? existing?.lastActivity ?? 0;
97
- return incomingTime >= existingTime;
98
- }
99
-
100
- export function getHookTime(session: Partial<SessionModel> | undefined): number {
101
- return session?.claude?.hookEventTime ?? session?.lastActivity ?? 0;
102
- }
103
-
104
- export function getExistingHookData(existing: SessionModel | undefined): Partial<SessionModel> | null {
105
- if (!existing) return null;
106
- if (existing.detectionSource !== "claude-hook") return null;
107
- if (!existing.claude?.hookEventType || typeof existing.claude.hookEventTime !== "number" || typeof existing.claude.hookSeq !== "number") {
108
- return null;
109
- }
110
-
111
- return {
112
- state: existing.state,
113
- hasAttention: existing.hasAttention,
114
- summary: existing.summary,
115
- activeTool: existing.activeTool,
116
- currentPath: existing.currentPath,
117
- lastActivity: existing.lastActivity,
118
- parentSessionId: existing.parentSessionId,
119
- detectionSource: "claude-hook",
120
- claude: {
121
- sessionId: existing.claude.sessionId,
122
- transcriptPath: existing.claude.transcriptPath,
123
- hookEventType: existing.claude.hookEventType,
124
- hookEventTime: existing.claude.hookEventTime,
125
- hookSeq: existing.claude.hookSeq,
126
- ...(existing.claude.permissionMode ? { permissionMode: existing.claude.permissionMode } : {}),
127
- ...(existing.claude.startSource ? { startSource: existing.claude.startSource } : {}),
128
- ...(existing.claude.endReason ? { endReason: existing.claude.endReason } : {}),
129
- ...(existing.claude.errorType ? { errorType: existing.claude.errorType } : {}),
130
- ...(existing.claude.attentionKind ? { attentionKind: existing.claude.attentionKind } : {}),
131
- ...(existing.claude.toolName ? { toolName: existing.claude.toolName } : {}),
132
- ...(existing.claude.toolInputSummary ? { toolInputSummary: existing.claude.toolInputSummary } : {}),
133
- ...(existing.claude.toolResultStatus ? { toolResultStatus: existing.claude.toolResultStatus } : {}),
134
- ...(existing.claude.agentId ? { agentId: existing.claude.agentId } : {}),
135
- ...(existing.claude.agentType ? { agentType: existing.claude.agentType } : {}),
136
- },
137
- };
138
- }
139
-
140
- const MERGEABLE_FIELDS: (keyof ClaudeSessionMeta)[] = [
141
- "hookEventType", "hookEventTime", "hookSeq",
142
- "permissionMode", "startSource", "endReason", "errorType",
143
- "attentionKind", "toolName", "toolInputSummary",
144
- "toolResultStatus", "agentId", "agentType",
145
- ];
146
-
147
- export function mergeClaudeMeta(
148
- base: ClaudeSessionMeta,
149
- incoming: Partial<ClaudeSessionMeta>,
150
- ): ClaudeSessionMeta {
151
- const result = { ...base };
152
- for (const key of MERGEABLE_FIELDS) {
153
- if (incoming[key] !== undefined) {
154
- (result as Record<string, unknown>)[key] = incoming[key];
155
- }
156
- }
157
- return result;
158
- }
159
-
160
- export function mergeHookIntoModel(model: SessionModel, hookData: Partial<SessionModel>, transcriptPath: string): SessionModel {
161
- const hookClaude = hookData.claude ?? { sessionId: model.claude?.sessionId ?? model.id.replace(/^claude:/, ""), transcriptPath };
162
- return {
163
- ...model,
164
- ...hookData,
165
- state: (hookData.state as SessionState | undefined) ?? model.state,
166
- hasAttention: hookData.hasAttention ?? model.hasAttention,
167
- id: model.id,
168
- source: model.source,
169
- summary: hookData.summary ?? model.summary,
170
- lastActivity: typeof hookData.lastActivity === "number" ? hookData.lastActivity : model.lastActivity,
171
- activeTool: hookData.activeTool ?? model.activeTool,
172
- detectionSource: "claude-hook",
173
- claude: mergeClaudeMeta(
174
- {
175
- sessionId: hookClaude.sessionId ?? model.claude?.sessionId ?? model.id.replace(/^claude:/, ""),
176
- transcriptPath: hookClaude.transcriptPath || transcriptPath,
177
- },
178
- hookClaude,
179
- ),
180
- };
181
- }
@@ -1,8 +0,0 @@
1
- export { ClaudeCollector } from "./collector";
2
- export type { ClaudeHookFileData } from "./hooks";
3
-
4
- // Test-accessible pure functions (not part of collector public API,
5
- // but exported for direct unit testing without replicating logic)
6
- export { inferState, extractSummary, extractTitle, isLocalCommandTranscript } from "./transcript";
7
- export { decodeClaudeProjectSlug } from "./discovery";
8
- export { mergeClaudeMeta } from "./hooks";
@@ -1,85 +0,0 @@
1
- import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
2
-
3
- export interface JsonlEntry {
4
- type?: string;
5
- role?: string;
6
- content?: unknown;
7
- message?: {
8
- role?: string;
9
- content?: unknown;
10
- };
11
- toolUse?: { name?: string };
12
- timestamp?: string;
13
- }
14
-
15
- export interface ContentBlock {
16
- type?: string;
17
- text?: string;
18
- name?: string;
19
- input?: Record<string, unknown>;
20
- tool_use_id?: string;
21
- }
22
-
23
- export function parseJsonlFile(path: string, maxBytes?: number): JsonlEntry[] {
24
- try {
25
- const content = readTextTail(path, maxBytes);
26
- return parseJsonlContent(content);
27
- } catch {
28
- return [];
29
- }
30
- }
31
-
32
- export function parseJsonlHead(path: string, maxBytes: number): JsonlEntry[] {
33
- try {
34
- return parseJsonlContent(readTextHead(path, maxBytes));
35
- } catch {
36
- return [];
37
- }
38
- }
39
-
40
- export function parseJsonlContent(content: string): JsonlEntry[] {
41
- return content
42
- .split(/\r?\n/)
43
- .filter(Boolean)
44
- .map((line) => {
45
- try {
46
- return JSON.parse(line) as JsonlEntry;
47
- } catch {
48
- return null;
49
- }
50
- })
51
- .filter((entry): entry is JsonlEntry => entry !== null);
52
- }
53
-
54
- export function readTextHead(path: string, maxBytes: number): string {
55
- const stat = statSync(path);
56
- if (stat.size <= maxBytes) return readFileSync(path, "utf8");
57
-
58
- const fd = openSync(path, "r");
59
- try {
60
- const buffer = Buffer.allocUnsafe(maxBytes);
61
- readSync(fd, buffer, 0, maxBytes, 0);
62
- const text = buffer.toString("utf8");
63
- const lastNewline = text.lastIndexOf("\n");
64
- return lastNewline >= 0 ? text.slice(0, lastNewline) : text;
65
- } finally {
66
- closeSync(fd);
67
- }
68
- }
69
-
70
- export function readTextTail(path: string, maxBytes?: number): string {
71
- if (!maxBytes || maxBytes <= 0) return readFileSync(path, "utf8");
72
- const stat = statSync(path);
73
- if (stat.size <= maxBytes) return readFileSync(path, "utf8");
74
-
75
- const fd = openSync(path, "r");
76
- try {
77
- const buffer = Buffer.allocUnsafe(maxBytes);
78
- readSync(fd, buffer, 0, maxBytes, stat.size - maxBytes);
79
- const text = buffer.toString("utf8");
80
- const firstNewline = text.indexOf("\n");
81
- return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
82
- } finally {
83
- closeSync(fd);
84
- }
85
- }
@@ -1,246 +0,0 @@
1
- import type { SessionModel, SessionState } from "../../../shared/types";
2
- import type { JsonlEntry, ContentBlock } from "./jsonl";
3
-
4
- export function getRole(entry: JsonlEntry): string | undefined {
5
- return entry.role ?? entry.message?.role;
6
- }
7
-
8
- export function getBlocks(entry: JsonlEntry): ContentBlock[] {
9
- const content = entry.content ?? entry.message?.content;
10
- if (!Array.isArray(content)) return [];
11
- return content.filter((block): block is ContentBlock => typeof block === "object" && block !== null);
12
- }
13
-
14
- export function getTextBlocks(entry: JsonlEntry): string[] {
15
- return getBlocks(entry)
16
- .filter((block) => block.type === "text" && typeof block.text === "string")
17
- .map((block) => block.text!.trim())
18
- .filter((text) => text.length > 0);
19
- }
20
-
21
- export function normalizeText(text: string): string {
22
- return text
23
- .replace(/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/g, "")
24
- .replace(/<command-name>(.*?)<\/command-name>/g, "$1")
25
- .trim();
26
- }
27
-
28
- export function isQuestionText(text: string): boolean {
29
- const trimmed = normalizeText(text);
30
- return /[?]/.test(trimmed) || /^(would you|do you|can you|should i|which|what|how|please confirm|shall i|want me to)/i.test(trimmed);
31
- }
32
-
33
- export function isInterruptedRequestText(text: string): boolean {
34
- return /^\[Request interrupted by user(?: for tool use)?\]$/i.test(text.trim());
35
- }
36
-
37
- export function isSyntheticUserEntry(entry: JsonlEntry): boolean {
38
- if (getRole(entry) !== "user") return false;
39
- if (isToolResultOnlyEntry(entry)) return true;
40
- const texts = getTextBlocks(entry).map((text) => normalizeText(text));
41
- return texts.length > 0 && texts.every((text) => isInterruptedRequestText(text));
42
- }
43
-
44
- export function isToolResultOnlyEntry(entry: JsonlEntry): boolean {
45
- const blocks = getBlocks(entry);
46
- return blocks.length > 0 && blocks.every((block) => block.type === "tool_result");
47
- }
48
-
49
- export function isThinkingOnlyAssistantEntry(entry: JsonlEntry): boolean {
50
- if (getRole(entry) !== "assistant") return false;
51
- const blocks = getBlocks(entry);
52
- return blocks.length > 0 && blocks.every((block) => block.type === "thinking");
53
- }
54
-
55
- export function findLastToolResultIndex(entries: JsonlEntry[]): number {
56
- for (let i = entries.length - 1; i >= 0; i--) {
57
- if (isToolResultOnlyEntry(entries[i]!)) return i;
58
- }
59
- return -1;
60
- }
61
-
62
- export function findLastMeaningfulEntry(entries: JsonlEntry[]): JsonlEntry | undefined {
63
- const lastToolResultIndex = findLastToolResultIndex(entries);
64
-
65
- for (let i = entries.length - 1; i >= 0; i--) {
66
- const entry = entries[i]!;
67
- const role = getRole(entry);
68
- if (role !== "user" && role !== "assistant") continue;
69
- if (isThinkingOnlyAssistantEntry(entry)) continue;
70
- if (isSyntheticUserEntry(entry)) continue;
71
-
72
- if (role === "assistant") {
73
- const blocks = getBlocks(entry);
74
- const hasToolUse = blocks.some((block) => block.type === "tool_use");
75
- const hasVisibleText = getTextBlocks(entry).length > 0;
76
- if (hasToolUse && !hasVisibleText && lastToolResultIndex > i) {
77
- // The most recent assistant entry is a fulfilled tool_use cycle.
78
- // In agentic mode, a completed tool cycle means the agent is
79
- // processing the result — return the entry so inferState sees
80
- // it as "working" (assistant with tool_use, no visible text).
81
- return entry;
82
- }
83
- }
84
-
85
- return entry;
86
- }
87
-
88
- return undefined;
89
- }
90
-
91
- /** Infer session state from the last meaningful transcript turn */
92
- export function inferState(entries: JsonlEntry[]): SessionState {
93
- if (entries.length === 0) return "idle";
94
-
95
- const last = findLastMeaningfulEntry(entries);
96
- if (!last) return "idle";
97
-
98
- const role = getRole(last);
99
- if (role === "assistant") {
100
- const blocks = getBlocks(last);
101
- const texts = getTextBlocks(last);
102
- const hasToolUse = blocks.some((block) => block.type === "tool_use");
103
-
104
- if (texts.some((value) => isQuestionText(value))) return "waiting-input";
105
- if (texts.length > 0) return "idle";
106
- if (hasToolUse) return "working";
107
- return "idle";
108
- }
109
-
110
- if (role === "user") {
111
- return isSyntheticUserEntry(last) ? "idle" : "working";
112
- }
113
-
114
- return "idle";
115
- }
116
-
117
- /** Extract a human-readable summary from the last assistant message */
118
- export function extractSummary(entries: JsonlEntry[]): string {
119
- for (let i = entries.length - 1; i >= 0; i--) {
120
- const entry = entries[i]!;
121
- const role = getRole(entry);
122
- if (role !== "assistant") continue;
123
- if (isThinkingOnlyAssistantEntry(entry)) continue;
124
-
125
- const content = getBlocks(entry);
126
- if (content.length === 0) continue;
127
-
128
- for (const block of content) {
129
- if (block.type === "tool_use" && block.name) {
130
- const cmd = typeof block.input?.command === "string" ? `: ${block.input.command.slice(0, 60)}` : "";
131
- return `Using ${block.name}${cmd}`;
132
- }
133
- }
134
-
135
- for (const block of content) {
136
- if (block.type === "text" && block.text) {
137
- const first = normalizeText(block.text).split("\n")[0] ?? "";
138
- if (first.length > 0) return first.slice(0, 120);
139
- }
140
- }
141
- }
142
-
143
- return "Claude session";
144
- }
145
-
146
- /**
147
- * A transcript whose user text entries are only the initialization caveat,
148
- * `/usage` slash-command invocations, or local-command stdout blocks.
149
- * Generated by third-party probe tools (e.g. CodexBar's ClaudeProbe) that
150
- * launch `claude` headlessly to read usage stats — not interactive work.
151
- * Limited to `/usage` to avoid filtering sessions that run other slash
152
- * commands as part of real work.
153
- */
154
- export function isUsageProbeTranscript(entries: JsonlEntry[]): boolean {
155
- const texts: string[] = [];
156
- for (const entry of entries) {
157
- if (getRole(entry) !== "user") continue;
158
- const content = entry.content ?? entry.message?.content;
159
- if (Array.isArray(content)) {
160
- for (const block of content) {
161
- if (typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string") {
162
- texts.push(block.text);
163
- }
164
- }
165
- } else if (typeof content === "string") {
166
- texts.push(content);
167
- }
168
- }
169
- if (texts.length === 0) return false;
170
- // Require at least one /usage slash command — a bare caveat alone is the
171
- // interactive session init message, not a probe.
172
- const hasUsageCommand = texts.some((text) => /<command-name>\s*\/usage[\s<]/.test(text));
173
- if (!hasUsageCommand) return false;
174
- return texts.every((text) =>
175
- text.startsWith("<local-command-caveat>")
176
- || /<command-name>\s*\/usage[\s<]/.test(text)
177
- || text.startsWith("<local-command-stdout>"),
178
- );
179
- }
180
-
181
- /**
182
- * Returns true when the transcript was generated in Claude Code's local-commands
183
- * mode (e.g. `claude -p "..."`). These are headless batch runs, not interactive
184
- * sessions — they clutter the TUI and carry no meaningful state.
185
- */
186
- export function isLocalCommandTranscript(entries: JsonlEntry[]): boolean {
187
- // Detect transcripts emitted by headless probe tools that only run `/usage`
188
- // (e.g. CodexBar's ClaudeProbe). These look like interactive sessions to the
189
- // caveat check below (bare init caveat + slash command), but carry no real
190
- // work state. Checked first because the caveat loop returns early.
191
- if (isUsageProbeTranscript(entries)) return true;
192
-
193
- for (const entry of entries) {
194
- const role = entry.role ?? entry.message?.role;
195
- if (role !== "user") continue;
196
- const content = entry.content ?? entry.message?.content;
197
- let text = "";
198
- if (Array.isArray(content)) {
199
- for (const block of content) {
200
- if (typeof block === "object" && block !== null && "type" in block) {
201
- const b = block as { type: string; text?: string };
202
- if (b.type === "text" && typeof b.text === "string") {
203
- text = b.text;
204
- break;
205
- }
206
- }
207
- }
208
- } else if (typeof content === "string") {
209
- text = content;
210
- }
211
- // Stop at first user entry regardless
212
- if (!text.startsWith("<local-command-caveat>")) break;
213
- // A bare caveat with no trailing task content is the initialization message
214
- // Claude Code sends at the start of interactive sessions — not a headless
215
- // `claude -p "task"` invocation. Real local commands have the task prompt
216
- // appended after the closing tag.
217
- const closingTag = "</local-command-caveat>";
218
- const afterCaveat = text.slice(text.indexOf(closingTag) + closingTag.length).trim();
219
- return afterCaveat.length > 0;
220
- }
221
- return false;
222
- }
223
-
224
- /** Extract a title from the first user message (the initial task/request) */
225
- export function extractTitle(entries: JsonlEntry[]): string | undefined {
226
- for (const entry of entries) {
227
- const role = getRole(entry);
228
- if (role !== "user") continue;
229
- if (isToolResultOnlyEntry(entry)) continue;
230
-
231
- const content = entry.content ?? entry.message?.content;
232
- if (Array.isArray(content)) {
233
- for (const block of getBlocks(entry)) {
234
- if (block.type === "text" && block.text) {
235
- const first = normalizeText(block.text).split("\n")[0] ?? "";
236
- if (first.length === 0) continue;
237
- return first.slice(0, 80);
238
- }
239
- }
240
- } else if (typeof content === "string" && content.trim().length > 0) {
241
- const first = normalizeText(content).split("\n")[0] ?? "";
242
- if (first.length > 0) return first.slice(0, 80);
243
- }
244
- }
245
- return undefined;
246
- }