@volter/supercode-terminal 0.2.30

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.
@@ -0,0 +1,1344 @@
1
+ export { clearRollHeadVerdict, createSessionRollTracker, scanTranscriptHead } from "./session-roll.js";
2
+ // How long a cooperative signal stays trusted before the derivation falls
3
+ // through to the next rung. Generous enough to span the gap between a hook's
4
+ // own turn-start/turn-end pings across one long tool call; short enough that a
5
+ // hook that's been removed (or a session the hook never covered) degrades to
6
+ // the next rung within a couple of observe intervals rather than staying
7
+ // pinned to a one-time snapshot forever.
8
+ export const COOPERATIVE_SIGNAL_TTL_MS = 120_000;
9
+ export function createCooperativeSignalStore() {
10
+ return new Map();
11
+ }
12
+ // The ONE place a raw hook event name becomes a stored signal — validates it so
13
+ // a malformed/future hook payload can't corrupt the store, and stamps the
14
+ // receipt time itself (the hook never gets to claim its own arrival time,
15
+ // which would let a stale replay masquerade as fresh). A synchronous Map
16
+ // `.set()` is O(1) with zero I/O, so calling this directly from an HTTP
17
+ // handler never touches the event-loop-hot-path doctrine.
18
+ export function recordCooperativeSignal(store, sessionId, event, nowMs) {
19
+ if (event !== "turn-end" && event !== "turn-start") {
20
+ throw new Error(`Unrecognized cooperative signal event: ${JSON.stringify(event)}`);
21
+ }
22
+ store.set(sessionId, { atMs: nowMs, event });
23
+ }
24
+ export function createAttentionRequestStore() {
25
+ return new Map();
26
+ }
27
+ export function isAttentionUrgency(value) {
28
+ return value === "now" || value === "soon" || value === "whenever";
29
+ }
30
+ // The ONE place a raw raise request becomes a stored AttentionRequest —
31
+ // validates urgency/reason and stamps raisedAt itself (nowMs, ISO), mirroring
32
+ // recordCooperativeSignal's "the store never lets the caller claim its own
33
+ // arrival time" doctrine. A synchronous Map .set(), zero I/O.
34
+ export function recordAttentionRequest(store, sessionId, request, nowMs) {
35
+ if (!request.reason || request.reason.length === 0) {
36
+ throw new Error("Attention request requires a non-empty reason.");
37
+ }
38
+ if (!isAttentionUrgency(request.urgency)) {
39
+ throw new Error(`Unrecognized attention urgency: ${JSON.stringify(request.urgency)}`);
40
+ }
41
+ const stored = {
42
+ ...(request.detail ? { detail: request.detail } : {}),
43
+ raisedAt: new Date(nowMs).toISOString(),
44
+ reason: request.reason,
45
+ urgency: request.urgency
46
+ };
47
+ store.set(sessionId, stored);
48
+ return stored;
49
+ }
50
+ // Clearing is idempotent (no error clearing an absent/already-cleared
51
+ // session) — both the explicit clear verb and the session-keyed-send
52
+ // auto-clear call this the same way.
53
+ export function clearAttentionRequest(store, sessionId) {
54
+ store.delete(sessionId);
55
+ }
56
+ const KNOWN_AGENT_IDS = ["claude", "codex", "gemini", "grok", "supercode"];
57
+ const PROCESS_SNAPSHOT_MAX_BUFFER = 16 * 1024 * 1024;
58
+ // Async acquisition shared by tmux and native observers. The legacy sync
59
+ // version remains for WezTerm; ordinary iTerm observations use the async gather.
60
+ export async function snapshotProcessTreeAsync(rootPids, options = {}) {
61
+ const rows = parseProcessRows(await execFileAsync("ps", ["-axo", "pid=,ppid=,pgid=,stat=,lstart=,ucomm="], { maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 }), true);
62
+ const relevantProcessIds = collectRelevantProcessIds(rows, [...(rootPids ?? []), ...(options.additionalPids ?? [])]);
63
+ const wholeHost = rootPids === undefined || (rootPids.length === 0 && options.additionalPids === undefined);
64
+ const [, listeners] = await Promise.all([
65
+ enrichProcessArgsAsync(rows, relevantProcessIds),
66
+ options.includeListeners === false ? new Map() : gatherProcessListeners(relevantProcessIds, wholeHost)
67
+ ]);
68
+ applyProcessListeners(rows, listeners);
69
+ return rows;
70
+ }
71
+ export function snapshotProcessTree(rootPids) {
72
+ const stdout = execFileSync("ps", ["-axo", "pid=,ppid=,pgid=,stat=,lstart=,ucomm="], { encoding: "utf8", maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 });
73
+ const rows = parseProcessRows(stdout, true);
74
+ const relevantProcessIds = collectRelevantProcessIds(rows, rootPids ?? []);
75
+ enrichProcessArgs(rows, relevantProcessIds);
76
+ // Legacy synchronous observers have no pre-gathered lease/retained-PID
77
+ // context. Keep their full inventory until those callers adopt the gather.
78
+ applyProcessListeners(rows, gatherProcessListenersSync(relevantProcessIds, true));
79
+ return rows;
80
+ }
81
+ // ONLY the pids of rootPids and everything descended from them — never the whole
82
+ // table. This is the sole safe input to a SIGKILL sweep: snapshotProcessTree
83
+ // returns every process on the machine (the lifecycle observe loop wants that),
84
+ // so SIGKILLing its keys would kill every process the user owns — terminals, the
85
+ // tmux server, this console. A kill must walk DOWN from the pane pids only; the
86
+ // tmux server is a parent, never a descendant, so it is correctly never returned.
87
+ export function snapshotDescendantPids(rootPids) {
88
+ if (rootPids.length === 0) {
89
+ return [];
90
+ }
91
+ const stdout = execFileSync("ps", ["-axo", "pid=,ppid=,pgid=,stat=,ucomm="], { encoding: "utf8", maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 });
92
+ return collectRelevantProcessIds(parseProcessRows(stdout), rootPids);
93
+ }
94
+ function parseProcessRows(stdout, includesStartTime = false) {
95
+ const rows = new Map();
96
+ for (const line of stdout.split("\n")) {
97
+ const trimmed = line.trim();
98
+ if (!trimmed)
99
+ continue;
100
+ // `ps -axo` covers EVERY process on the host, including transient/kernel rows
101
+ // with an empty command. One unexpected row must not abort the whole observe
102
+ // cycle (that would flap the machine to degraded for an unrelated process) —
103
+ // skip rows we can't parse rather than throwing.
104
+ const match = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/);
105
+ if (!match)
106
+ continue;
107
+ const [, pidRaw, ppidRaw, pgidRaw, stat, remainder] = match;
108
+ // lstart is five whitespace-separated fields, followed by the free-form
109
+ // command. Keep ps's internal spacing: lease files use that exact identity.
110
+ const started = includesStartTime ? remainder?.match(/^(\S+\s+\S+\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s*(.*)$/) : undefined;
111
+ if (includesStartTime && !started)
112
+ continue;
113
+ const command = includesStartTime ? started?.[2] : remainder;
114
+ const pid = Number(pidRaw);
115
+ const ppid = Number(ppidRaw);
116
+ const pgid = Number(pgidRaw);
117
+ if (!Number.isInteger(pid) || pid < 1 || !Number.isInteger(ppid) || ppid < 0 || !Number.isInteger(pgid)) {
118
+ continue;
119
+ }
120
+ rows.set(pid, {
121
+ args: command ?? "",
122
+ command: command ?? "",
123
+ pgid,
124
+ pid,
125
+ ppid,
126
+ ...(started?.[1] === undefined ? {} : { processStartTime: started[1] }),
127
+ stat
128
+ });
129
+ }
130
+ return rows;
131
+ }
132
+ function collectRelevantProcessIds(rows, rootPids) {
133
+ const relevant = new Set();
134
+ const childrenByParent = new Map();
135
+ for (const row of rows.values()) {
136
+ const children = childrenByParent.get(row.ppid) ?? [];
137
+ children.push(row);
138
+ childrenByParent.set(row.ppid, children);
139
+ }
140
+ const queue = rootPids.filter((pid) => rows.has(pid));
141
+ while (queue.length > 0) {
142
+ const pid = queue.shift();
143
+ if (pid === undefined || relevant.has(pid))
144
+ continue;
145
+ relevant.add(pid);
146
+ for (const child of childrenByParent.get(pid) ?? []) {
147
+ queue.push(child.pid);
148
+ }
149
+ }
150
+ return Array.from(relevant).sort((a, b) => a - b);
151
+ }
152
+ function processIdChunks(pids, chunkSize = 64) {
153
+ const chunks = [];
154
+ for (let index = 0; index < pids.length; index += chunkSize) {
155
+ const chunk = pids.slice(index, index + chunkSize);
156
+ if (chunk.length > 0)
157
+ chunks.push(chunk);
158
+ }
159
+ return chunks;
160
+ }
161
+ function applyEnrichedArgs(rows, stdout) {
162
+ for (const line of stdout.split("\n")) {
163
+ const trimmed = line.trim();
164
+ if (!trimmed)
165
+ continue;
166
+ const match = trimmed.match(/^(\d+)\s*(.*)$/);
167
+ if (!match)
168
+ continue;
169
+ const [, pidRaw, argsRaw] = match;
170
+ const pid = Number(pidRaw);
171
+ const row = rows.get(pid);
172
+ if (!row)
173
+ continue;
174
+ row.args = argsRaw || row.command;
175
+ }
176
+ }
177
+ function enrichProcessArgs(rows, pids) {
178
+ for (const chunk of processIdChunks(pids)) {
179
+ // `ps -p` exits non-zero when none of the listed pids still exist — a pid can
180
+ // die in the gap between the snapshot and this enrichment. That is not a
181
+ // failure of the observe cycle: those rows simply keep their `command`.
182
+ try {
183
+ applyEnrichedArgs(rows, execFileSync("ps", ["-p", chunk.join(","), "-o", "pid=,args="], { encoding: "utf8", maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 }));
184
+ }
185
+ catch {
186
+ // Best-effort enrichment; leave these rows with their `command` fallback.
187
+ }
188
+ }
189
+ }
190
+ async function enrichProcessArgsAsync(rows, pids) {
191
+ // Independent chunks run concurrently rather than blocking in sequence. A chunk
192
+ // whose pids all exited between the snapshot and now makes `ps -p` exit
193
+ // non-zero; tolerate it per-chunk so one dead pid can't reject the whole pass.
194
+ const outputs = await Promise.all(processIdChunks(pids).map((chunk) => execFileAsync("ps", ["-p", chunk.join(","), "-o", "pid=,args="], { maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 }).then((stdout) => stdout).catch(() => "")));
195
+ for (const stdout of outputs)
196
+ applyEnrichedArgs(rows, stdout);
197
+ }
198
+ function parseListenerAddress(value) {
199
+ const match = value.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/u);
200
+ const address = match?.[1] ?? match?.[2];
201
+ const port = Number(match?.[3]);
202
+ if (!address || !Number.isSafeInteger(port) || port < 1 || port > 65_535)
203
+ return undefined;
204
+ return { address, port };
205
+ }
206
+ function parseProcessListeners(stdout) {
207
+ const listeners = new Map();
208
+ let pid;
209
+ for (const line of stdout.split("\n")) {
210
+ const processMatch = line.match(/^p(\d+)$/u);
211
+ if (processMatch) {
212
+ pid = Number(processMatch[1]);
213
+ continue;
214
+ }
215
+ if (pid === undefined || !line.startsWith("n"))
216
+ continue;
217
+ const listener = parseListenerAddress(line.slice(1));
218
+ if (listener === undefined)
219
+ continue;
220
+ const current = listeners.get(pid) ?? [];
221
+ if (!current.some((candidate) => candidate.address === listener.address && candidate.port === listener.port))
222
+ current.push(listener);
223
+ listeners.set(pid, current);
224
+ }
225
+ for (const current of listeners.values())
226
+ current.sort((a, b) => a.port - b.port || a.address.localeCompare(b.address));
227
+ return listeners;
228
+ }
229
+ function applyProcessListeners(rows, listeners) {
230
+ for (const [pid, current] of listeners) {
231
+ const row = rows.get(pid);
232
+ if (row !== undefined)
233
+ row.listeners = current;
234
+ }
235
+ }
236
+ function parseProcNetListeners(tables) {
237
+ const listeners = new Map();
238
+ for (const table of tables) {
239
+ for (const row of table.split("\n")) {
240
+ const columns = row.trim().split(/\s+/u);
241
+ if (columns.length < 10 || columns[3] !== "0A")
242
+ continue;
243
+ const [addressHex, portHex] = columns[1]?.split(":") ?? [];
244
+ if (addressHex === undefined || portHex === undefined)
245
+ continue;
246
+ let address;
247
+ if (addressHex.length === 8) {
248
+ const value = Number.parseInt(addressHex, 16);
249
+ const ipv4 = `${value & 0xff}.${(value >>> 8) & 0xff}.${(value >>> 16) & 0xff}.${value >>> 24}`;
250
+ address = ipv4 === "0.0.0.0" ? "*" : ipv4;
251
+ }
252
+ else if (addressHex.length === 32) {
253
+ if (/^0+$/u.test(addressHex))
254
+ address = "*";
255
+ else if (/^(?:0{24}01000000|0{30}01)$/iu.test(addressHex))
256
+ address = "::1";
257
+ }
258
+ const port = Number.parseInt(portHex, 16);
259
+ const inode = columns[9];
260
+ if (address !== undefined && inode !== undefined && Number.isSafeInteger(port) && port > 0 && port <= 65_535)
261
+ listeners.set(inode, { address, port });
262
+ }
263
+ }
264
+ return listeners;
265
+ }
266
+ function listenersFromProcLinks(links, listenersByInode) {
267
+ const listeners = [];
268
+ for (const link of links) {
269
+ const inode = /^socket:\[(\d+)\]$/u.exec(link)?.[1];
270
+ const listener = inode === undefined ? undefined : listenersByInode.get(inode);
271
+ if (listener !== undefined && !listeners.some((candidate) => candidate.address === listener.address && candidate.port === listener.port))
272
+ listeners.push(listener);
273
+ }
274
+ return listeners.sort((left, right) => left.port - right.port || left.address.localeCompare(right.address));
275
+ }
276
+ async function mergeProcNetListeners(base, processIds) {
277
+ const tables = await Promise.all(["/proc/net/tcp", "/proc/net/tcp6"].map((path) => readFileAsync(path, "utf8").catch(() => "")));
278
+ const listenersByInode = parseProcNetListeners(tables);
279
+ await Promise.all(processIds.filter((pid) => !base.has(pid)).map(async (pid) => {
280
+ const directory = `/proc/${pid}/fd`;
281
+ const entries = await readdirAsync(directory).catch(() => []);
282
+ const links = await Promise.all(entries.map((entry) => readlinkAsync(join(directory, entry)).catch(() => "")));
283
+ const listeners = listenersFromProcLinks(links, listenersByInode);
284
+ if (listeners.length > 0)
285
+ base.set(pid, listeners);
286
+ }));
287
+ return base;
288
+ }
289
+ function mergeProcNetListenersSync(base, processIds) {
290
+ const tables = ["/proc/net/tcp", "/proc/net/tcp6"].map((path) => {
291
+ try {
292
+ return readFileSync(path, "utf8");
293
+ }
294
+ catch {
295
+ return "";
296
+ }
297
+ });
298
+ const listenersByInode = parseProcNetListeners(tables);
299
+ for (const pid of processIds) {
300
+ if (base.has(pid))
301
+ continue;
302
+ const directory = `/proc/${pid}/fd`;
303
+ let entries;
304
+ try {
305
+ entries = readdirSync(directory);
306
+ }
307
+ catch {
308
+ continue;
309
+ }
310
+ const links = entries.map((entry) => {
311
+ try {
312
+ return readlinkSync(join(directory, entry));
313
+ }
314
+ catch {
315
+ return "";
316
+ }
317
+ });
318
+ const listeners = listenersFromProcLinks(links, listenersByInode);
319
+ if (listeners.length > 0)
320
+ base.set(pid, listeners);
321
+ }
322
+ return base;
323
+ }
324
+ async function gatherProcessListeners(processIds, wholeHost = false) {
325
+ if (!wholeHost && processIds.length === 0)
326
+ return new Map();
327
+ const outputs = [];
328
+ // Bound argv without launching a concurrent fleet of expensive lsof children.
329
+ for (const chunk of wholeHost ? [undefined] : processIdChunks(processIds, 256)) {
330
+ try {
331
+ outputs.push(await execFileAsync("lsof", listenerArgs(chunk), { maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 }));
332
+ }
333
+ catch (error) {
334
+ outputs.push(normalListenerExitOutput(error));
335
+ }
336
+ }
337
+ const listeners = wholeHost ? parseProcessListeners(outputs.join("\n")) : selectedProcessListeners(outputs.join("\n"), processIds);
338
+ return mergeProcNetListeners(listeners, processIds);
339
+ }
340
+ function gatherProcessListenersSync(processIds, wholeHost = false) {
341
+ if (!wholeHost && processIds.length === 0)
342
+ return new Map();
343
+ const outputs = (wholeHost ? [undefined] : processIdChunks(processIds, 256)).map((chunk) => {
344
+ try {
345
+ return execFileSync("lsof", listenerArgs(chunk), { encoding: "utf8", maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 });
346
+ }
347
+ catch (error) {
348
+ return normalListenerExitOutput(error);
349
+ }
350
+ });
351
+ const listeners = wholeHost ? parseProcessListeners(outputs.join("\n")) : selectedProcessListeners(outputs.join("\n"), processIds);
352
+ return mergeProcNetListenersSync(listeners, processIds);
353
+ }
354
+ function normalListenerExitOutput(error) {
355
+ // lsof may exit 1 for a disappeared selected PID while still reporting other
356
+ // valid listeners. A timeout/signal/maxBuffer failure is not a complete scan.
357
+ const result = error;
358
+ return result && (result.code === 1 || result.status === 1) && !result.killed && !result.signal && typeof result.stdout === "string"
359
+ ? result.stdout
360
+ : "";
361
+ }
362
+ function listenerArgs(processIds) {
363
+ // lsof selections are OR-ed unless -a is supplied. Without it, -p still
364
+ // enumerates every listener on the host, defeating the host boundary.
365
+ return ["-nP", ...(processIds === undefined ? [] : ["-a", "-p", processIds.join(",")]), "-iTCP", "-sTCP:LISTEN", "-Fpn"];
366
+ }
367
+ function selectedProcessListeners(stdout, processIds) {
368
+ const selected = new Set(processIds);
369
+ return new Map([...parseProcessListeners(stdout)].filter(([pid]) => selected.has(pid)));
370
+ }
371
+ export function createLifecycleStore() {
372
+ return {
373
+ derivationUnknownCount: 0,
374
+ observeDurationMs: 0,
375
+ panes: new Map(),
376
+ sessions: new Map()
377
+ };
378
+ }
379
+ // One gather per observation, shared by the native and tmux drivers. Detached
380
+ // work remains in scope even after its pane or original agent has disappeared.
381
+ // No cross-tick cache: PID/start-time identities always come from this scan.
382
+ export async function gatherLifecycleObservation(panes, store, rollTracker) {
383
+ const sessions = [...store.sessions.values()];
384
+ const leasesByDir = await gatherBackgroundLeases([
385
+ ...panes.map((pane) => pane.backgroundLeaseDir),
386
+ ...sessions.map((session) => session.backgroundLeaseDir)
387
+ ]);
388
+ const additionalPids = [
389
+ ...sessions.flatMap((session) => session.background.map((item) => item.pid)),
390
+ ...[...leasesByDir.values()].flatMap((leases) => leases.flatMap((lease) => Number.isInteger(lease.pid) && Number(lease.pid) > 0 ? [Number(lease.pid)] : []))
391
+ ];
392
+ const rootPids = panes.map((pane) => pane.rootPid);
393
+ const processRows = rootPids.length === 0 && additionalPids.length === 0
394
+ ? new Map()
395
+ : await snapshotProcessTreeAsync(rootPids, { additionalPids });
396
+ const startTimes = new Map();
397
+ for (const [pid, row] of processRows) {
398
+ if (row.processStartTime !== undefined)
399
+ startTimes.set(pid, row.processStartTime);
400
+ }
401
+ const detectedIds = [];
402
+ const detectedIdsByPane = new Map();
403
+ if (rollTracker) {
404
+ // The same pre-gathered IO context as the pure pass: detecting identity must
405
+ // not fall back to synchronous ps/lease reads before an asynchronous gather.
406
+ const previousIo = activeIo;
407
+ activeIo = { leasesByDir, startTimes };
408
+ try {
409
+ for (const pane of panes) {
410
+ const detected = detectCurrentSession(pane, processRows, Date.now());
411
+ if (detected) {
412
+ detectedIds.push(detected.sessionId);
413
+ detectedIdsByPane.set(pane.paneId, detected.sessionId);
414
+ }
415
+ }
416
+ }
417
+ finally {
418
+ activeIo = previousIo;
419
+ }
420
+ }
421
+ const sessionRolls = rollTracker ? await rollTracker.gather([
422
+ ...detectedIds,
423
+ ...panes.flatMap((pane) => {
424
+ const current = store.panes.get(pane.paneId)?.currentSessionId;
425
+ return current ? [current] : [];
426
+ }),
427
+ ], detectedIdsByPane) : undefined;
428
+ return { leasesByDir, processRows, startTimes, ...(sessionRolls ? { sessionRolls } : {}) };
429
+ }
430
+ // observeLifecycle runs synchronously start-to-finish (no awaits), so a module
431
+ // scoped context is never interleaved across calls.
432
+ let activeIo;
433
+ export function observeLifecycle(options) {
434
+ activeIo = options.io;
435
+ // Store resolution happens ONCE, here — the one named place (no `??`
436
+ // fallback repeated at the inner pass) — so the wrapper always holds the
437
+ // same store object it times below, even when a caller (a fresh driver, a
438
+ // test) supplies none.
439
+ const store = options.store ?? createLifecycleStore();
440
+ const startedAt = performance.now();
441
+ try {
442
+ return observeLifecycleInner({ ...options, store });
443
+ }
444
+ finally {
445
+ // §I.1: observeDurationMs is a diagnostic for the event-loop-hot-
446
+ // path doctrine, surfaced at /healthz — measured around the WHOLE pure
447
+ // pass (this function runs synchronously start-to-finish, no awaits), not
448
+ // just a sub-step, so a future regression that slows any part of it shows
449
+ // up here.
450
+ store.observeDurationMs = performance.now() - startedAt;
451
+ activeIo = undefined;
452
+ }
453
+ }
454
+ function observeLifecycleInner(options) {
455
+ const store = options.store;
456
+ const observedAt = (options.now ?? new Date()).toISOString();
457
+ // Resolved once per pass (not re-derived per pane) — the single input the
458
+ // cooperative rung's freshness check (deriveSessionDerivation) reads.
459
+ const nowMs = Date.parse(observedAt);
460
+ const livePaneIds = new Set(options.panes.map((pane) => pane.paneId));
461
+ // Detect the entire current batch before mutating ownership. A newcomer may
462
+ // directly own a previously inferred successor; pane iteration order must
463
+ // never let an old mapping steal it before that new pane enters the store.
464
+ const detectedByPane = new Map(options.panes.map((pane) => [pane.paneId,
465
+ preserveSessionIdentityAcrossArgGap(detectCurrentSession(pane, options.processRows, nowMs), store.panes.get(pane.paneId), store),
466
+ ]));
467
+ const currentClaims = new Map();
468
+ for (const pane of options.panes) {
469
+ const claim = activeIo?.sessionRollClaims?.get(pane.paneId) ?? detectedByPane.get(pane.paneId)?.sessionId;
470
+ if (claim)
471
+ currentClaims.set(pane.paneId, claim);
472
+ }
473
+ for (const pane of options.panes) {
474
+ const existingPane = store.panes.get(pane.paneId);
475
+ const sessionIds = existingPane?.sessionIds ? [...existingPane.sessionIds] : [];
476
+ const stableDetected = detectedByPane.get(pane.paneId) ?? null;
477
+ const { rolledFrom, session: currentSession } = resolveSessionRoll(stableDetected, pane.paneId, store, currentClaims);
478
+ let currentSessionId = existingPane?.currentSessionId ?? null;
479
+ if (currentSession) {
480
+ if (currentSessionId && currentSessionId !== currentSession.sessionId) {
481
+ detachPreviousSession({
482
+ backgroundLeaseDir: pane.backgroundLeaseDir,
483
+ observedAt,
484
+ paneId: pane.paneId,
485
+ processRows: options.processRows,
486
+ sessionId: currentSessionId,
487
+ store
488
+ });
489
+ }
490
+ const previous = store.sessions.get(currentSession.sessionId);
491
+ recordDerivationDegradation(store, previous, currentSession);
492
+ currentSessionId = currentSession.sessionId;
493
+ // A rolled-away launch id stays in the pane's history (before its
494
+ // successor) — the roll re-attributes the pane, it never erases where the
495
+ // pane has been.
496
+ if (rolledFrom && !sessionIds.includes(rolledFrom)) {
497
+ sessionIds.push(rolledFrom);
498
+ }
499
+ if (!sessionIds.includes(currentSession.sessionId)) {
500
+ sessionIds.push(currentSession.sessionId);
501
+ }
502
+ store.sessions.set(currentSession.sessionId, {
503
+ sessionId: currentSession.sessionId,
504
+ ...(currentSession.agentSessionId ? { agentSessionId: currentSession.agentSessionId } : {}),
505
+ ...(currentSession.controlCwd ? { cwd: currentSession.controlCwd } : {}),
506
+ ...(currentSession.isControl ? { isControl: true } : {}),
507
+ attachedPaneId: pane.paneId,
508
+ background: currentSession.background,
509
+ backgroundLeaseDir: pane.backgroundLeaseDir,
510
+ derivation: currentSession.derivation,
511
+ firstSeenAt: previous?.firstSeenAt ?? observedAt,
512
+ lastPaneId: pane.paneId,
513
+ lastSeenAt: observedAt,
514
+ mainPid: currentSession.mainPid,
515
+ ...(currentSession.mainProcessStartTime ? { mainProcessStartTime: currentSession.mainProcessStartTime } : {}),
516
+ harness: currentSession.harness,
517
+ // Carried through from detectCurrentSession alongside `derivation`: both
518
+ // are populated from the same detection pass, so classifyAttentionSignal's
519
+ // "asking"/"errored" read reaches the stored session and every consumer
520
+ // downstream of it.
521
+ ...(currentSession.signal ? { signal: currentSession.signal } : {}),
522
+ state: currentSession.state
523
+ });
524
+ }
525
+ else if (currentSessionId) {
526
+ const session = store.sessions.get(currentSessionId);
527
+ if (session) {
528
+ // The bounded process-table read can return without this pane's agent
529
+ // row under severe host pressure even though kill(0) still proves the
530
+ // exact process is alive. Preserve the last exact identity for that
531
+ // degraded tick. When start-time evidence is available it must still
532
+ // match, preventing a reused PID from inheriting the conversation.
533
+ if (sameMainProcessAlive(session)) {
534
+ store.sessions.set(currentSessionId, {
535
+ ...session,
536
+ lastSeenAt: observedAt
537
+ });
538
+ }
539
+ else {
540
+ const background = refreshBackground(session.background, options.processRows, pane.backgroundLeaseDir ?? session.backgroundLeaseDir);
541
+ const hasBackground = background.length > 0;
542
+ if (hasBackground) {
543
+ store.sessions.set(currentSessionId, {
544
+ ...session,
545
+ attachedPaneId: null,
546
+ background,
547
+ backgroundLeaseDir: pane.backgroundLeaseDir ?? session.backgroundLeaseDir,
548
+ derivation: BACKGROUND_DERIVATION,
549
+ lastPaneId: pane.paneId,
550
+ lastSeenAt: observedAt,
551
+ mainPid: undefined,
552
+ mainProcessStartTime: undefined,
553
+ state: "session_stopped_background_running"
554
+ });
555
+ currentSessionId = null;
556
+ }
557
+ else {
558
+ store.sessions.delete(currentSessionId);
559
+ currentSessionId = null;
560
+ }
561
+ }
562
+ }
563
+ }
564
+ store.panes.set(pane.paneId, {
565
+ currentSessionId,
566
+ paneId: pane.paneId,
567
+ processes: paneProcesses(pane.rootPid, options.processRows),
568
+ rootPid: pane.rootPid,
569
+ sessionIds,
570
+ target: pane.target
571
+ });
572
+ }
573
+ for (const session of store.sessions.values()) {
574
+ if (session.attachedPaneId && !livePaneIds.has(session.attachedPaneId)) {
575
+ const background = refreshBackground(session.background, options.processRows, session.backgroundLeaseDir);
576
+ if (background.length > 0) {
577
+ store.sessions.set(session.sessionId, {
578
+ ...session,
579
+ attachedPaneId: null,
580
+ background,
581
+ derivation: BACKGROUND_DERIVATION,
582
+ lastSeenAt: observedAt,
583
+ mainPid: undefined,
584
+ mainProcessStartTime: undefined,
585
+ state: "session_stopped_background_running"
586
+ });
587
+ }
588
+ else {
589
+ store.sessions.delete(session.sessionId);
590
+ }
591
+ const pane = store.panes.get(session.attachedPaneId);
592
+ if (pane) {
593
+ store.panes.set(session.attachedPaneId, {
594
+ ...pane,
595
+ currentSessionId: null
596
+ });
597
+ }
598
+ }
599
+ else if (!session.attachedPaneId && session.background.length > 0) {
600
+ const background = refreshBackground(session.background, options.processRows, session.backgroundLeaseDir);
601
+ const lastPaneIsLive = session.lastPaneId ? livePaneIds.has(session.lastPaneId) : false;
602
+ if (background.length === 0) {
603
+ if (lastPaneIsLive) {
604
+ if (!session.lastPaneId) {
605
+ throw new Error(`Session ${session.sessionId} has live last pane state without a last pane id.`);
606
+ }
607
+ const pane = store.panes.get(session.lastPaneId);
608
+ if (pane) {
609
+ store.panes.set(pane.paneId, {
610
+ ...pane,
611
+ currentSessionId: null
612
+ });
613
+ }
614
+ }
615
+ store.sessions.delete(session.sessionId);
616
+ continue;
617
+ }
618
+ store.sessions.set(session.sessionId, {
619
+ ...session,
620
+ attachedPaneId: background.length > 0 ? null : session.lastPaneId,
621
+ background,
622
+ // Mirrors the `state` ternary immediately below: background.length is
623
+ // always > 0 here (the ===0 case already `continue`d above), so this
624
+ // always resolves to BACKGROUND_DERIVATION in practice — kept as a
625
+ // ternary anyway so the two never drift if that guard ever changes.
626
+ derivation: background.length > 0 ? BACKGROUND_DERIVATION : { confidence: "high", source: "screen-heuristic", value: "session_waiting" },
627
+ lastSeenAt: observedAt,
628
+ mainPid: undefined,
629
+ mainProcessStartTime: undefined,
630
+ state: background.length > 0 ? "session_stopped_background_running" : "session_waiting"
631
+ });
632
+ }
633
+ else if (!session.attachedPaneId && session.background.length === 0) {
634
+ const lastPaneIsLive = session.lastPaneId ? livePaneIds.has(session.lastPaneId) : false;
635
+ if (!lastPaneIsLive) {
636
+ store.sessions.delete(session.sessionId);
637
+ }
638
+ }
639
+ }
640
+ // Prune dead panes that no surviving session still references. A pane absent
641
+ // from this observation is kept only while a backgrounded session points at it
642
+ // (attachedPaneId/lastPaneId) — once that session is pruned the pane entry is
643
+ // pure leak. Without this, store.panes grows one entry per closed window for the
644
+ // host's whole lifetime (sessions are pruned; panes were not).
645
+ const referencedPaneIds = new Set();
646
+ for (const session of store.sessions.values()) {
647
+ if (session.attachedPaneId)
648
+ referencedPaneIds.add(session.attachedPaneId);
649
+ if (session.lastPaneId)
650
+ referencedPaneIds.add(session.lastPaneId);
651
+ }
652
+ for (const paneId of store.panes.keys()) {
653
+ if (!livePaneIds.has(paneId) && !referencedPaneIds.has(paneId)) {
654
+ store.panes.delete(paneId);
655
+ }
656
+ }
657
+ return {
658
+ observedAt,
659
+ panes: Array.from(store.panes.values()).sort((a, b) => a.paneId.localeCompare(b.paneId)),
660
+ sessions: Array.from(store.sessions.values()).sort((a, b) => a.sessionId.localeCompare(b.sessionId))
661
+ };
662
+ }
663
+ function paneProcesses(rootPid, rows) {
664
+ const root = rows.get(rootPid);
665
+ return [
666
+ ...(root === undefined ? [] : [root]),
667
+ ...collectDescendants(rootPid, rows).map((node) => node.row)
668
+ ]
669
+ .filter((row) => !isInfrastructureProcess(row) && !isDefunctProcess(row))
670
+ .map((row) => {
671
+ const processStartTime = currentProcessStartTime(row.pid);
672
+ return {
673
+ args: row.args,
674
+ command: row.command,
675
+ ...(row.listeners === undefined ? {} : { listeners: row.listeners }),
676
+ ...(row.pgid === undefined ? {} : { pgid: row.pgid }),
677
+ pid: row.pid,
678
+ ppid: row.ppid,
679
+ ...(processStartTime === undefined ? {} : { processStartTime })
680
+ };
681
+ });
682
+ }
683
+ function detectCurrentSession(pane, rows, nowMs) {
684
+ const root = rows.get(pane.rootPid);
685
+ const observed = [
686
+ ...(root ? [{ depth: 0, row: root }] : []),
687
+ ...collectDescendants(pane.rootPid, rows)
688
+ ]
689
+ .filter((node) => !isInfrastructureProcess(node.row) && !isDefunctProcess(node.row));
690
+ const agent = observed
691
+ .filter((node) => agentIdForProcess(node.row) !== null)
692
+ .sort((a, b) => a.depth - b.depth || a.row.pid - b.row.pid)[0];
693
+ if (!agent)
694
+ return null;
695
+ const detected = agentIdForProcess(agent.row);
696
+ if (!detected)
697
+ return null;
698
+ // A harness-served kind (hermes) shows as a `supercode open supercode-live://…` process: the pane is
699
+ // the runtime's window, and the driver's stamp names the kind the attach serves. The stamp is
700
+ // that session's identity — the runtime id the engine minted — so the kind and the id are read
701
+ // from it, never from the attach process.
702
+ const harness = harnessServedKindForPane(detected, agent.row.args, pane.knownAgentSessionId) ?? detected;
703
+ // Explicit arg id (claude --session-id) wins; otherwise fall back to the id the
704
+ // driver stamped on the pane at launch (its prefix must match the detected
705
+ // harness, so a stale stamp from a different agent is never adopted).
706
+ const explicitAgentSessionId = explicitSessionIdForProcess(harness, agent.row.args)
707
+ ?? bareKnownSessionIdForHarness(harness, pane.knownAgentSessionId)
708
+ ?? undefined;
709
+ const background = observed
710
+ .filter((node) => node.depth > 0)
711
+ .filter((node) => node.row.pid !== agent.row.pid)
712
+ .filter((node) => agentIdForProcess(node.row) === null)
713
+ .map((node) => toBackgroundItem(node.row, "descendant"));
714
+ for (const lease of explicitBackgroundLeases(pane.backgroundLeaseDir, rows)) {
715
+ background.push(lease);
716
+ }
717
+ const sessionId = explicitAgentSessionId ? `${harness}:${explicitAgentSessionId}` : `${harness}:${agent.row.pid}`;
718
+ const signal = classifyAttentionSignal(pane.contents);
719
+ const derivation = deriveSessionDerivation(sessionId, classifyForegroundStatus(harness, pane.contents), background, nowMs);
720
+ const mainProcessStartTime = currentProcessStartTime(agent.row.pid);
721
+ return {
722
+ sessionId,
723
+ ...(explicitAgentSessionId ? { agentSessionId: explicitAgentSessionId } : {}),
724
+ ...(pane.controlCwd ? { controlCwd: pane.controlCwd } : {}),
725
+ derivation,
726
+ ...(pane.isControl ? { isControl: true } : {}),
727
+ mainPid: agent.row.pid,
728
+ ...(mainProcessStartTime ? { mainProcessStartTime } : {}),
729
+ harness,
730
+ state: derivation.value,
731
+ ...(signal ? { signal } : {}),
732
+ background
733
+ };
734
+ }
735
+ // Argument enrichment is deliberately best-effort: a saturated host can make
736
+ // the bounded `ps ... args=` read time out while the cheap process-tree pass
737
+ // still proves the same agent PID is alive. In that one degraded observation,
738
+ // detectCurrentSession has only a synthetic `harness:<pid>` identity. Do not
739
+ // replace an exact session id we already observed for that SAME harness and
740
+ // PID; doing so makes every session-keyed consumer believe the live executor
741
+ // disappeared. State/attention still come from this tick's fresh evidence —
742
+ // only identity is carried across the temporary argv gap. A different PID is a
743
+ // different process and is never inherited, so a later manually-started agent
744
+ // cannot take over the prior conversation's identity.
745
+ function preserveSessionIdentityAcrossArgGap(detected, existingPane, store) {
746
+ if (!detected || detected.agentSessionId || !existingPane?.currentSessionId)
747
+ return detected;
748
+ const previous = store.sessions.get(existingPane.currentSessionId);
749
+ if (!previous?.agentSessionId || previous.mainPid !== detected.mainPid || previous.harness !== detected.harness)
750
+ return detected;
751
+ return {
752
+ ...detected,
753
+ agentSessionId: previous.agentSessionId,
754
+ sessionId: previous.sessionId
755
+ };
756
+ }
757
+ function sameMainProcessAlive(session) {
758
+ if (session.mainPid === undefined || !pidAlive(session.mainPid))
759
+ return false;
760
+ if (session.mainProcessStartTime === undefined)
761
+ return true;
762
+ const observedStartTime = activeIo
763
+ ? activeIo.startTimes.get(session.mainPid)
764
+ : currentProcessStartTime(session.mainPid);
765
+ return observedStartTime === undefined || observedStartTime === session.mainProcessStartTime;
766
+ }
767
+ // A claude /clear rolls the live process to a NEW session id, but the argv pin
768
+ // (`--session-id`/`--resume`) detectCurrentSession reads never changes — so
769
+ // without correction the pane reports the launch-time id forever while the
770
+ // transcript being written belongs to the successor. Follow the gathered roll
771
+ // chain (io.sessionRolls, evidence built in agent-session-roll) to the session
772
+ // the pane is actually writing. A successor already claimed by another pane is
773
+ // never adopted: a session that is live elsewhere must not be stolen by a
774
+ // mis-attributed roll.
775
+ function resolveSessionRoll(detected, paneId, store, currentClaims) {
776
+ const rolls = activeIo?.sessionRolls;
777
+ if (!detected || !rolls || rolls.size === 0)
778
+ return { rolledFrom: null, session: detected };
779
+ const seen = new Set([detected.sessionId]);
780
+ let sessionId = detected.sessionId;
781
+ for (;;) {
782
+ const next = rolls.get(sessionId);
783
+ if (!next || seen.has(next)
784
+ || [...currentClaims].some(([owner, id]) => owner !== paneId && id === next)
785
+ || claimedByAnotherPane(next, paneId, store))
786
+ break;
787
+ seen.add(next);
788
+ sessionId = next;
789
+ }
790
+ if (sessionId === detected.sessionId)
791
+ return { rolledFrom: null, session: detected };
792
+ return {
793
+ rolledFrom: detected.sessionId,
794
+ session: {
795
+ ...detected,
796
+ agentSessionId: sessionId.slice(sessionId.indexOf(":") + 1),
797
+ sessionId
798
+ }
799
+ };
800
+ }
801
+ function claimedByAnotherPane(sessionId, paneId, store) {
802
+ for (const pane of store.panes.values()) {
803
+ if (pane.paneId !== paneId && pane.currentSessionId === sessionId)
804
+ return true;
805
+ }
806
+ const session = store.sessions.get(sessionId);
807
+ return Boolean(session?.attachedPaneId && session.attachedPaneId !== paneId);
808
+ }
809
+ // Conservative pane read for the finer attention signal. High-precision: a numbered
810
+ // choice/permission selector on screen ⇒ "asking" (blocked on your answer); a strong
811
+ // crash/API-error marker ⇒ "errored". Anything ambiguous returns undefined so it
812
+ // degrades to the running/waiting state — a false positive would misroute attention.
813
+ export function classifyAttentionSignal(contents) {
814
+ if (!contents)
815
+ return undefined;
816
+ const lines = visibleLines(contents.replace(/\u001b\[[0-9?;]*[ -/]*[@-~]/g, "").replace(/\r/g, ""));
817
+ // errored: only an *agent-level* API error (claude/codex/gemini show "API Error:
818
+ // NNN" when their own call fails). Deliberately NOT panic:/fatal:/Traceback — those
819
+ // are tool output (git, a failing test), not the agent crashing; a real agent crash
820
+ // already drops out of the lifecycle (-> ended).
821
+ if (lines.some((line) => /\bAPI Error:\s*\d{3}\b/i.test(line))) {
822
+ return "errored";
823
+ }
824
+ // A selector (❯ or ›) on a numbered option is a choice/permission prompt — distinct
825
+ // from the plain "❯" idle input line (no digit follows).
826
+ if (lines.some((line) => /^[❯›]\s*\d+[.)]\s/.test(line))) {
827
+ return "asking";
828
+ }
829
+ return undefined;
830
+ }
831
+ // The kinds supercode's harness protocol serves: the pane runs `supercode open supercode-live://…` for
832
+ // them and the driver stamps `<kind>:<runtime id>` on it.
833
+ const HARNESS_SERVED_KINDS = ["hermes"];
834
+ function harnessServedKindForPane(detected, args, knownAgentSessionId) {
835
+ if (detected !== "supercode" || !/\bopen\s+supercode-live:\/\//.test(args) || !knownAgentSessionId)
836
+ return null;
837
+ const kind = HARNESS_SERVED_KINDS.find((candidate) => knownAgentSessionId.startsWith(`${candidate}:`));
838
+ return kind ?? null;
839
+ }
840
+ function explicitSessionIdForProcess(harness, args) {
841
+ // claude carries --session-id (create) or --resume (reattach); gemini carries
842
+ // --session-id only (buildAgentStartupCommand: gemini has no resume-by-uuid, see
843
+ // agent-launch.ts). codex has no session-id flag on a FRESH launch (it mints its
844
+ // own id), but a RESUME launch carries the session id as a bare positional after
845
+ // the `resume` subcommand token (`codex resume <uuid>`, not a flag) — extracted by
846
+ // codexResumeSessionId below. supercode takes no session-id flag at all (its id is
847
+ // driver-stamped on the pane instead) and correctly falls through to null here,
848
+ // recovered by bareKnownSessionIdForHarness or the synthetic pid-keyed id.
849
+ if (harness === "claude")
850
+ return valueAfterFlag(args, "--session-id") ?? valueAfterFlag(args, "--resume");
851
+ if (harness === "gemini")
852
+ return valueAfterFlag(args, "--session-id");
853
+ if (harness === "grok")
854
+ return valueAfterFlag(args, "--session-id") ?? valueAfterFlag(args, "--resume");
855
+ if (harness === "codex")
856
+ return codexResumeSessionId(args);
857
+ return null;
858
+ }
859
+ // codex flags that take a value as the FOLLOWING token (not `--flag=value`) —
860
+ // the tokenizer below must skip both the flag AND its value token so it never
861
+ // mistakes a flag's value for the `resume` subcommand or the id positional.
862
+ // Keep in sync with every flag defaultAgentCommand's codex branch can emit
863
+ // ahead of the subcommand (agent-launch.ts): `-c` (trust-table config) and
864
+ // `--model`. `--dangerously-bypass-approvals-and-sandbox` /
865
+ // `--dangerously-bypass-hook-trust` take no value.
866
+ const CODEX_VALUE_FLAGS = new Set(["-c", "--model"]);
867
+ // Split a shell-ish argv string into tokens, respecting single/double quotes
868
+ // (so a `-c` value containing spaces — shellQuote wraps it in single quotes —
869
+ // stays one token instead of fragmenting). Quote markers are stripped from
870
+ // the returned token.
871
+ function tokenizeArgs(args) {
872
+ const tokens = [];
873
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
874
+ let match;
875
+ while ((match = pattern.exec(args)) !== null) {
876
+ tokens.push(match[1] ?? match[2] ?? match[3] ?? "");
877
+ }
878
+ return tokens;
879
+ }
880
+ // The UUID positional codex's `resume` subcommand takes (`codex resume <uuid>`,
881
+ // possibly followed by more flags/prompt) — never a `--flag value` pair, so
882
+ // valueAfterFlag doesn't apply. Anchored to the SUBCOMMAND position (not a bare
883
+ // substring search) so a FRESH launch's prompt can never be mistaken for a
884
+ // resume: prompt text is a trailing positional after the agent binary and its
885
+ // flags, so if it happens to contain the words "resume <uuid>" a naive
886
+ // anywhere-in-argv search would misattribute the pane to that uuid (a hostile
887
+ // or coincidental prompt hijack). Walk tokens past the codex binary, skipping
888
+ // flag tokens and their values; the FIRST remaining token must be exactly
889
+ // `resume`, and the NEXT token must pass the UUID check — anything else
890
+ // (including `resume --last` or `resume <name>`, codex's picker/session-name
891
+ // forms) returns null. If a `-c` value contains spaces, `ps` flattens argv and
892
+ // this tokenizer can false-negative on the value-flag skip (rare, and the
893
+ // codex resume launch stamps the id directly via noteAgentSessionId as a
894
+ // belt-and-suspenders fallback at launch).
895
+ //
896
+ // KNOWN-ACCEPTED RESIDUAL (owner decision, not chased further): a FRESH
897
+ // (non-resume) codex launch whose PROMPT is *itself* exactly `resume <uuid>
898
+ // ...` — i.e. the prompt's first word is literally "resume" immediately
899
+ // followed by a valid uuid, with nothing else between the flags and it — is
900
+ // STILL misread as a genuine `codex resume <uuid>` invocation and adopted as
901
+ // that session. This is irreducible at the `ps -o args` level: `ps` reports
902
+ // only the flattened argv string, with no marker for where codex's own
903
+ // subcommand parsing would have drawn the boundary between "subcommand
904
+ // positional" and "prompt positional" — from this tokenizer's vantage point
905
+ // `codex resume <uuid> and finish the migration` (a genuine resume with a
906
+ // trailing prompt) and `codex <prompt starting with the literal text "resume
907
+ // <uuid> ...">` are the exact same string shape. The non-initial hijack case
908
+ // (prompt text starting with anything OTHER than the bare "resume <uuid>"
909
+ // sequence, e.g. "please resume <uuid> …") IS caught — see the F2 test below —
910
+ // because the first token after the binary+flags fails the `=== "resume"`
911
+ // check. Only the prompt-INITIAL form survives. The managed-launch belt-stamp
912
+ // (noteAgentSessionId) cannot help here either: it is written by the engine
913
+ // only for a launch the engine itself knows is a resume (options.resume ===
914
+ // true), and a fresh launch — by definition — carries no such stamp to fall
915
+ // back on. Fixing this for real needs a non-flattened, argv-boundary-aware
916
+ // read of the process (e.g. /proc/<pid>/cmdline's NUL-separated argv on
917
+ // Linux, or an equivalent native read on macOS) — out of scope here; `ps`
918
+ // gives us none of that. See the "known-accepted residual" test below, which
919
+ // encodes today's actual (adopted) behavior so a future fix flips it
920
+ // deliberately rather than by accident.
921
+ function codexResumeSessionId(args) {
922
+ const tokens = tokenizeArgs(args);
923
+ const binaryIndex = tokens.findIndex((token) => /(?:^|\/)codex$/.test(token));
924
+ if (binaryIndex < 0)
925
+ return null;
926
+ let index = binaryIndex + 1;
927
+ for (let token = tokens[index]; token !== undefined && token.startsWith("-"); token = tokens[index]) {
928
+ if (CODEX_VALUE_FLAGS.has(token)) {
929
+ index += 1;
930
+ }
931
+ index += 1;
932
+ }
933
+ if (tokens[index] !== "resume")
934
+ return null;
935
+ const candidate = tokens[index + 1];
936
+ return candidate && isUuid(candidate) ? candidate : null;
937
+ }
938
+ // The bare id from a driver-stamped pane hint, but only if its prefix matches the
939
+ // harness actually detected on the pane (e.g. "supercode:<uuid>" for a supercode
940
+ // process). Guards against adopting a stale stamp left by a prior, different agent.
941
+ function bareKnownSessionIdForHarness(harness, knownAgentSessionId) {
942
+ if (!knownAgentSessionId)
943
+ return null;
944
+ const prefix = `${harness}:`;
945
+ return knownAgentSessionId.startsWith(prefix) ? knownAgentSessionId.slice(prefix.length) : null;
946
+ }
947
+ function valueAfterFlag(args, flag) {
948
+ const pattern = new RegExp(`${escapeRegExp(flag)}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|(\\S+))`);
949
+ const match = args.match(pattern);
950
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
951
+ }
952
+ function escapeRegExp(value) {
953
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
954
+ }
955
+ function detachPreviousSession(options) {
956
+ const session = options.store.sessions.get(options.sessionId);
957
+ if (!session)
958
+ return;
959
+ // A current-batch newcomer may already have claimed this session earlier in
960
+ // the iteration. Detaching our stale inferred identity must not delete it.
961
+ if (session.attachedPaneId && session.attachedPaneId !== options.paneId)
962
+ return;
963
+ const background = refreshBackground(session.background, options.processRows, options.backgroundLeaseDir ?? session.backgroundLeaseDir);
964
+ if (background.length > 0) {
965
+ options.store.sessions.set(options.sessionId, {
966
+ ...session,
967
+ attachedPaneId: null,
968
+ background,
969
+ backgroundLeaseDir: options.backgroundLeaseDir ?? session.backgroundLeaseDir,
970
+ derivation: BACKGROUND_DERIVATION,
971
+ lastPaneId: options.paneId,
972
+ lastSeenAt: options.observedAt,
973
+ mainPid: undefined,
974
+ mainProcessStartTime: undefined,
975
+ state: "session_stopped_background_running"
976
+ });
977
+ return;
978
+ }
979
+ options.store.sessions.delete(options.sessionId);
980
+ }
981
+ // Background work is process-tree evidence (rung 4, always-available) and
982
+ // outranks a screen-scraping miss (rung 5, least trustworthy): a foreground
983
+ // screen we can't read is still known to be doing something if a leased/
984
+ // descendant process is alive, so background wins over "unrecognized" — but
985
+ // never over an affirmatively-read "running" screen.
986
+ //
987
+ // One function producing BOTH `state` and its provenance together (rather
988
+ // than a sibling function mirroring the same branches) so the two can never
989
+ // drift apart — exactly the "silently resolved by branch order" failure mode
990
+ // §I.1 warns about, just one layer up. Screen-matched states (running,
991
+ // idle-prompt) are "high" confidence — a matched, specific pattern, not a
992
+ // guess; the unmatched fall-through is the one "low"-confidence case (the
993
+ // honest floor); background is process-tree evidence, directly observed via
994
+ // `ps`/leases, so "high".
995
+ //
996
+ // The cooperative rung (A6, §I.1) is checked FIRST, unconditionally: it is the
997
+ // new top of the ladder, so a fresh signal wins even over an affirmatively-
998
+ // read screen or live background work — the hook reports the actual agent
999
+ // process's own turn boundary, which is strictly more direct evidence than
1000
+ // anything read or inferred from the outside. A missing or stale (past
1001
+ // COOPERATIVE_SIGNAL_TTL_MS) entry is not a distinct case: it simply falls
1002
+ // through to the rungs below, unchanged from before this rung existed.
1003
+ function deriveSessionDerivation(sessionId, foreground, background, nowMs) {
1004
+ const cooperative = activeIo?.cooperativeSignals?.get(sessionId);
1005
+ if (cooperative && nowMs - cooperative.atMs <= COOPERATIVE_SIGNAL_TTL_MS) {
1006
+ return { confidence: "high", source: "cooperative", value: cooperative.event === "turn-start" ? "session_running" : "session_waiting" };
1007
+ }
1008
+ if (foreground === "running")
1009
+ return { confidence: "high", source: "screen-heuristic", value: "session_running" };
1010
+ if (background.length > 0)
1011
+ return BACKGROUND_DERIVATION;
1012
+ if (foreground === "unrecognized")
1013
+ return { confidence: "low", source: "screen-heuristic", value: "session_unrecognized" };
1014
+ return { confidence: "high", source: "screen-heuristic", value: "session_waiting" };
1015
+ }
1016
+ // Shared by every "this session's foreground stopped but a background/leased
1017
+ // process is still alive" transition below — one named constant instead of
1018
+ // repeating the literal at each of those sites.
1019
+ const BACKGROUND_DERIVATION = { confidence: "high", source: "process-tree", value: "session_stopped_background_running" };
1020
+ // The tag every derivation-degraded transition logs under — one named literal
1021
+ // a host can grep verbatim, never re-typed at a second call site.
1022
+ export const DERIVATION_DEGRADED_LOG_TAG = "[substrate:lifecycle.derivation-degraded]";
1023
+ // Degradation transitions are observable events (§I.1): when a screen
1024
+ // pattern that used to match a session stops matching (or any other rung's
1025
+ // confidence otherwise drops), fire the tagged log line and bump the store's
1026
+ // cumulative counter — ONCE, on the drop edge, never on every tick the session
1027
+ // stays degraded. Compared against the session's OWN previously stored
1028
+ // derivation (not a side map — the stored session already holds the previous
1029
+ // value, so nothing else has to remember it) — a session that recovers to high
1030
+ // confidence and
1031
+ // later drops again re-fires exactly once for that NEW drop, because the
1032
+ // store already holds the fact "the last confidence we saw was high" the
1033
+ // moment before this call overwrites it.
1034
+ function recordDerivationDegradation(store, previous, current) {
1035
+ if (previous?.derivation.confidence !== "high" || current.derivation.confidence !== "low")
1036
+ return;
1037
+ store.derivationUnknownCount += 1;
1038
+ console.error(`${DERIVATION_DEGRADED_LOG_TAG} ${current.sessionId} derivation confidence dropped from high to low (source=${current.derivation.source}, value=${current.derivation.value})`);
1039
+ }
1040
+ function classifyForegroundStatus(harness, contents) {
1041
+ if (!contents)
1042
+ return "unrecognized";
1043
+ const normalized = contents
1044
+ .replace(/\u001b\[[0-9?;]*[ -/]*[@-~]/g, "")
1045
+ .replace(/\r/g, "");
1046
+ if (harness === "claude") {
1047
+ if (isClaudeRunning(normalized)) {
1048
+ return "running";
1049
+ }
1050
+ if (isClaudeIdle(normalized)) {
1051
+ return "prompt";
1052
+ }
1053
+ }
1054
+ if (harness === "codex" && isCodexIdle(normalized)) {
1055
+ return "prompt";
1056
+ }
1057
+ if (harness === "gemini") {
1058
+ if (isGeminiRunning(normalized)) {
1059
+ return "running";
1060
+ }
1061
+ if (isGeminiIdle(normalized)) {
1062
+ return "prompt";
1063
+ }
1064
+ }
1065
+ return "unrecognized";
1066
+ }
1067
+ function isClaudeIdle(contents) {
1068
+ const lines = visibleLines(contents);
1069
+ return lines.some((line) => /^❯(?:\s|$)/.test(line));
1070
+ }
1071
+ function isClaudeRunning(contents) {
1072
+ const lines = visibleLines(contents);
1073
+ return lines.some((line) => /^●\s+Running\s+\d+\s+shell command/.test(line))
1074
+ || lines.some((line) => /^✻\s+\S+.*\(\d+s\b/.test(line))
1075
+ || lines.some((line) => /^✻\s+(?:Doodling|Thinking|Working|Baking|Cogitating|Herding|Musing|Pondering|Processing|Reading|Searching|Thinking)…/.test(line))
1076
+ || lines.some((line) => /\besc to interrupt\b/i.test(line));
1077
+ }
1078
+ function isCodexIdle(contents) {
1079
+ const lines = visibleLines(contents);
1080
+ return lines.some((line) => /^›\s*(?:$|\/|\w|\S)/.test(line))
1081
+ || lines.some((line) => /\bgpt-[\w.-]+.*·\s+~\//.test(line))
1082
+ || lines.some((line) => /\bOpenAI Codex\b/.test(line) && !lines.some((candidate) => /\bRunning\b|\bThinking\b/i.test(candidate)));
1083
+ }
1084
+ // Derived from real `gemini` v0.49.0 captures (test/fixtures/gemini-screens/,
1085
+ // tmux `capture-pane -e` — the same preserveEscapes:true path the real driver
1086
+ // uses), never invented from memory (§I.1's whole point: a plausible-but-wrong
1087
+ // pattern is worse than no pattern). Gemini's idle input caret is a bare ASCII
1088
+ // `>` — distinct from claude's `❯` and codex's `›`, so there is no cross-harness
1089
+ // collision.
1090
+ function isGeminiIdle(contents) {
1091
+ const lines = visibleLines(contents);
1092
+ return lines.some((line) => /^>(?:\s|$)/.test(line));
1093
+ }
1094
+ // Gemini's busy indicator is a braille spinner glyph (observed frames ⠦ ⠧ ⠏ ⠙ ⠴
1095
+ // ⠼, all in the Braille Patterns block U+2800-U+28FF) followed by
1096
+ // "Thinking... (esc to cancel, Ns)". Checked BEFORE isGeminiIdle in
1097
+ // classifyForegroundStatus: a running screen still shows the just-submitted
1098
+ // message on an idle-shaped `>` line above the spinner AND a fresh empty `>`
1099
+ // input box below it, so idle-pattern lines coexist with the running one on a
1100
+ // real busy screen (both real-capture fixtures prove this) — running must win.
1101
+ function isGeminiRunning(contents) {
1102
+ const lines = visibleLines(contents);
1103
+ return lines.some((line) => /^[⠀-⣿]\s+Thinking\.\.\.\s*\(esc to cancel/.test(line));
1104
+ }
1105
+ function visibleLines(contents) {
1106
+ return contents
1107
+ .split("\n")
1108
+ .map((line) => line.trim())
1109
+ .filter(Boolean);
1110
+ }
1111
+ function refreshBackground(previous, rows, backgroundLeaseDir) {
1112
+ const active = new Map();
1113
+ for (const item of previous) {
1114
+ const row = rows.get(item.pid);
1115
+ if (row) {
1116
+ if (isDefunctProcess(row) || isInfrastructureProcess(row) || !processStartMatches(item.pid, item.processStartTime))
1117
+ continue;
1118
+ active.set(item.pid, {
1119
+ ...toBackgroundItem(row, "leased-descendant"),
1120
+ processStartTime: item.processStartTime ?? currentProcessStartTime(item.pid)
1121
+ });
1122
+ }
1123
+ else if (pidAlive(item.pid) && processStartMatches(item.pid, item.processStartTime)) {
1124
+ const { listeners: _listeners, ...withoutStaleListeners } = item;
1125
+ active.set(item.pid, {
1126
+ ...withoutStaleListeners,
1127
+ source: "leased-descendant"
1128
+ });
1129
+ }
1130
+ }
1131
+ for (const item of explicitBackgroundLeases(backgroundLeaseDir, rows)) {
1132
+ active.set(item.pid, item);
1133
+ }
1134
+ return Array.from(active.values()).sort((a, b) => a.pid - b.pid);
1135
+ }
1136
+ function explicitBackgroundLeases(backgroundLeaseDir, rows) {
1137
+ if (!backgroundLeaseDir)
1138
+ return [];
1139
+ if (activeIo) {
1140
+ return leaseItemsFromRaw(activeIo.leasesByDir.get(backgroundLeaseDir) ?? [], rows);
1141
+ }
1142
+ if (!existsSync(backgroundLeaseDir)) {
1143
+ throw new Error(`Background lease directory does not exist: ${backgroundLeaseDir}`);
1144
+ }
1145
+ const leases = [];
1146
+ for (const entry of readdirSync(backgroundLeaseDir, { withFileTypes: true })) {
1147
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
1148
+ continue;
1149
+ leases.push(JSON.parse(readFileSync(join(backgroundLeaseDir, entry.name), "utf8")));
1150
+ }
1151
+ return leaseItemsFromRaw(leases, rows);
1152
+ }
1153
+ function leaseItemsFromRaw(leases, rows) {
1154
+ const items = [];
1155
+ for (const lease of leases) {
1156
+ if (!Number.isInteger(lease.pid) || Number(lease.pid) < 1) {
1157
+ throw new Error(`Invalid background lease pid: ${JSON.stringify(lease.pid)}`);
1158
+ }
1159
+ if (typeof lease.processStartTime !== "string" || lease.processStartTime.length === 0) {
1160
+ throw new Error(`Invalid background lease processStartTime: ${JSON.stringify(lease.processStartTime)}`);
1161
+ }
1162
+ const pid = Number(lease.pid);
1163
+ if (!pidAlive(pid) || !processStartMatches(pid, lease.processStartTime))
1164
+ continue;
1165
+ const row = rows.get(pid);
1166
+ if (row && (isDefunctProcess(row) || isInfrastructureProcess(row)))
1167
+ continue;
1168
+ const base = row ? toBackgroundItem(row, "explicit-lease") : {
1169
+ pid,
1170
+ source: "explicit-lease"
1171
+ };
1172
+ items.push({
1173
+ ...base,
1174
+ ...(typeof lease.args === "string" ? { args: lease.args } : {}),
1175
+ ...(typeof lease.command === "string" ? { command: lease.command } : {}),
1176
+ ...(typeof lease.description === "string" ? { description: lease.description } : {}),
1177
+ processStartTime: lease.processStartTime
1178
+ });
1179
+ }
1180
+ return items;
1181
+ }
1182
+ function collectDescendants(rootPid, rows) {
1183
+ if (!rows.has(rootPid))
1184
+ return [];
1185
+ const childrenByParent = new Map();
1186
+ for (const row of rows.values()) {
1187
+ const children = childrenByParent.get(row.ppid);
1188
+ if (children)
1189
+ children.push(row);
1190
+ else
1191
+ childrenByParent.set(row.ppid, [row]);
1192
+ }
1193
+ const out = [];
1194
+ const queue = [{ depth: 0, pid: rootPid }];
1195
+ const seen = new Set();
1196
+ while (queue.length > 0) {
1197
+ const { depth, pid } = queue.shift();
1198
+ if (seen.has(pid))
1199
+ continue;
1200
+ seen.add(pid);
1201
+ const children = childrenByParent.get(pid) ?? [];
1202
+ for (const child of children) {
1203
+ out.push({ depth: depth + 1, row: child });
1204
+ queue.push({ depth: depth + 1, pid: child.pid });
1205
+ }
1206
+ }
1207
+ return out;
1208
+ }
1209
+ function toBackgroundItem(row, source) {
1210
+ return {
1211
+ ...(row.listeners !== undefined ? { listeners: row.listeners } : {}),
1212
+ pid: row.pid,
1213
+ ppid: row.ppid,
1214
+ ...(row.pgid !== undefined ? { pgid: row.pgid } : {}),
1215
+ args: row.args,
1216
+ command: row.command,
1217
+ processStartTime: currentProcessStartTime(row.pid),
1218
+ source
1219
+ };
1220
+ }
1221
+ function isInfrastructureProcess(row) {
1222
+ const command = basename(row.command);
1223
+ const args = row.args.trim();
1224
+ return command === "tmux"
1225
+ || command === "reattach-to-user-namespace"
1226
+ // Claude Code spawns `caffeinate -i -t 300` as a keep-awake helper and re-spawns
1227
+ // it on activity — it is never agent work. Counted as background, it pins a
1228
+ // finished/idle session to session_stopped_background_running (→ attention
1229
+ // "working") for up to its 300s lifetime, and indefinitely if anything pokes the
1230
+ // session before then, so the parked prompt never surfaces as awaiting_me.
1231
+ || command === "caffeinate"
1232
+ || (command === "sleep" && /^sleep\s+3600(?:\s|$)/.test(args))
1233
+ || args.includes("@playwright/mcp")
1234
+ || command === "playwright-mcp"
1235
+ || /(?:^|[/\s])playwright-mcp(?:\s|$)/.test(args);
1236
+ }
1237
+ function basename(command) {
1238
+ return command.split("/").at(-1) ?? command;
1239
+ }
1240
+ function isDefunctProcess(row) {
1241
+ return row.stat?.includes("Z") || row.command.startsWith("(") || row.args.startsWith("(");
1242
+ }
1243
+ function agentIdForProcess(row) {
1244
+ for (const agentId of KNOWN_AGENT_IDS) {
1245
+ const escaped = agentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1246
+ const re = new RegExp(`(?:^|[\\s/])${escaped}(?:\\.exe)?(?:\\s|$)`);
1247
+ if (re.test(row.command) || re.test(row.args)) {
1248
+ return agentId;
1249
+ }
1250
+ }
1251
+ return null;
1252
+ }
1253
+ function pidAlive(pid) {
1254
+ try {
1255
+ process.kill(pid, 0);
1256
+ return true;
1257
+ }
1258
+ catch {
1259
+ return false;
1260
+ }
1261
+ }
1262
+ function processStartMatches(pid, expected) {
1263
+ if (!expected)
1264
+ return true;
1265
+ return currentProcessStartTime(pid) === expected;
1266
+ }
1267
+ function currentProcessStartTime(pid) {
1268
+ // Pre-gathered (async) start-times when running the pure observe path.
1269
+ if (activeIo)
1270
+ return activeIo.startTimes.get(pid);
1271
+ try {
1272
+ const stdout = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
1273
+ encoding: "utf8",
1274
+ maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER,
1275
+ timeout: 1000
1276
+ }).trim();
1277
+ return stdout || undefined;
1278
+ }
1279
+ catch {
1280
+ return undefined;
1281
+ }
1282
+ }
1283
+ // Batched async twin of currentProcessStartTime: one ps call yields start-times
1284
+ // for every live pid, gathered off the event loop and fed in via ObserveIo.
1285
+ export async function gatherProcessStartTimes() {
1286
+ const startTimes = new Map();
1287
+ let stdout;
1288
+ try {
1289
+ stdout = await execFileAsync("ps", ["-axo", "pid=,lstart="], { maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 });
1290
+ }
1291
+ catch {
1292
+ return startTimes;
1293
+ }
1294
+ for (const line of stdout.split("\n")) {
1295
+ const match = line.trim().match(/^(\d+)\s+(.+)$/);
1296
+ if (!match?.[2])
1297
+ continue;
1298
+ startTimes.set(Number(match[1]), match[2].trim());
1299
+ }
1300
+ return startTimes;
1301
+ }
1302
+ // Async twin of the lease file reads, gathered off the event loop. Returns the
1303
+ // parsed lease files per directory; observeLifecycle applies the same liveness
1304
+ // rules over them as the sync path.
1305
+ export async function gatherBackgroundLeases(dirs) {
1306
+ const byDir = new Map();
1307
+ const unique = [...new Set(dirs.filter((dir) => Boolean(dir)))];
1308
+ await Promise.all(unique.map(async (dir) => {
1309
+ const leases = [];
1310
+ let entries;
1311
+ try {
1312
+ entries = await readdirAsync(dir, { withFileTypes: true });
1313
+ }
1314
+ catch {
1315
+ byDir.set(dir, leases);
1316
+ return;
1317
+ }
1318
+ await Promise.all(entries.map(async (entry) => {
1319
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
1320
+ return;
1321
+ try {
1322
+ leases.push(JSON.parse(await readFileAsync(join(dir, entry.name), "utf8")));
1323
+ }
1324
+ catch {
1325
+ // Skip unreadable/!corrupt lease files.
1326
+ }
1327
+ }));
1328
+ byDir.set(dir, leases);
1329
+ }));
1330
+ return byDir;
1331
+ }
1332
+ import { execFile, execFileSync } from "node:child_process";
1333
+ import { existsSync, readdirSync, readFileSync, readlinkSync } from "node:fs";
1334
+ import { readdir as readdirAsync, readFile as readFileAsync, readlink as readlinkAsync } from "node:fs/promises";
1335
+ import { join } from "node:path";
1336
+ import { promisify } from "node:util";
1337
+ function isUuid(value) {
1338
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value);
1339
+ }
1340
+ const execFileRaw = promisify(execFile);
1341
+ async function execFileAsync(command, args, options) {
1342
+ const { stdout } = await execFileRaw(command, args, { encoding: "utf8", ...options });
1343
+ return stdout;
1344
+ }