@scrylog/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +836 -0
  3. package/package.json +75 -0
  4. package/src/api/docs/sessions.keys.ts +6 -0
  5. package/src/api/docs/sessions.ts +81 -0
  6. package/src/api/docs/system.keys.ts +5 -0
  7. package/src/api/docs/system.ts +66 -0
  8. package/src/api/docs/usage.keys.ts +3 -0
  9. package/src/api/docs/usage.ts +24 -0
  10. package/src/api/docs/webhooks.keys.ts +5 -0
  11. package/src/api/docs/webhooks.ts +78 -0
  12. package/src/api/index.ts +18 -0
  13. package/src/api/lib/auth.ts +43 -0
  14. package/src/api/lib/errors.ts +28 -0
  15. package/src/api/lib/route.ts +18 -0
  16. package/src/api/models/claude.ts +4 -0
  17. package/src/api/models/codex.ts +4 -0
  18. package/src/api/models/opencode.ts +11 -0
  19. package/src/api/routes/sessions.ts +47 -0
  20. package/src/api/routes/system.ts +103 -0
  21. package/src/api/routes/usage.ts +15 -0
  22. package/src/api/routes/webhooks/claude.ts +161 -0
  23. package/src/api/routes/webhooks/codex.ts +175 -0
  24. package/src/api/routes/webhooks/opencode.ts +107 -0
  25. package/src/api/routes.ts +93 -0
  26. package/src/api/schemas/common.ts +21 -0
  27. package/src/api/schemas/sessions.ts +72 -0
  28. package/src/api/schemas/usage.ts +50 -0
  29. package/src/api/schemas/webhooks/claude.ts +32 -0
  30. package/src/api/schemas/webhooks/codex.ts +34 -0
  31. package/src/api/schemas/webhooks/opencode.ts +29 -0
  32. package/src/api/services/claude-session.ts +9 -0
  33. package/src/api/services/codex-session.ts +8 -0
  34. package/src/api/services/opencode-session.ts +73 -0
  35. package/src/classifier.ts +206 -0
  36. package/src/cli/commands.ts +498 -0
  37. package/src/cli/prompt.ts +44 -0
  38. package/src/daemon/cli.ts +27 -0
  39. package/src/daemon/collectors/claude/README.md +108 -0
  40. package/src/daemon/collectors/claude/collector.ts +413 -0
  41. package/src/daemon/collectors/claude/discovery.ts +148 -0
  42. package/src/daemon/collectors/claude/hooks.ts +181 -0
  43. package/src/daemon/collectors/claude/index.ts +8 -0
  44. package/src/daemon/collectors/claude/jsonl.ts +85 -0
  45. package/src/daemon/collectors/claude/transcript.ts +246 -0
  46. package/src/daemon/collectors/codex/README.md +147 -0
  47. package/src/daemon/collectors/codex/collector.ts +661 -0
  48. package/src/daemon/collectors/codex/discovery.ts +104 -0
  49. package/src/daemon/collectors/codex/hooks.ts +179 -0
  50. package/src/daemon/collectors/codex/index.ts +7 -0
  51. package/src/daemon/collectors/codex/jsonl.ts +108 -0
  52. package/src/daemon/collectors/codex/transcript.ts +299 -0
  53. package/src/daemon/collectors/git.ts +295 -0
  54. package/src/daemon/collectors/opencode/README.md +106 -0
  55. package/src/daemon/collectors/opencode/collector.ts +543 -0
  56. package/src/daemon/collectors/opencode/discovery.ts +257 -0
  57. package/src/daemon/collectors/opencode/index.ts +5 -0
  58. package/src/daemon/collectors/opencode/session-builder.ts +59 -0
  59. package/src/daemon/collectors/opencode-db.ts +282 -0
  60. package/src/daemon/collectors/process.ts +201 -0
  61. package/src/daemon/collectors/tmux/README.md +128 -0
  62. package/src/daemon/collectors/tmux/collector.ts +472 -0
  63. package/src/daemon/collectors/tmux/enrichment.ts +241 -0
  64. package/src/daemon/collectors/tmux/index.ts +4 -0
  65. package/src/daemon/collectors/tmux/parse.ts +128 -0
  66. package/src/daemon/collectors/usage.ts +193 -0
  67. package/src/daemon/index.ts +207 -0
  68. package/src/daemon/scheduler.ts +86 -0
  69. package/src/daemon/store.ts +535 -0
  70. package/src/detect.ts +143 -0
  71. package/src/hooks/claude.ts +596 -0
  72. package/src/hooks/codex.ts +517 -0
  73. package/src/legacy/classifier.ts +215 -0
  74. package/src/legacy/detect.ts +143 -0
  75. package/src/mcp/client-server.ts +21 -0
  76. package/src/mcp/http-handler.ts +126 -0
  77. package/src/mcp/index.ts +61 -0
  78. package/src/mcp/resources/config-current.ts +19 -0
  79. package/src/mcp/resources/index.ts +11 -0
  80. package/src/mcp/resources/sessions-active.ts +19 -0
  81. package/src/mcp/resources/sessions-all.ts +19 -0
  82. package/src/mcp/schemas/sessions.ts +3 -0
  83. package/src/mcp/server.ts +17 -0
  84. package/src/mcp/services/api-adapter.ts +48 -0
  85. package/src/mcp/services/data-source.ts +22 -0
  86. package/src/mcp/services/store-adapter.ts +40 -0
  87. package/src/mcp/tools/get-active-sessions.ts +16 -0
  88. package/src/mcp/tools/get-session-children.ts +24 -0
  89. package/src/mcp/tools/get-session-messages.ts +24 -0
  90. package/src/mcp/tools/get-session.ts +23 -0
  91. package/src/mcp/tools/index.ts +15 -0
  92. package/src/mcp/tools/list-sessions.ts +16 -0
  93. package/src/neo-blessed.d.ts +4 -0
  94. package/src/oc/index.ts +302 -0
  95. package/src/service/manager.ts +242 -0
  96. package/src/shared/config.ts +478 -0
  97. package/src/shared/ipc.ts +80 -0
  98. package/src/shared/logger.ts +248 -0
  99. package/src/shared/paths.ts +73 -0
  100. package/src/shared/platform.ts +110 -0
  101. package/src/shared/session-view.ts +241 -0
  102. package/src/shared/types.ts +299 -0
  103. package/src/shared/utils.ts +339 -0
  104. package/src/tui/app.ts +920 -0
  105. package/src/tui/client.ts +149 -0
  106. package/src/tui/index.ts +181 -0
  107. package/src/types.ts +113 -0
  108. package/src/usage/README.md +323 -0
  109. package/src/usage/api.ts +99 -0
  110. package/src/usage/client.ts +3 -0
  111. package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
  112. package/src/usage/clients/anthropic-web-usage.ts +245 -0
  113. package/src/usage/clients/claude-cli-probe.ts +187 -0
  114. package/src/usage/clients/codex-oauth-usage.ts +113 -0
  115. package/src/usage/clients/opencode-go-usage.ts +441 -0
  116. package/src/usage/index.ts +19 -0
  117. package/src/usage/mappers.ts +273 -0
  118. package/src/usage/planner.ts +54 -0
  119. package/src/usage/providers/claude.ts +113 -0
  120. package/src/usage/providers/codex.ts +34 -0
  121. package/src/usage/providers/opencode-go.ts +37 -0
  122. package/src/usage/resolver.ts +4 -0
  123. package/src/usage/resolvers/browser-cookies.ts +198 -0
  124. package/src/usage/resolvers/claude-auth.ts +99 -0
  125. package/src/usage/resolvers/claude-cli.ts +24 -0
  126. package/src/usage/resolvers/claude-web.ts +16 -0
  127. package/src/usage/resolvers/codex-auth.ts +117 -0
  128. package/src/usage/resolvers/opencode-go-web.ts +14 -0
  129. package/src/usage/types.ts +190 -0
@@ -0,0 +1,257 @@
1
+ import { existsSync, readdirSync, readFileSync, readlinkSync } from "node:fs";
2
+ import type { ProcessInfo } from "../../../shared/types";
3
+ import { maybeRunCommand } from "../../../shared/utils";
4
+ import { IS_LINUX, IS_WINDOWS } from "../../../shared/platform";
5
+ import type { Logger } from "../../../shared/logger";
6
+ import { parseWindowsProcesses } from "../process";
7
+
8
+ const OPENCODE_DEFAULT_PORT = 4096;
9
+
10
+ /**
11
+ * Subcommands of opencode that are NOT local servers.
12
+ * Processes running these should not be matched as opencode server instances.
13
+ */
14
+ const OPENCODE_NON_SERVER_SUBCOMMANDS = /^(attach|check)\b/i;
15
+
16
+ /**
17
+ * Returns true if the command string represents an opencode executable invocation
18
+ * (not opencode as an argument to another process, or opencode as a directory component).
19
+ *
20
+ * Valid executable forms:
21
+ * - `opencode [args]` — bare name, first token
22
+ * - `/path/to/opencode [args]` — absolute path, first token
23
+ * - `node /path/to/opencode [args]` — Node.js runner
24
+ * - `bun /path/to/opencode [args]` — Bun runner
25
+ *
26
+ * Rejected forms:
27
+ * - `tmux new-session ... opencode` — opencode is an argument to tmux
28
+ * - `/Users/x/.opencode/node_modules/.../binary` — opencode is a directory in the path
29
+ */
30
+ function isOpencodeExecutable(command: string): boolean {
31
+ const tokens = command.trim().split(/\s+/);
32
+ const first = tokens[0] ?? "";
33
+ const second = tokens[1] ?? "";
34
+
35
+ // First token ends with /opencode or IS opencode (bare name)
36
+ if (/(?:^|[\\/])opencode(?:\.exe)?$/i.test(first)) return true;
37
+
38
+ // Interpreter (node/bun) with second token being the opencode script
39
+ if (/^(node|bun)(?:\.exe)?$/i.test(first) && /(?:^|[\\/])opencode(?:\.exe)?$/i.test(second)) return true;
40
+
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * Given a process command string, attempt to extract the OpenCode HTTP URL.
46
+ * Matches:
47
+ * - `opencode serve [--port N] [--hostname H]`
48
+ * - `opencode [--port N]` (TUI mode with explicit port)
49
+ * - `opencode` (TUI mode, no args — assumes default port 4096 on localhost)
50
+ * Returns undefined if the command is not an OpenCode server process.
51
+ */
52
+ export function parseOpencodeUrl(command: string): string | undefined {
53
+ if (!isOpencodeExecutable(command)) return undefined;
54
+
55
+ // Extract the subcommand/args portion (everything after the executable token)
56
+ const tokens = command.trim().split(/\s+/);
57
+ const isInterpreted = /^(node|bun)(?:\.exe)?$/i.test(tokens[0] ?? "");
58
+ const args = tokens.slice(isInterpreted ? 2 : 1).join(" ");
59
+
60
+ // Client-only subcommands — not a local server
61
+ if (OPENCODE_NON_SERVER_SUBCOMMANDS.test(args.trim())) return undefined;
62
+
63
+ const portMatch = command.match(/--port[=\s]+(\d+)/);
64
+ const hostMatch = command.match(/--hostname[=\s]+(\S+)/);
65
+ const port = portMatch ? Number.parseInt(portMatch[1] ?? "", 10) : OPENCODE_DEFAULT_PORT;
66
+ const rawHost = hostMatch ? (hostMatch[1] ?? "127.0.0.1") : "127.0.0.1";
67
+ const host = rawHost === "0.0.0.0" ? "127.0.0.1" : rawHost;
68
+ return `http://${host}:${port}`;
69
+ }
70
+
71
+ /**
72
+ * Returns true if the command explicitly specifies a --port argument.
73
+ * Used to decide whether lsof-based port discovery is needed.
74
+ */
75
+ export function hasExplicitPort(command: string): boolean {
76
+ return /--port[=\s]+\d+/.test(command);
77
+ }
78
+
79
+ /**
80
+ * Use `lsof` to find the TCP port an opencode process is listening on.
81
+ * Runs `lsof -nP -p <pid>` and looks for a LISTEN TCP entry.
82
+ * Returns the port number, or undefined if none found.
83
+ *
84
+ * This is needed when opencode is started without `--port` (TUI mode),
85
+ * in which case it binds to a random ephemeral port.
86
+ */
87
+ export function discoverListeningPort(pid: number, timeoutMs?: number): number | undefined {
88
+ const lsofResult = maybeRunCommand(["lsof", "-nP", "-p", String(pid)], timeoutMs);
89
+ if (lsofResult.ok) {
90
+ for (const line of lsofResult.output.split(/\r?\n/)) {
91
+ // Match lines like: opencode 12345 user 10u IPv4 ... TCP 127.0.0.1:59871 (LISTEN)
92
+ const m = line.match(/TCP\s+[\w.*]+:(\d+)\s+\(LISTEN\)/i);
93
+ if (m) {
94
+ const port = Number.parseInt(m[1] ?? "", 10);
95
+ if (Number.isFinite(port) && port > 0) return port;
96
+ }
97
+ }
98
+ }
99
+
100
+ // Fallback for minimal Linux containers/WSL without lsof: read /proc/<pid>/net/tcp
101
+ if (IS_LINUX) {
102
+ return discoverListeningPortFromProc(pid);
103
+ }
104
+
105
+ return undefined;
106
+ }
107
+
108
+ export function parseProcNetTcpListeningPorts(content: string, socketInodes: ReadonlySet<string>): number[] {
109
+ const ports: number[] = [];
110
+
111
+ for (const line of content.split(/\r?\n/).filter(Boolean).slice(1)) {
112
+ // Columns: sl local_address rem_address st tx_queue:rx_queue tr:tm->when retrnsmt uid timeout inode
113
+ const cols = line.trim().split(/\s+/);
114
+ const local = cols[1];
115
+ const st = cols[3];
116
+ const inode = cols[9];
117
+ if (st !== "0A" || !local || !inode || !socketInodes.has(inode)) continue;
118
+
119
+ const portHex = local.split(":")[1];
120
+ if (!portHex) continue;
121
+ const port = Number.parseInt(portHex, 16);
122
+ if (Number.isFinite(port) && port > 0) ports.push(port);
123
+ }
124
+
125
+ return ports;
126
+ }
127
+
128
+ function discoverListeningPortFromProc(pid: number): number | undefined {
129
+ const fdDir = `/proc/${pid}/fd`;
130
+ if (!existsSync(fdDir)) return undefined;
131
+
132
+ try {
133
+ const socketInodes = new Set<string>();
134
+ for (const fd of readdirSync(fdDir)) {
135
+ try {
136
+ const target = readlinkSync(`${fdDir}/${fd}`);
137
+ const match = target.match(/^socket:\[(\d+)\]$/);
138
+ if (match?.[1]) socketInodes.add(match[1]);
139
+ } catch {
140
+ // Ignore fds that disappear while scanning.
141
+ }
142
+ }
143
+ if (socketInodes.size === 0) return undefined;
144
+
145
+ for (const procPath of [`/proc/${pid}/net/tcp`, `/proc/${pid}/net/tcp6`]) {
146
+ if (!existsSync(procPath)) continue;
147
+ const ports = parseProcNetTcpListeningPorts(readFileSync(procPath, "utf8"), socketInodes);
148
+ if (ports.length > 0) return ports[0];
149
+ }
150
+ } catch {
151
+ // Ignore permission/read errors.
152
+ }
153
+
154
+ return undefined;
155
+ }
156
+
157
+ /**
158
+ * Scan a process tree (list of descendants of a pane's root PID) looking for
159
+ * an OpenCode server process. Returns the pid and derived HTTP URL if found.
160
+ *
161
+ * NOTE: opencode runs as a child of the shell (panePid), so we scan
162
+ * descendants — not ancestors — of the pane root.
163
+ */
164
+ export function findOpencodeInTree(
165
+ processTree: ProcessInfo[],
166
+ ): { pid: number; url: string } | undefined {
167
+ for (const p of processTree) {
168
+ const url = parseOpencodeUrl(p.command);
169
+ if (url !== undefined) return { pid: p.pid, url };
170
+ }
171
+ return undefined;
172
+ }
173
+
174
+ // ── Auto-discovery ────────────────────────────────────────────────────────────
175
+
176
+ export interface DiscoveredInstance {
177
+ url: string;
178
+ pid?: number;
179
+ }
180
+
181
+ // Cache pid → discovered port to avoid repeated lsof calls each discovery cycle.
182
+ // Entries are kept until the process disappears (instance teardown clears them).
183
+ const pidPortCache = new Map<number, number>();
184
+
185
+ export function discoverInstances(logger: Logger, timeoutMs?: number): DiscoveredInstance[] {
186
+ const instances: DiscoveredInstance[] = [];
187
+
188
+ // Scan for opencode processes and try to extract port from args
189
+ const result = IS_WINDOWS
190
+ ? maybeRunCommand(["wmic", "process", "get", "ProcessId,ParentProcessId,CommandLine", "/format:csv"], timeoutMs)
191
+ : maybeRunCommand(["ps", "-ax", "-o", "pid=,command="], timeoutMs);
192
+ if (result.ok) {
193
+ const processes = IS_WINDOWS
194
+ ? parseWindowsProcesses(result.output)
195
+ : result.output.split(/\r?\n/).filter(Boolean).map((line) => {
196
+ const match = line.match(/^\s*(\d+)\s+(.*)$/);
197
+ if (!match) return null;
198
+ return { pid: Number.parseInt(match[1] ?? "", 10), command: match[2] ?? "" };
199
+ }).filter((p): p is { pid: number; command: string } => Boolean(p));
200
+ logger.debug("opencode", "Process scan for autodiscover", { totalLines: processes.length });
201
+
202
+ for (const { pid, command } of processes) {
203
+ if (!Number.isFinite(pid) || pid <= 0) continue;
204
+
205
+ const url = parseOpencodeUrl(command);
206
+ if (!url) continue;
207
+
208
+ // If no explicit --port in the command, the opencode TUI binds to a random
209
+ // port. Use lsof to find the actual listening port and override the default.
210
+ let resolvedUrl = url;
211
+ if (!hasExplicitPort(command)) {
212
+ const cached = pidPortCache.get(pid);
213
+ if (cached !== undefined) {
214
+ resolvedUrl = `http://127.0.0.1:${cached}`;
215
+ logger.debug("opencode", "Using cached port for opencode TUI", { pid, port: cached });
216
+ } else {
217
+ const discovered = discoverListeningPort(pid, timeoutMs);
218
+ if (discovered !== undefined) {
219
+ pidPortCache.set(pid, discovered);
220
+ resolvedUrl = `http://127.0.0.1:${discovered}`;
221
+ logger.debug("opencode", "Discovered port via lsof for opencode TUI", { pid, port: discovered });
222
+ } else {
223
+ logger.debug("opencode", "No listening port found via lsof for opencode TUI — skipping (not yet bound or hooks mode)", { pid });
224
+ continue;
225
+ }
226
+ }
227
+ }
228
+
229
+ logger.debug("opencode", "Found opencode process", { pid, url: resolvedUrl, command: command.slice(0, 80) });
230
+ instances.push({ url: resolvedUrl, pid });
231
+ }
232
+ } else {
233
+ logger.warn("opencode", "Process scan failed", { error: result.error });
234
+ }
235
+
236
+ // Always probe default port if no instances found via process scan
237
+ if (instances.length === 0) {
238
+ const fallbackUrl = `http://localhost:4096`;
239
+ logger.debug("opencode", "No opencode processes found via scan — probing default port", { url: fallbackUrl });
240
+ instances.push({ url: fallbackUrl });
241
+ }
242
+
243
+ // De-duplicate by URL
244
+ const seen = new Set<string>();
245
+ const deduped = instances.filter((inst) => {
246
+ if (seen.has(inst.url)) return false;
247
+ seen.add(inst.url);
248
+ return true;
249
+ });
250
+
251
+ logger.debug("opencode", "Autodiscover result", { count: deduped.length, urls: deduped.map((i) => i.url) });
252
+ return deduped;
253
+ }
254
+
255
+ export function clearPidPortCache(pid: number): void {
256
+ pidPortCache.delete(pid);
257
+ }
@@ -0,0 +1,5 @@
1
+ export { OpenCodeCollector } from "./collector";
2
+ export { parseOpencodeUrl, hasExplicitPort, discoverListeningPort, findOpencodeInTree, discoverInstances, clearPidPortCache } from "./discovery";
3
+ export { mapSessionStatus, buildSessionModel } from "./session-builder";
4
+ export type { DiscoveredInstance } from "./discovery";
5
+ export type { InstanceState } from "./session-builder";
@@ -0,0 +1,59 @@
1
+ import type { SessionModel, SessionState } from "../../../shared/types";
2
+ import type { Session, SessionStatus } from "@opencode-ai/sdk";
3
+ import type { GitCollector } from "../git";
4
+
5
+ export interface InstanceState {
6
+ url: string;
7
+ pid?: number;
8
+ sessions: Map<string, SessionModel>;
9
+ status: Map<string, SessionStatus>;
10
+ /** sessionId → set of pending permissionRequest IDs */
11
+ pendingPermissions: Map<string, Set<string>>;
12
+ /** sessionId → set of pending question IDs */
13
+ pendingQuestions: Map<string, Set<string>>;
14
+ sseAbort?: AbortController;
15
+ pollTimer?: ReturnType<typeof setInterval>;
16
+ polling?: boolean;
17
+ skippedPolls?: number;
18
+ }
19
+
20
+ export function mapSessionStatus(status: SessionStatus): SessionState {
21
+ if (status.type === "busy") return "working";
22
+ if (status.type === "idle") return "idle";
23
+ if (status.type === "retry") return "working";
24
+ return "idle";
25
+ }
26
+
27
+ export function buildSessionModel(
28
+ session: Session,
29
+ status: SessionStatus,
30
+ instanceUrl: string,
31
+ pendingPermissions: Map<string, Set<string>>,
32
+ pendingQuestions: Map<string, Set<string>>,
33
+ gitCollector?: GitCollector,
34
+ ): SessionModel {
35
+ const hasPendingPermission = (pendingPermissions.get(session.id)?.size ?? 0) > 0;
36
+ const hasPendingQuestion = (pendingQuestions.get(session.id)?.size ?? 0) > 0;
37
+ const hasAttention = hasPendingPermission || hasPendingQuestion;
38
+ const state: SessionState = hasAttention ? "waiting-input" : mapSessionStatus(status);
39
+ const currentPath = session.directory || undefined;
40
+ const project = currentPath ? gitCollector?.resolve(currentPath) : undefined;
41
+
42
+ return {
43
+ id: `opencode:${session.id}`,
44
+ source: "standalone",
45
+ tool: "opencode",
46
+ state,
47
+ summary: session.title || "OpenCode session",
48
+ lastActivity: session.time.updated,
49
+ hasAttention,
50
+ opencode: {
51
+ sessionId: session.id,
52
+ instanceUrl,
53
+ },
54
+ detectionSource: "opencode-sdk",
55
+ currentPath,
56
+ project,
57
+ parentSessionId: session.parentID ? `opencode:${session.parentID}` : undefined,
58
+ };
59
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * OpenCode SQLite Collector
3
+ *
4
+ * Reads session metadata from the OpenCode SQLite database and enriches
5
+ * existing sessions in the store. Runs every 30 seconds.
6
+ *
7
+ * Priority rules:
8
+ * - NEVER overwrites `state` — only metadata fields are written.
9
+ * - If a session already exists (from SSE/webhook), merges title/directory/lastActivity.
10
+ * - If a session does NOT exist yet, adds it with state="idle" and detectionSource="opencode-db".
11
+ */
12
+
13
+ import { Database } from "bun:sqlite";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { existsSync } from "node:fs";
17
+ import type { ScrylogConfig } from "../../shared/config";
18
+ import type { SessionModel } from "../../shared/types";
19
+ import type { DaemonStore } from "../store";
20
+ import type { Collector } from "../scheduler";
21
+ import type { Logger } from "../../shared/logger";
22
+ import { NOOP_LOGGER } from "../../shared/logger";
23
+ import type { GitCollector } from "./git";
24
+
25
+ const POLL_INTERVAL_MS = 30_000;
26
+ const CHILD_ACTIVITY_WINDOW_MS = 90_000;
27
+
28
+ export function inferChildSessionState(
29
+ session: Pick<SessionModel, "detectionSource" | "parentSessionId" | "lastActivity">,
30
+ parent: Pick<SessionModel, "state"> | undefined,
31
+ config: Pick<ScrylogConfig, "opencode">,
32
+ now: number,
33
+ ): SessionModel["state"] | undefined {
34
+ if (config.opencode.mode !== "hooks") return undefined;
35
+ if (session.detectionSource !== "opencode-db") return undefined;
36
+ if (!session.parentSessionId) return undefined;
37
+ if (parent?.state !== "working" && parent?.state !== "waiting-input") return "idle";
38
+ if (now - session.lastActivity > CHILD_ACTIVITY_WINDOW_MS) return "idle";
39
+ return "working";
40
+ }
41
+
42
+ // ── DB path resolution ────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Returns an ordered list of candidate DB paths for the current platform.
46
+ * The first path that exists on disk should be used.
47
+ *
48
+ * Priority:
49
+ * 1. OPENCODE_DATA_DIR env var (explicit override, always first)
50
+ * 2. Platform-specific primary path
51
+ * 3. Platform-specific fallback path(s)
52
+ */
53
+ export function resolveDbCandidates(
54
+ platform: string = process.platform,
55
+ env: Record<string, string | undefined> = process.env,
56
+ ): string[] {
57
+ const home = homedir();
58
+
59
+ if (env["OPENCODE_DATA_DIR"]) {
60
+ return [join(env["OPENCODE_DATA_DIR"], "opencode.db")];
61
+ }
62
+
63
+ if (platform === "win32") {
64
+ const appData = env["APPDATA"] ?? join(home, "AppData", "Roaming");
65
+ return [
66
+ join(appData, "opencode", "opencode.db"),
67
+ join(home, "AppData", "Roaming", "opencode", "opencode.db"),
68
+ ];
69
+ }
70
+
71
+ if (platform === "darwin") {
72
+ return [
73
+ // XDG path — used by current OpenCode releases on macOS
74
+ join(home, ".local", "share", "opencode", "opencode.db"),
75
+ // Native macOS path — fallback for builds that follow Apple conventions
76
+ join(home, "Library", "Application Support", "opencode", "opencode.db"),
77
+ ];
78
+ }
79
+
80
+ // Linux and other Unix-like systems
81
+ return [
82
+ join(home, ".local", "share", "opencode", "opencode.db"),
83
+ join(home, ".config", "opencode", "opencode.db"),
84
+ ];
85
+ }
86
+
87
+ /**
88
+ * Returns the first candidate path that exists on disk, or null if none found.
89
+ */
90
+ function findDbPath(): { path: string; candidates: string[] } | null {
91
+ const candidates = resolveDbCandidates();
92
+ for (const candidate of candidates) {
93
+ if (existsSync(candidate)) return { path: candidate, candidates };
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // ── DB row type ───────────────────────────────────────────────────────────────
99
+
100
+ interface SessionRow {
101
+ id: string;
102
+ title: string | null;
103
+ directory: string | null;
104
+ time_created: number;
105
+ time_updated: number;
106
+ parent_id: string | null;
107
+ }
108
+
109
+ // ── Collector ─────────────────────────────────────────────────────────────────
110
+
111
+ export class OpenCodeDbCollector implements Collector {
112
+ name = "opencode-db";
113
+ private store?: DaemonStore;
114
+ private config?: ScrylogConfig;
115
+ private logger: Logger = NOOP_LOGGER;
116
+ private timer?: ReturnType<typeof setInterval>;
117
+ private gitCollector?: GitCollector;
118
+ private polling = false;
119
+ private skippedPolls = 0;
120
+ private errorCount = 0;
121
+
122
+ constructor(gitCollector?: GitCollector) {
123
+ this.gitCollector = gitCollector;
124
+ }
125
+
126
+ async start(store: DaemonStore, config: ScrylogConfig, logger: Logger): Promise<void> {
127
+ this.store = store;
128
+ this.config = config;
129
+ this.logger = logger;
130
+
131
+ const found = findDbPath();
132
+ if (!found) {
133
+ const candidates = resolveDbCandidates();
134
+ logger.info("opencode-db", "Database not found — collector inactive", { event: "collector.unavailable", collector: this.name, candidates });
135
+ store.setCollectorStatus(this.name, {
136
+ running: false,
137
+ lastError: `DB not found. Tried: ${candidates.join(", ")}`,
138
+ });
139
+ return;
140
+ }
141
+
142
+ const { path: dbPath } = found;
143
+ logger.info("opencode-db", "Collector started", { event: "collector.started", collector: this.name, path: dbPath, intervalMs: POLL_INTERVAL_MS });
144
+ store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now() });
145
+
146
+ await this.safePoll(dbPath);
147
+ this.timer = setInterval(() => void this.safePoll(dbPath), POLL_INTERVAL_MS);
148
+ }
149
+
150
+ async stop(): Promise<void> {
151
+ if (this.timer) {
152
+ clearInterval(this.timer);
153
+ this.timer = undefined;
154
+ }
155
+ this.logger.info("opencode-db", "Collector stopped", { event: "collector.stopped", collector: this.name });
156
+ }
157
+
158
+ // ── Internal ───────────────────────────────────────────────────────────────
159
+
160
+ private async safePoll(dbPath: string): Promise<void> {
161
+ const store = this.store!;
162
+ if (this.polling) {
163
+ this.skippedPolls += 1;
164
+ store.setCollectorStatus(this.name, { skippedPolls: this.skippedPolls });
165
+ this.logger.debug("opencode-db", "Skipping overlapping poll", { event: "collector.poll_skipped", collector: this.name, skippedPolls: this.skippedPolls });
166
+ return;
167
+ }
168
+
169
+ this.polling = true;
170
+ const startedAt = Date.now();
171
+ try {
172
+ await this.poll(dbPath);
173
+ store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now(), lastDurationMs: Date.now() - startedAt, skippedPolls: this.skippedPolls, errorCount: this.errorCount });
174
+ } finally {
175
+ this.polling = false;
176
+ }
177
+ }
178
+
179
+ private async poll(dbPath: string): Promise<void> {
180
+ const store = this.store!;
181
+ const config = this.config!;
182
+ const now = Date.now();
183
+ let db: Database | undefined;
184
+ try {
185
+ // Open read-only (WAL-safe)
186
+ db = new Database(dbPath, { readonly: true });
187
+
188
+ const cutoff = config.retention.standaloneMaxAgeHours > 0
189
+ ? now - config.retention.standaloneMaxAgeHours * 60 * 60 * 1000
190
+ : 0;
191
+ const limit = Math.max(1, config.retention.maxSessions || 500);
192
+ const rows = db
193
+ .query<SessionRow, [number, number]>(
194
+ "SELECT id, title, directory, time_created, time_updated, parent_id FROM session WHERE time_archived IS NULL AND time_updated >= ? ORDER BY time_updated DESC LIMIT ?",
195
+ )
196
+ .all(cutoff, limit);
197
+
198
+ this.logger.debug("opencode-db", "Poll result", { event: "collector.poll_completed", collector: this.name, rows: rows.length });
199
+
200
+ for (const row of rows) {
201
+ const storeId = `opencode:${row.id}`;
202
+ const existing = store.getSession(storeId);
203
+
204
+ if (existing) {
205
+ // Merge-only: preserve state, just update metadata
206
+ const newParentSessionId = row.parent_id ? `opencode:${row.parent_id}` : undefined;
207
+ const effectiveParentSessionId = newParentSessionId ?? existing.parentSessionId;
208
+ const needsUpdate =
209
+ existing.summary !== (row.title || "OpenCode session") ||
210
+ existing.currentPath !== (row.directory ?? undefined) ||
211
+ existing.lastActivity !== row.time_updated ||
212
+ existing.parentSessionId !== newParentSessionId;
213
+ const targetState = inferChildSessionState(
214
+ existing,
215
+ effectiveParentSessionId ? store.getSession(effectiveParentSessionId) : undefined,
216
+ config,
217
+ now,
218
+ );
219
+
220
+ if (needsUpdate || (targetState !== undefined && targetState !== existing.state)) {
221
+ const project = row.directory
222
+ ? (this.gitCollector?.resolve(row.directory) ?? existing.project)
223
+ : existing.project;
224
+ const merged: SessionModel = {
225
+ ...existing,
226
+ state: targetState ?? existing.state,
227
+ summary: row.title || existing.summary,
228
+ currentPath: row.directory ?? existing.currentPath,
229
+ lastActivity: Math.max(existing.lastActivity, row.time_updated),
230
+ project: project ?? existing.project,
231
+ parentSessionId: effectiveParentSessionId,
232
+ };
233
+ store.upsertSession(merged);
234
+ this.logger.debug("opencode-db", "Enriched existing session", { id: storeId });
235
+ }
236
+ } else {
237
+ // Session not yet known — add with minimal state
238
+ const currentPath = row.directory ?? undefined;
239
+ const project = currentPath ? this.gitCollector?.resolve(currentPath) : undefined;
240
+ const parentSessionId = row.parent_id ? `opencode:${row.parent_id}` : undefined;
241
+ const inferredChildState = inferChildSessionState(
242
+ { detectionSource: "opencode-db", parentSessionId, lastActivity: row.time_updated },
243
+ parentSessionId ? store.getSession(parentSessionId) : undefined,
244
+ config,
245
+ now,
246
+ );
247
+ const newSession: SessionModel = {
248
+ id: storeId,
249
+ source: "standalone",
250
+ tool: "opencode",
251
+ state: inferredChildState ?? "idle",
252
+ summary: row.title || "OpenCode session",
253
+ lastActivity: row.time_updated,
254
+ detectionSource: "opencode-db",
255
+ currentPath,
256
+ project,
257
+ opencode: {
258
+ sessionId: row.id,
259
+ instanceUrl: "",
260
+ },
261
+ parentSessionId,
262
+ };
263
+ store.upsertSession(newSession);
264
+ this.logger.debug("opencode-db", "Added session from DB", { id: storeId, title: row.title });
265
+ }
266
+ }
267
+
268
+ // All status updates consolidated in safePoll()
269
+ } catch (error) {
270
+ const msg = error instanceof Error ? error.message : String(error);
271
+ this.errorCount += 1;
272
+ this.logger.warn("opencode-db", "Poll error", { event: "collector.poll_failed", collector: this.name, errorMessage: msg });
273
+ store.setCollectorStatus(this.name, { lastError: msg, errorCount: this.errorCount });
274
+ } finally {
275
+ try {
276
+ db?.close();
277
+ } catch {
278
+ // Ignore close errors
279
+ }
280
+ }
281
+ }
282
+ }