@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.
- package/CAPABILITIES.md +57 -0
- package/LICENSE-SUBSTRATE +201 -0
- package/NOTICE-SUBSTRATE +14 -0
- package/README.md +190 -0
- package/client.d.ts +17 -0
- package/client.mjs +32 -0
- package/index.d.ts +195 -0
- package/index.mjs +981 -0
- package/package.json +156 -0
- package/react/stream.d.ts +3 -0
- package/react/stream.mjs +446 -0
- package/react/ui.d.ts +136 -0
- package/react/ui.mjs +892 -0
- package/stream.d.ts +3 -0
- package/stream.mjs +446 -0
- package/styles.css +160 -0
- package/substrate/attach.d.ts +29 -0
- package/substrate/attach.js +147 -0
- package/substrate/client.d.ts +62 -0
- package/substrate/client.js +138 -0
- package/substrate/docker-exec.d.ts +41 -0
- package/substrate/docker-exec.js +101 -0
- package/substrate/internal/exec.d.ts +15 -0
- package/substrate/internal/exec.js +192 -0
- package/substrate/internal/process-tree.d.ts +1 -0
- package/substrate/internal/process-tree.js +41 -0
- package/substrate/local-terminal.d.ts +13 -0
- package/substrate/local-terminal.js +41 -0
- package/substrate/native-host.d.ts +57 -0
- package/substrate/native-host.js +529 -0
- package/substrate/session-lifecycle.d.ts +162 -0
- package/substrate/session-lifecycle.js +1344 -0
- package/substrate/session-roll.d.ts +14 -0
- package/substrate/session-roll.js +407 -0
- package/substrate/tmux-stream.d.ts +32 -0
- package/substrate/tmux-stream.js +212 -0
- package/substrate/tmux.d.ts +144 -0
- package/substrate/tmux.js +899 -0
- package/ui.d.ts +136 -0
- package/ui.mjs +892 -0
|
@@ -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
|
+
}
|