@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,472 @@
1
+ import type { SessionModel } from "../../../shared/types";
2
+ import type { SnapshotHistory } from "../../../shared/types";
3
+ import type { ScrylogConfig } from "../../../shared/config";
4
+ import type { DaemonStore } from "../../store";
5
+ import type { Collector } from "../../scheduler";
6
+ import type { Logger } from "../../../shared/logger";
7
+ import { NOOP_LOGGER } from "../../../shared/logger";
8
+ import type { ProcessCollector } from "../process";
9
+ import { buildProcessTree, findAncestorInSet } from "../process";
10
+ import type { GitCollector } from "../git";
11
+ import { findOpencodeInTree } from "../opencode/discovery";
12
+ import { collectTmuxState, capturePane } from "./parse";
13
+ import {
14
+ isOpenCodeManagedSession,
15
+ isClaudeManagedSession,
16
+ isCodexManagedSession,
17
+ clearStaleClaudeTmuxBindings,
18
+ clearStaleCodexTmuxBindings,
19
+ clearStaleOpenCodeTmuxBindings,
20
+ managedStableForMs,
21
+ detectClaudePermissionPrompt,
22
+ detectCodexPermissionPrompt,
23
+ isClaudePaneCandidate,
24
+ isCodexPaneCandidate,
25
+ isOpenCodePaneCandidate,
26
+ shouldHideOpenCodePane,
27
+ looksLikeAgent,
28
+ paneModelToSession,
29
+ } from "./enrichment";
30
+ import { classifyPane } from "../../../legacy/classifier";
31
+
32
+ /**
33
+ * Tmux collector: runs on a periodic timer, builds pane models, and writes
34
+ * them to the store. Delegates classification to legacy detect/classifier.
35
+ */
36
+ export class TmuxCollector implements Collector {
37
+ name = "tmux";
38
+ private timer: ReturnType<typeof setInterval> | null = null;
39
+ private history = new Map<string, SnapshotHistory>();
40
+ private logger: Logger = NOOP_LOGGER;
41
+
42
+ constructor(
43
+ private readonly processCollector: ProcessCollector,
44
+ private readonly gitCollector: GitCollector,
45
+ ) {}
46
+
47
+ start(store: DaemonStore, config: ScrylogConfig, logger: Logger): void {
48
+ this.logger = logger;
49
+ const run = () => {
50
+ try {
51
+ this.collect(store, config);
52
+ store.setCollectorStatus(this.name, { running: true, lastRunAt: Date.now() });
53
+ } catch (error) {
54
+ const msg = error instanceof Error ? error.message : String(error);
55
+ store.setCollectorStatus(this.name, { lastError: msg });
56
+ store.reportError(this.name, msg);
57
+ }
58
+ };
59
+
60
+ logger.info("tmux", "Collector started", { event: "collector.started", collector: this.name, intervalMs: config.daemon.pollIntervalMs });
61
+ run();
62
+ this.timer = setInterval(run, config.daemon.pollIntervalMs);
63
+ }
64
+
65
+ stop(): void {
66
+ if (this.timer) {
67
+ clearInterval(this.timer);
68
+ this.timer = null;
69
+ }
70
+ this.logger.info("tmux", "Collector stopped", { event: "collector.stopped", collector: this.name });
71
+ }
72
+
73
+ private collect(store: DaemonStore, config: ScrylogConfig): void {
74
+ const tmux = collectTmuxState(config.daemon.commandTimeoutMs);
75
+ store.setTmuxSessions(tmux.sessions);
76
+
77
+ if (tmux.errors.length > 0) {
78
+ this.logger.warn("tmux", "tmux reported errors", { event: "collector.poll_partial", collector: this.name, errors: tmux.errors });
79
+ }
80
+
81
+ for (const msg of tmux.errors) {
82
+ store.reportError(this.name, msg);
83
+ }
84
+
85
+ if (tmux.panes.length === 0) {
86
+ this.logger.debug("tmux", "No panes found — clearing fallback sessions", { event: "collector.poll_completed", collector: this.name, panes: 0 });
87
+ clearStaleOpenCodeTmuxBindings(store, new Set());
88
+ // Remove all sessions that came from tmux
89
+ const current = store.getSessions();
90
+ for (const session of current) {
91
+ if (session.source === "tmux" && session.detectionSource === "fallback-detection") {
92
+ store.removeSession(session.id);
93
+ }
94
+ }
95
+ return;
96
+ }
97
+
98
+ this.logger.debug("tmux", "Collected panes", { event: "collector.poll_completed", collector: this.name, panes: tmux.panes.length, sessions: tmux.sessions.length });
99
+
100
+ const allProcesses = this.processCollector.processes;
101
+ const now = Date.now();
102
+
103
+ // Build set of known SDK PIDs for PID-ancestry matching
104
+ const sdkPidMap = store.getSdkPids();
105
+ const sdkPidSet = new Set(sdkPidMap.keys());
106
+
107
+ const newFallbackSessions: SessionModel[] = [];
108
+
109
+ for (const pane of tmux.panes) {
110
+ const processTree = buildProcessTree(pane.panePid, allProcesses);
111
+ const shouldPreview = looksLikeAgent(processTree, pane.currentCommand);
112
+ const sample = shouldPreview
113
+ ? capturePane(pane.paneId, config.tmux.previewLines, config.daemon.commandTimeoutMs)
114
+ : null;
115
+
116
+ const result = classifyPane({
117
+ pane: {
118
+ ...pane,
119
+ project: this.gitCollector.resolve(pane.currentPath),
120
+ },
121
+ processTree,
122
+ sample,
123
+ history: this.history.get(pane.paneId),
124
+ now,
125
+ });
126
+
127
+ this.history.set(pane.paneId, result.history);
128
+
129
+ if (shouldHideOpenCodePane(config.opencode, pane, processTree, result.pane.tool)) {
130
+ this.logger.debug("tmux", "Hiding OpenCode pane in SSE mode", {
131
+ paneId: pane.paneId,
132
+ currentCommand: pane.currentCommand,
133
+ });
134
+ continue;
135
+ }
136
+
137
+ // Check if any process in the tree belongs to a known SDK instance
138
+ // Walk all PIDs in the process tree and check for ancestor match
139
+ const allTreePids = new Set(processTree.map((p) => p.pid));
140
+ allTreePids.add(pane.panePid);
141
+
142
+ let matchedSdkPid: number | undefined;
143
+ for (const pid of allTreePids) {
144
+ const ancestor = findAncestorInSet(pid, sdkPidSet, allProcesses);
145
+ if (ancestor !== undefined) {
146
+ matchedSdkPid = ancestor;
147
+ break;
148
+ }
149
+ }
150
+
151
+ // ── M0: plugin-written tmux pane option ──────────────────────────────────
152
+ // The scrylog opencode plugin writes @opencode-session to the pane user
153
+ // option whenever session.created / session.updated fires. When present,
154
+ // this is an exact, authoritative match — skip all other heuristics.
155
+ if (pane.opencodeSession) {
156
+ const current = store.getSessions();
157
+ const pluginSession = current.find(
158
+ (s) =>
159
+ isOpenCodeManagedSession(s) &&
160
+ s.opencode?.sessionId === pane.opencodeSession &&
161
+ (!s.legacyPaneId || s.legacyPaneId === pane.paneId),
162
+ );
163
+ if (pluginSession) {
164
+ store.clearTmuxBindingForPane(pane.paneId, pluginSession.id);
165
+ const enriched: SessionModel = {
166
+ ...pluginSession,
167
+ source: "tmux",
168
+ project: result.pane.project,
169
+ tmux: {
170
+ session: pane.sessionName,
171
+ window: String(pane.windowIndex),
172
+ pane: pane.paneId,
173
+ },
174
+ legacyPaneId: pane.paneId,
175
+ processTree: result.pane.processTree,
176
+ processChain: result.pane.processChain,
177
+ sample: result.pane.sample,
178
+ stableForMs: managedStableForMs(pluginSession),
179
+ currentCommand: pane.currentCommand,
180
+ currentPath: pane.currentPath,
181
+ panePid: pane.panePid,
182
+ tty: pane.tty,
183
+ paneTitle: pane.title,
184
+ };
185
+ this.logger.debug("tmux", "Pane matched by plugin pane option — enriching", {
186
+ paneId: pane.paneId,
187
+ sessionId: pane.opencodeSession,
188
+ });
189
+ store.upsertSession(enriched);
190
+ continue;
191
+ }
192
+ // Exact session ID is authoritative. If the referenced session is not in
193
+ // the store yet, do not attempt any automatic OpenCode pane matching.
194
+ this.logger.debug("tmux", "Plugin pane option set but session not found in store yet", {
195
+ paneId: pane.paneId,
196
+ opencodeSession: pane.opencodeSession,
197
+ });
198
+ newFallbackSessions.push(paneModelToSession(result.pane));
199
+ continue;
200
+ }
201
+
202
+ // ── M1: hook-written @claude-session pane option ─────────────────────────
203
+ // The scrylog-claude-hook binary writes @claude-session to the pane when
204
+ // Claude runs inside tmux. This is an exact, authoritative match.
205
+ if (pane.claudeSession) {
206
+ const current = store.getSessions();
207
+ const linkedSession = current.find(
208
+ (s) =>
209
+ isClaudeManagedSession(s) &&
210
+ s.claude?.sessionId === pane.claudeSession &&
211
+ (!s.legacyPaneId || s.legacyPaneId === pane.paneId),
212
+ );
213
+ if (linkedSession) {
214
+ // Only apply detectClaudePermissionPrompt for non-hook-managed sessions.
215
+ // Hook-managed sessions have authoritative state from the PermissionRequest
216
+ // event and must not be overridden by stale tmux screen sampling.
217
+ const claudePrompt = linkedSession.detectionSource === "claude-hook"
218
+ ? null
219
+ : detectClaudePermissionPrompt(result.pane.sample);
220
+ store.clearTmuxBindingForPane(pane.paneId, linkedSession.id);
221
+ const enriched: SessionModel = {
222
+ ...linkedSession,
223
+ source: "tmux",
224
+ ...(claudePrompt ? {
225
+ state: claudePrompt.state,
226
+ hasAttention: claudePrompt.hasAttention,
227
+ summary: claudePrompt.summary,
228
+ } : {}),
229
+ project: result.pane.project,
230
+ tmux: {
231
+ session: pane.sessionName,
232
+ window: String(pane.windowIndex),
233
+ pane: pane.paneId,
234
+ },
235
+ legacyPaneId: pane.paneId,
236
+ processTree: result.pane.processTree,
237
+ processChain: result.pane.processChain,
238
+ sample: result.pane.sample,
239
+ stableForMs: managedStableForMs(linkedSession),
240
+ currentCommand: pane.currentCommand,
241
+ currentPath: pane.currentPath,
242
+ panePid: pane.panePid,
243
+ tty: pane.tty,
244
+ paneTitle: pane.title,
245
+ };
246
+ this.logger.debug("tmux", "Pane matched by @claude-session option — enriching", {
247
+ paneId: pane.paneId,
248
+ claudeSessionId: pane.claudeSession,
249
+ });
250
+ store.upsertSession(enriched);
251
+ continue;
252
+ }
253
+ // Check if deliberately excluded (local-command batch run)
254
+ if (store.isLocalCommand(pane.claudeSession)) {
255
+ this.logger.debug("tmux", "Skipping fallback for local-command session", {
256
+ paneId: pane.paneId,
257
+ claudeSession: pane.claudeSession,
258
+ });
259
+ continue;
260
+ }
261
+ // Authoritative but not yet in store — keep as fallback
262
+ this.logger.debug("tmux", "@claude-session set but session not in store yet", {
263
+ paneId: pane.paneId,
264
+ claudeSession: pane.claudeSession,
265
+ });
266
+ newFallbackSessions.push(paneModelToSession(result.pane));
267
+ continue;
268
+ }
269
+
270
+ // ── M1.5: hook-written @codex-session pane option ────────────────────────
271
+ // The scrylog-codex-hook binary writes @codex-session to the pane when
272
+ // Codex runs inside tmux. This is an exact, authoritative match.
273
+ if (pane.codexSession) {
274
+ const current = store.getSessions();
275
+ const linkedSession = current.find(
276
+ (s) =>
277
+ isCodexManagedSession(s) &&
278
+ s.codex?.sessionId === pane.codexSession &&
279
+ (!s.legacyPaneId || s.legacyPaneId === pane.paneId),
280
+ );
281
+ if (linkedSession) {
282
+ const codexPrompt = linkedSession.detectionSource === "codex-hook"
283
+ ? null
284
+ : detectCodexPermissionPrompt(result.pane.sample);
285
+ store.clearTmuxBindingForPane(pane.paneId, linkedSession.id);
286
+ const enriched: SessionModel = {
287
+ ...linkedSession,
288
+ source: "tmux",
289
+ ...(codexPrompt ? {
290
+ state: codexPrompt.state,
291
+ hasAttention: codexPrompt.hasAttention,
292
+ summary: codexPrompt.summary,
293
+ } : {}),
294
+ project: result.pane.project,
295
+ tmux: {
296
+ session: pane.sessionName,
297
+ window: String(pane.windowIndex),
298
+ pane: pane.paneId,
299
+ },
300
+ legacyPaneId: pane.paneId,
301
+ processTree: result.pane.processTree,
302
+ processChain: result.pane.processChain,
303
+ sample: result.pane.sample,
304
+ stableForMs: managedStableForMs(linkedSession),
305
+ currentCommand: pane.currentCommand,
306
+ currentPath: pane.currentPath,
307
+ panePid: pane.panePid,
308
+ tty: pane.tty,
309
+ paneTitle: pane.title,
310
+ };
311
+ this.logger.debug("tmux", "Pane matched by @codex-session option — enriching", {
312
+ paneId: pane.paneId,
313
+ codexSessionId: pane.codexSession,
314
+ });
315
+ store.upsertSession(enriched);
316
+ continue;
317
+ }
318
+ // Authoritative but not yet in store — keep as fallback
319
+ this.logger.debug("tmux", "@codex-session set but session not in store yet", {
320
+ paneId: pane.paneId,
321
+ codexSession: pane.codexSession,
322
+ });
323
+ newFallbackSessions.push(paneModelToSession(result.pane));
324
+ continue;
325
+ }
326
+
327
+ // Claude panes without the pane option: keep as fallback to avoid duplicate
328
+ // entries once the hook fires and the pane option is set.
329
+ if (isClaudePaneCandidate(processTree, pane.currentCommand)) {
330
+ newFallbackSessions.push(paneModelToSession(result.pane));
331
+ continue;
332
+ }
333
+
334
+ // Codex panes without the pane option: keep as fallback.
335
+ if (isCodexPaneCandidate(processTree, pane.currentCommand)) {
336
+ newFallbackSessions.push(paneModelToSession(result.pane));
337
+ continue;
338
+ }
339
+
340
+ // OpenCode panes inside tmux must only correlate by exact session ID.
341
+ // Without the plugin-written pane option, keep a temporary fallback pane
342
+ // row and avoid heuristic matching to DB/SDK sessions.
343
+ if (isOpenCodePaneCandidate(pane, processTree, result.pane.tool)) {
344
+ newFallbackSessions.push(paneModelToSession(result.pane));
345
+ continue;
346
+ }
347
+
348
+ if (matchedSdkPid !== undefined) {
349
+ // Find the SDK sessions for this instance and enrich them with tmux context.
350
+ const instanceUrl = sdkPidMap.get(matchedSdkPid);
351
+ const current = store.getSessions();
352
+ const sdkSessionsForInstance = current.filter(
353
+ (s) =>
354
+ isOpenCodeManagedSession(s) &&
355
+ s.opencode?.instanceUrl === instanceUrl &&
356
+ !s.legacyPaneId,
357
+ );
358
+
359
+ for (const sdkSession of sdkSessionsForInstance) {
360
+ const enriched: SessionModel = {
361
+ ...sdkSession,
362
+ // Switch to tmux source so TUI groups it with tmux sessions
363
+ source: "tmux",
364
+ project: result.pane.project,
365
+ tmux: {
366
+ session: pane.sessionName,
367
+ window: String(pane.windowIndex),
368
+ pane: pane.paneId,
369
+ },
370
+ legacyPaneId: pane.paneId,
371
+ // Extended fields from pane analysis
372
+ processTree: result.pane.processTree,
373
+ processChain: result.pane.processChain,
374
+ sample: result.pane.sample,
375
+ stableForMs: managedStableForMs(sdkSession),
376
+ currentCommand: pane.currentCommand,
377
+ currentPath: pane.currentPath,
378
+ panePid: pane.panePid,
379
+ tty: pane.tty,
380
+ paneTitle: pane.title,
381
+ };
382
+ this.logger.debug("tmux", "Pane covered by SDK — enriching SDK session", {
383
+ paneId: pane.paneId,
384
+ sdkSessionId: sdkSession.id,
385
+ instanceUrl,
386
+ });
387
+ store.upsertSession(enriched);
388
+ }
389
+ // Don't create a fallback entry for this pane
390
+ this.logger.debug("tmux", "Pane covered by SDK — skipping fallback entry", {
391
+ paneId: pane.paneId,
392
+ matchedSdkPid,
393
+ instanceUrl,
394
+ });
395
+ } else {
396
+ // PID ancestry via sdkPidMap failed (e.g. registerSdkPid not yet called).
397
+ // Attempt URL-from-args matching: walk the pane's ancestor chain looking
398
+ // for an opencode process and derive the instance URL from its args.
399
+ // This is deterministic and has no false positives unlike path matching.
400
+ const ancestor = findOpencodeInTree(processTree);
401
+ if (ancestor) {
402
+ const current = store.getSessions();
403
+ const sdkSessionsForInstance = current.filter(
404
+ (s) =>
405
+ isOpenCodeManagedSession(s) &&
406
+ s.opencode?.instanceUrl === ancestor.url &&
407
+ !s.legacyPaneId,
408
+ );
409
+
410
+ if (sdkSessionsForInstance.length > 0) {
411
+ for (const sdkSession of sdkSessionsForInstance) {
412
+ const enriched: SessionModel = {
413
+ ...sdkSession,
414
+ source: "tmux",
415
+ project: result.pane.project,
416
+ tmux: {
417
+ session: pane.sessionName,
418
+ window: String(pane.windowIndex),
419
+ pane: pane.paneId,
420
+ },
421
+ legacyPaneId: pane.paneId,
422
+ processTree: result.pane.processTree,
423
+ processChain: result.pane.processChain,
424
+ sample: result.pane.sample,
425
+ stableForMs: managedStableForMs(sdkSession),
426
+ currentCommand: pane.currentCommand,
427
+ currentPath: pane.currentPath,
428
+ panePid: pane.panePid,
429
+ tty: pane.tty,
430
+ paneTitle: pane.title,
431
+ };
432
+ this.logger.debug("tmux", "Pane matched SDK session by URL-from-args — enriching", {
433
+ paneId: pane.paneId,
434
+ sdkSessionId: sdkSession.id,
435
+ instanceUrl: ancestor.url,
436
+ });
437
+ store.upsertSession(enriched);
438
+ }
439
+ this.logger.debug("tmux", "Pane covered by URL-from-args match — skipping fallback", {
440
+ paneId: pane.paneId,
441
+ instanceUrl: ancestor.url,
442
+ });
443
+ } else {
444
+ newFallbackSessions.push(paneModelToSession(result.pane));
445
+ }
446
+ } else {
447
+ newFallbackSessions.push(paneModelToSession(result.pane));
448
+ }
449
+ }
450
+ }
451
+
452
+ // Prune history for gone panes
453
+ const livePaneIds = new Set(tmux.panes.map((pane) => pane.paneId));
454
+ clearStaleOpenCodeTmuxBindings(store, livePaneIds);
455
+ clearStaleClaudeTmuxBindings(store, livePaneIds);
456
+ clearStaleCodexTmuxBindings(store, livePaneIds);
457
+
458
+ for (const paneId of [...this.history.keys()]) {
459
+ if (!livePaneIds.has(paneId)) {
460
+ this.history.delete(paneId);
461
+ }
462
+ }
463
+
464
+ // Replace only fallback sessions (non-enriched); keep SDK-sourced sessions
465
+ const current = store.getSessions();
466
+ const nonFallback = current.filter(
467
+ (s) => !(s.source === "tmux" && s.detectionSource === "fallback-detection"),
468
+ );
469
+
470
+ store.setSessions([...nonFallback, ...newFallbackSessions]);
471
+ }
472
+ }