@termfleet/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-launch.d.ts +78 -0
- package/dist/agent-launch.js +247 -0
- package/dist/agent-session-id.d.ts +10 -0
- package/dist/agent-session-id.js +36 -0
- package/dist/agent-session-index-client.d.ts +7 -0
- package/dist/agent-session-index-client.js +86 -0
- package/dist/agent-session-index-worker.d.ts +1 -0
- package/dist/agent-session-index-worker.js +20 -0
- package/dist/agent-session-index.d.ts +34 -0
- package/dist/agent-session-index.js +527 -0
- package/dist/agent-session-tail.d.ts +33 -0
- package/dist/agent-session-tail.js +184 -0
- package/dist/agent-session-watcher.d.ts +36 -0
- package/dist/agent-session-watcher.js +194 -0
- package/dist/agent-session.d.ts +380 -0
- package/dist/agent-session.js +1688 -0
- package/dist/background-runner.d.ts +3 -0
- package/dist/background-runner.js +55 -0
- package/dist/boot-queue.d.ts +35 -0
- package/dist/boot-queue.js +66 -0
- package/dist/build-info.d.ts +5 -0
- package/dist/build-info.js +38 -0
- package/dist/collab/canvas-doc.d.ts +47 -0
- package/dist/collab/canvas-doc.js +83 -0
- package/dist/contracts/auth.d.ts +77 -0
- package/dist/contracts/auth.js +1 -0
- package/dist/contracts/canvas.d.ts +34 -0
- package/dist/contracts/canvas.js +76 -0
- package/dist/contracts/console-layout.d.ts +39 -0
- package/dist/contracts/console-layout.js +135 -0
- package/dist/contracts/files.d.ts +38 -0
- package/dist/contracts/files.js +37 -0
- package/dist/contracts/provider-url.d.ts +3 -0
- package/dist/contracts/provider-url.js +49 -0
- package/dist/contracts/registry.d.ts +58 -0
- package/dist/contracts/registry.js +285 -0
- package/dist/launch-trace.d.ts +6 -0
- package/dist/launch-trace.js +33 -0
- package/dist/lib/errors.d.ts +1 -0
- package/dist/lib/errors.js +5 -0
- package/dist/lib/exec.d.ts +13 -0
- package/dist/lib/exec.js +134 -0
- package/dist/local-providers.d.ts +32 -0
- package/dist/local-providers.js +184 -0
- package/dist/local-tunnel.d.ts +6 -0
- package/dist/local-tunnel.js +258 -0
- package/dist/provider-access-token.d.ts +11 -0
- package/dist/provider-access-token.js +77 -0
- package/dist/provider-client.d.ts +152 -0
- package/dist/provider-client.js +666 -0
- package/dist/provider-url-resolver.d.ts +16 -0
- package/dist/provider-url-resolver.js +37 -0
- package/dist/registry-client.d.ts +93 -0
- package/dist/registry-client.js +170 -0
- package/dist/registry.d.ts +56 -0
- package/dist/registry.js +406 -0
- package/dist/session-attention.d.ts +24 -0
- package/dist/session-attention.js +54 -0
- package/dist/session-lifecycle.d.ts +83 -0
- package/dist/session-lifecycle.js +658 -0
- package/dist/session-window.d.ts +3 -0
- package/dist/session-window.js +20 -0
- package/dist/terminal-client.d.ts +49 -0
- package/dist/terminal-client.js +89 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.js +21 -0
- package/package.json +26 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
const KNOWN_AGENT_IDS = ["claude", "codex"];
|
|
2
|
+
const PROCESS_SNAPSHOT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
3
|
+
// Async twin of snapshotProcessTree used by the busy virtual-tmux observe loop
|
|
4
|
+
// so the ps calls never block the event loop. The sync version stays for the
|
|
5
|
+
// low-window iTerm/WezTerm drivers, which are not loop-saturating.
|
|
6
|
+
export async function snapshotProcessTreeAsync(rootPids = []) {
|
|
7
|
+
const rows = parseProcessRows(await execFileAsync("ps", ["-axo", "pid=,ppid=,pgid=,stat=,ucomm="], { maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 }));
|
|
8
|
+
await enrichProcessArgsAsync(rows, collectRelevantProcessIds(rows, rootPids));
|
|
9
|
+
return rows;
|
|
10
|
+
}
|
|
11
|
+
export function snapshotProcessTree(rootPids = []) {
|
|
12
|
+
const stdout = execFileSync("ps", ["-axo", "pid=,ppid=,pgid=,stat=,ucomm="], { encoding: "utf8", maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 });
|
|
13
|
+
const rows = parseProcessRows(stdout);
|
|
14
|
+
enrichProcessArgs(rows, collectRelevantProcessIds(rows, rootPids));
|
|
15
|
+
return rows;
|
|
16
|
+
}
|
|
17
|
+
// ONLY the pids of rootPids and everything descended from them — never the whole
|
|
18
|
+
// table. This is the sole safe input to a SIGKILL sweep: snapshotProcessTree
|
|
19
|
+
// returns every process on the machine (the lifecycle observe loop wants that),
|
|
20
|
+
// so SIGKILLing its keys would kill every process the user owns — terminals, the
|
|
21
|
+
// tmux server, this console. A kill must walk DOWN from the pane pids only; the
|
|
22
|
+
// tmux server is a parent, never a descendant, so it is correctly never returned.
|
|
23
|
+
export function snapshotDescendantPids(rootPids) {
|
|
24
|
+
if (rootPids.length === 0) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const stdout = execFileSync("ps", ["-axo", "pid=,ppid=,pgid=,stat=,ucomm="], { encoding: "utf8", maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 });
|
|
28
|
+
return collectRelevantProcessIds(parseProcessRows(stdout), rootPids);
|
|
29
|
+
}
|
|
30
|
+
function parseProcessRows(stdout) {
|
|
31
|
+
const rows = new Map();
|
|
32
|
+
for (const line of stdout.split("\n")) {
|
|
33
|
+
const trimmed = line.trim();
|
|
34
|
+
if (!trimmed)
|
|
35
|
+
continue;
|
|
36
|
+
// `ps -axo` covers EVERY process on the host, including transient/kernel rows
|
|
37
|
+
// with an empty command. One unexpected row must not abort the whole observe
|
|
38
|
+
// cycle (that would flap the machine to degraded for an unrelated process) —
|
|
39
|
+
// skip rows we can't parse rather than throwing.
|
|
40
|
+
const match = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/);
|
|
41
|
+
if (!match)
|
|
42
|
+
continue;
|
|
43
|
+
const [, pidRaw, ppidRaw, pgidRaw, stat, command] = match;
|
|
44
|
+
const pid = Number(pidRaw);
|
|
45
|
+
const ppid = Number(ppidRaw);
|
|
46
|
+
const pgid = Number(pgidRaw);
|
|
47
|
+
if (!Number.isInteger(pid) || pid < 1 || !Number.isInteger(ppid) || ppid < 0 || !Number.isInteger(pgid)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
rows.set(pid, {
|
|
51
|
+
args: command ?? "",
|
|
52
|
+
command: command ?? "",
|
|
53
|
+
pgid,
|
|
54
|
+
pid,
|
|
55
|
+
ppid,
|
|
56
|
+
stat
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return rows;
|
|
60
|
+
}
|
|
61
|
+
function collectRelevantProcessIds(rows, rootPids) {
|
|
62
|
+
const relevant = new Set();
|
|
63
|
+
const childrenByParent = new Map();
|
|
64
|
+
for (const row of rows.values()) {
|
|
65
|
+
const children = childrenByParent.get(row.ppid) ?? [];
|
|
66
|
+
children.push(row);
|
|
67
|
+
childrenByParent.set(row.ppid, children);
|
|
68
|
+
}
|
|
69
|
+
const queue = rootPids.filter((pid) => rows.has(pid));
|
|
70
|
+
while (queue.length > 0) {
|
|
71
|
+
const pid = queue.shift();
|
|
72
|
+
if (pid === undefined || relevant.has(pid))
|
|
73
|
+
continue;
|
|
74
|
+
relevant.add(pid);
|
|
75
|
+
for (const child of childrenByParent.get(pid) ?? []) {
|
|
76
|
+
queue.push(child.pid);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return Array.from(relevant).sort((a, b) => a - b);
|
|
80
|
+
}
|
|
81
|
+
function processIdChunks(pids) {
|
|
82
|
+
const chunkSize = 64;
|
|
83
|
+
const chunks = [];
|
|
84
|
+
for (let index = 0; index < pids.length; index += chunkSize) {
|
|
85
|
+
const chunk = pids.slice(index, index + chunkSize);
|
|
86
|
+
if (chunk.length > 0)
|
|
87
|
+
chunks.push(chunk);
|
|
88
|
+
}
|
|
89
|
+
return chunks;
|
|
90
|
+
}
|
|
91
|
+
function applyEnrichedArgs(rows, stdout) {
|
|
92
|
+
for (const line of stdout.split("\n")) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed)
|
|
95
|
+
continue;
|
|
96
|
+
const match = trimmed.match(/^(\d+)\s*(.*)$/);
|
|
97
|
+
if (!match)
|
|
98
|
+
continue;
|
|
99
|
+
const [, pidRaw, argsRaw] = match;
|
|
100
|
+
const pid = Number(pidRaw);
|
|
101
|
+
const row = rows.get(pid);
|
|
102
|
+
if (!row)
|
|
103
|
+
continue;
|
|
104
|
+
row.args = argsRaw || row.command;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function enrichProcessArgs(rows, pids) {
|
|
108
|
+
for (const chunk of processIdChunks(pids)) {
|
|
109
|
+
// `ps -p` exits non-zero when none of the listed pids still exist — a pid can
|
|
110
|
+
// die in the gap between the snapshot and this enrichment. That is not a
|
|
111
|
+
// failure of the observe cycle: those rows simply keep their `command`.
|
|
112
|
+
try {
|
|
113
|
+
applyEnrichedArgs(rows, execFileSync("ps", ["-p", chunk.join(","), "-o", "pid=,args="], { encoding: "utf8", maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 }));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Best-effort enrichment; leave these rows with their `command` fallback.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function enrichProcessArgsAsync(rows, pids) {
|
|
121
|
+
// Independent chunks run concurrently rather than blocking in sequence. A chunk
|
|
122
|
+
// whose pids all exited between the snapshot and now makes `ps -p` exit
|
|
123
|
+
// non-zero; tolerate it per-chunk so one dead pid can't reject the whole pass.
|
|
124
|
+
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(() => "")));
|
|
125
|
+
for (const stdout of outputs)
|
|
126
|
+
applyEnrichedArgs(rows, stdout);
|
|
127
|
+
}
|
|
128
|
+
export function createLifecycleStore() {
|
|
129
|
+
return {
|
|
130
|
+
panes: new Map(),
|
|
131
|
+
sessions: new Map()
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// observeLifecycle runs synchronously start-to-finish (no awaits), so a module
|
|
135
|
+
// scoped context is never interleaved across calls.
|
|
136
|
+
let activeIo;
|
|
137
|
+
export function observeLifecycle(options) {
|
|
138
|
+
activeIo = options.io;
|
|
139
|
+
try {
|
|
140
|
+
return observeLifecycleInner(options);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
activeIo = undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function observeLifecycleInner(options) {
|
|
147
|
+
const store = options.store ?? createLifecycleStore();
|
|
148
|
+
const observedAt = (options.now ?? new Date()).toISOString();
|
|
149
|
+
const livePaneIds = new Set(options.panes.map((pane) => pane.paneId));
|
|
150
|
+
for (const pane of options.panes) {
|
|
151
|
+
const existingPane = store.panes.get(pane.paneId);
|
|
152
|
+
const sessionIds = existingPane?.sessionIds ? [...existingPane.sessionIds] : [];
|
|
153
|
+
const currentSession = detectCurrentSession(pane, options.processRows);
|
|
154
|
+
let currentSessionId = existingPane?.currentSessionId ?? null;
|
|
155
|
+
if (currentSession) {
|
|
156
|
+
if (currentSessionId && currentSessionId !== currentSession.sessionId) {
|
|
157
|
+
detachPreviousSession({
|
|
158
|
+
backgroundLeaseDir: pane.backgroundLeaseDir,
|
|
159
|
+
observedAt,
|
|
160
|
+
paneId: pane.paneId,
|
|
161
|
+
processRows: options.processRows,
|
|
162
|
+
sessionId: currentSessionId,
|
|
163
|
+
store
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const previous = store.sessions.get(currentSession.sessionId);
|
|
167
|
+
currentSessionId = currentSession.sessionId;
|
|
168
|
+
if (!sessionIds.includes(currentSession.sessionId)) {
|
|
169
|
+
sessionIds.push(currentSession.sessionId);
|
|
170
|
+
}
|
|
171
|
+
store.sessions.set(currentSession.sessionId, {
|
|
172
|
+
sessionId: currentSession.sessionId,
|
|
173
|
+
...(currentSession.agentSessionId ? { agentSessionId: currentSession.agentSessionId } : {}),
|
|
174
|
+
attachedPaneId: pane.paneId,
|
|
175
|
+
background: currentSession.background,
|
|
176
|
+
backgroundLeaseDir: pane.backgroundLeaseDir,
|
|
177
|
+
firstSeenAt: previous?.firstSeenAt ?? observedAt,
|
|
178
|
+
lastPaneId: pane.paneId,
|
|
179
|
+
lastSeenAt: observedAt,
|
|
180
|
+
mainPid: currentSession.mainPid,
|
|
181
|
+
provider: currentSession.provider,
|
|
182
|
+
state: currentSession.state
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else if (currentSessionId) {
|
|
186
|
+
const session = store.sessions.get(currentSessionId);
|
|
187
|
+
if (session) {
|
|
188
|
+
const background = refreshBackground(session.background, options.processRows, pane.backgroundLeaseDir ?? session.backgroundLeaseDir);
|
|
189
|
+
const hasBackground = background.length > 0;
|
|
190
|
+
if (hasBackground) {
|
|
191
|
+
store.sessions.set(currentSessionId, {
|
|
192
|
+
...session,
|
|
193
|
+
attachedPaneId: null,
|
|
194
|
+
background,
|
|
195
|
+
backgroundLeaseDir: pane.backgroundLeaseDir ?? session.backgroundLeaseDir,
|
|
196
|
+
lastPaneId: pane.paneId,
|
|
197
|
+
lastSeenAt: observedAt,
|
|
198
|
+
mainPid: undefined,
|
|
199
|
+
state: "session_stopped_background_running"
|
|
200
|
+
});
|
|
201
|
+
currentSessionId = null;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
store.sessions.delete(currentSessionId);
|
|
205
|
+
currentSessionId = null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
store.panes.set(pane.paneId, {
|
|
210
|
+
currentSessionId,
|
|
211
|
+
paneId: pane.paneId,
|
|
212
|
+
rootPid: pane.rootPid,
|
|
213
|
+
sessionIds,
|
|
214
|
+
target: pane.target
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
for (const session of store.sessions.values()) {
|
|
218
|
+
if (session.attachedPaneId && !livePaneIds.has(session.attachedPaneId)) {
|
|
219
|
+
const background = refreshBackground(session.background, options.processRows, session.backgroundLeaseDir);
|
|
220
|
+
if (background.length > 0) {
|
|
221
|
+
store.sessions.set(session.sessionId, {
|
|
222
|
+
...session,
|
|
223
|
+
attachedPaneId: null,
|
|
224
|
+
background,
|
|
225
|
+
lastSeenAt: observedAt,
|
|
226
|
+
mainPid: undefined,
|
|
227
|
+
state: "session_stopped_background_running"
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
store.sessions.delete(session.sessionId);
|
|
232
|
+
}
|
|
233
|
+
const pane = store.panes.get(session.attachedPaneId);
|
|
234
|
+
if (pane) {
|
|
235
|
+
store.panes.set(session.attachedPaneId, {
|
|
236
|
+
...pane,
|
|
237
|
+
currentSessionId: null
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else if (!session.attachedPaneId && session.background.length > 0) {
|
|
242
|
+
const background = refreshBackground(session.background, options.processRows, session.backgroundLeaseDir);
|
|
243
|
+
const lastPaneIsLive = session.lastPaneId ? livePaneIds.has(session.lastPaneId) : false;
|
|
244
|
+
if (background.length === 0) {
|
|
245
|
+
if (lastPaneIsLive) {
|
|
246
|
+
if (!session.lastPaneId) {
|
|
247
|
+
throw new Error(`Session ${session.sessionId} has live last pane state without a last pane id.`);
|
|
248
|
+
}
|
|
249
|
+
const pane = store.panes.get(session.lastPaneId);
|
|
250
|
+
if (pane) {
|
|
251
|
+
store.panes.set(pane.paneId, {
|
|
252
|
+
...pane,
|
|
253
|
+
currentSessionId: null
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
store.sessions.delete(session.sessionId);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
store.sessions.set(session.sessionId, {
|
|
261
|
+
...session,
|
|
262
|
+
attachedPaneId: background.length > 0 ? null : session.lastPaneId,
|
|
263
|
+
background,
|
|
264
|
+
lastSeenAt: observedAt,
|
|
265
|
+
mainPid: undefined,
|
|
266
|
+
state: background.length > 0 ? "session_stopped_background_running" : "session_waiting"
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
else if (!session.attachedPaneId && session.background.length === 0) {
|
|
270
|
+
const lastPaneIsLive = session.lastPaneId ? livePaneIds.has(session.lastPaneId) : false;
|
|
271
|
+
if (!lastPaneIsLive) {
|
|
272
|
+
store.sessions.delete(session.sessionId);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Prune dead panes that no surviving session still references. A pane absent
|
|
277
|
+
// from this observation is kept only while a backgrounded session points at it
|
|
278
|
+
// (attachedPaneId/lastPaneId) — once that session is pruned the pane entry is
|
|
279
|
+
// pure leak. Without this, store.panes grows one entry per closed window for the
|
|
280
|
+
// provider's whole lifetime (sessions are pruned; panes were not).
|
|
281
|
+
const referencedPaneIds = new Set();
|
|
282
|
+
for (const session of store.sessions.values()) {
|
|
283
|
+
if (session.attachedPaneId)
|
|
284
|
+
referencedPaneIds.add(session.attachedPaneId);
|
|
285
|
+
if (session.lastPaneId)
|
|
286
|
+
referencedPaneIds.add(session.lastPaneId);
|
|
287
|
+
}
|
|
288
|
+
for (const paneId of store.panes.keys()) {
|
|
289
|
+
if (!livePaneIds.has(paneId) && !referencedPaneIds.has(paneId)) {
|
|
290
|
+
store.panes.delete(paneId);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
observedAt,
|
|
295
|
+
panes: Array.from(store.panes.values()).sort((a, b) => a.paneId.localeCompare(b.paneId)),
|
|
296
|
+
sessions: Array.from(store.sessions.values()).sort((a, b) => a.sessionId.localeCompare(b.sessionId))
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function detectCurrentSession(pane, rows) {
|
|
300
|
+
const root = rows.get(pane.rootPid);
|
|
301
|
+
const observed = [
|
|
302
|
+
...(root ? [{ depth: 0, row: root }] : []),
|
|
303
|
+
...collectDescendants(pane.rootPid, rows)
|
|
304
|
+
]
|
|
305
|
+
.filter((node) => !isInfrastructureProcess(node.row) && !isDefunctProcess(node.row));
|
|
306
|
+
const agent = observed
|
|
307
|
+
.filter((node) => agentIdForProcess(node.row) !== null)
|
|
308
|
+
.sort((a, b) => a.depth - b.depth || a.row.pid - b.row.pid)[0];
|
|
309
|
+
if (!agent)
|
|
310
|
+
return null;
|
|
311
|
+
const provider = agentIdForProcess(agent.row);
|
|
312
|
+
if (!provider)
|
|
313
|
+
return null;
|
|
314
|
+
const explicitAgentSessionId = explicitSessionIdForProcess(provider, agent.row.args) ?? undefined;
|
|
315
|
+
const background = observed
|
|
316
|
+
.filter((node) => node.depth > 0)
|
|
317
|
+
.filter((node) => node.row.pid !== agent.row.pid)
|
|
318
|
+
.filter((node) => agentIdForProcess(node.row) === null)
|
|
319
|
+
.map((node) => toBackgroundItem(node.row, "descendant"));
|
|
320
|
+
for (const lease of explicitBackgroundLeases(pane.backgroundLeaseDir, rows)) {
|
|
321
|
+
background.push(lease);
|
|
322
|
+
}
|
|
323
|
+
const signal = classifyAttentionSignal(pane.contents);
|
|
324
|
+
return {
|
|
325
|
+
sessionId: explicitAgentSessionId ? `${provider}:${explicitAgentSessionId}` : `${provider}:${agent.row.pid}`,
|
|
326
|
+
...(explicitAgentSessionId ? { agentSessionId: explicitAgentSessionId } : {}),
|
|
327
|
+
mainPid: agent.row.pid,
|
|
328
|
+
provider,
|
|
329
|
+
state: deriveSessionState(classifyForegroundStatus(provider, pane.contents), background),
|
|
330
|
+
...(signal ? { signal } : {}),
|
|
331
|
+
background
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
// Conservative pane read for the finer attention signal. High-precision: a numbered
|
|
335
|
+
// choice/permission selector on screen ⇒ "asking" (blocked on your answer); a strong
|
|
336
|
+
// crash/API-error marker ⇒ "errored". Anything ambiguous returns undefined so it
|
|
337
|
+
// degrades to the running/waiting state — a false positive would misroute attention.
|
|
338
|
+
export function classifyAttentionSignal(contents) {
|
|
339
|
+
if (!contents)
|
|
340
|
+
return undefined;
|
|
341
|
+
const lines = visibleLines(contents.replace(/\u001b\[[0-9?;]*[ -/]*[@-~]/g, "").replace(/\r/g, ""));
|
|
342
|
+
// errored: only an *agent-level* API error (claude/codex/gemini show "API Error:
|
|
343
|
+
// NNN" when their own call fails). Deliberately NOT panic:/fatal:/Traceback — those
|
|
344
|
+
// are tool output (git, a failing test), not the agent crashing; a real agent crash
|
|
345
|
+
// already drops out of the lifecycle (-> ended).
|
|
346
|
+
if (lines.some((line) => /\bAPI Error:\s*\d{3}\b/i.test(line))) {
|
|
347
|
+
return "errored";
|
|
348
|
+
}
|
|
349
|
+
// A selector (❯ or ›) on a numbered option is a choice/permission prompt — distinct
|
|
350
|
+
// from the plain "❯" idle input line (no digit follows).
|
|
351
|
+
if (lines.some((line) => /^[❯›]\s*\d+[.)]\s/.test(line))) {
|
|
352
|
+
return "asking";
|
|
353
|
+
}
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
function explicitSessionIdForProcess(provider, args) {
|
|
357
|
+
if (provider !== "claude")
|
|
358
|
+
return null;
|
|
359
|
+
return valueAfterFlag(args, "--session-id") ?? valueAfterFlag(args, "--resume");
|
|
360
|
+
}
|
|
361
|
+
function valueAfterFlag(args, flag) {
|
|
362
|
+
const pattern = new RegExp(`${escapeRegExp(flag)}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|(\\S+))`);
|
|
363
|
+
const match = args.match(pattern);
|
|
364
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
365
|
+
}
|
|
366
|
+
function escapeRegExp(value) {
|
|
367
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
368
|
+
}
|
|
369
|
+
function detachPreviousSession(options) {
|
|
370
|
+
const session = options.store.sessions.get(options.sessionId);
|
|
371
|
+
if (!session)
|
|
372
|
+
return;
|
|
373
|
+
const background = refreshBackground(session.background, options.processRows, options.backgroundLeaseDir ?? session.backgroundLeaseDir);
|
|
374
|
+
if (background.length > 0) {
|
|
375
|
+
options.store.sessions.set(options.sessionId, {
|
|
376
|
+
...session,
|
|
377
|
+
attachedPaneId: null,
|
|
378
|
+
background,
|
|
379
|
+
backgroundLeaseDir: options.backgroundLeaseDir ?? session.backgroundLeaseDir,
|
|
380
|
+
lastPaneId: options.paneId,
|
|
381
|
+
lastSeenAt: options.observedAt,
|
|
382
|
+
mainPid: undefined,
|
|
383
|
+
state: "session_stopped_background_running"
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
options.store.sessions.delete(options.sessionId);
|
|
388
|
+
}
|
|
389
|
+
function deriveSessionState(foreground, background) {
|
|
390
|
+
if (foreground === "running")
|
|
391
|
+
return "session_running";
|
|
392
|
+
return background.length > 0 ? "session_stopped_background_running" : "session_waiting";
|
|
393
|
+
}
|
|
394
|
+
function classifyForegroundStatus(provider, contents) {
|
|
395
|
+
if (!contents)
|
|
396
|
+
return "running";
|
|
397
|
+
const normalized = contents
|
|
398
|
+
.replace(/\u001b\[[0-9?;]*[ -/]*[@-~]/g, "")
|
|
399
|
+
.replace(/\r/g, "");
|
|
400
|
+
if (provider === "claude") {
|
|
401
|
+
if (isClaudeRunning(normalized)) {
|
|
402
|
+
return "running";
|
|
403
|
+
}
|
|
404
|
+
if (isClaudeIdle(normalized)) {
|
|
405
|
+
return "prompt";
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (provider === "codex" && isCodexIdle(normalized)) {
|
|
409
|
+
return "prompt";
|
|
410
|
+
}
|
|
411
|
+
return "running";
|
|
412
|
+
}
|
|
413
|
+
function isClaudeIdle(contents) {
|
|
414
|
+
const lines = visibleLines(contents);
|
|
415
|
+
return lines.some((line) => /^❯(?:\s|$)/.test(line));
|
|
416
|
+
}
|
|
417
|
+
function isClaudeRunning(contents) {
|
|
418
|
+
const lines = visibleLines(contents);
|
|
419
|
+
return lines.some((line) => /^●\s+Running\s+\d+\s+shell command/.test(line))
|
|
420
|
+
|| lines.some((line) => /^✻\s+\S+.*\(\d+s\b/.test(line))
|
|
421
|
+
|| lines.some((line) => /^✻\s+(?:Doodling|Thinking|Working|Baking|Cogitating|Herding|Musing|Pondering|Processing|Reading|Searching|Thinking)…/.test(line))
|
|
422
|
+
|| lines.some((line) => /\besc to interrupt\b/i.test(line));
|
|
423
|
+
}
|
|
424
|
+
function isCodexIdle(contents) {
|
|
425
|
+
const lines = visibleLines(contents);
|
|
426
|
+
return lines.some((line) => /^›\s*(?:$|\/|\w|\S)/.test(line))
|
|
427
|
+
|| lines.some((line) => /\bgpt-[\w.-]+.*·\s+~\//.test(line))
|
|
428
|
+
|| lines.some((line) => /\bOpenAI Codex\b/.test(line) && !lines.some((candidate) => /\bRunning\b|\bThinking\b/i.test(candidate)));
|
|
429
|
+
}
|
|
430
|
+
function visibleLines(contents) {
|
|
431
|
+
return contents
|
|
432
|
+
.split("\n")
|
|
433
|
+
.map((line) => line.trim())
|
|
434
|
+
.filter(Boolean);
|
|
435
|
+
}
|
|
436
|
+
function refreshBackground(previous, rows, backgroundLeaseDir) {
|
|
437
|
+
const active = new Map();
|
|
438
|
+
for (const item of previous) {
|
|
439
|
+
const row = rows.get(item.pid);
|
|
440
|
+
if (row) {
|
|
441
|
+
if (isDefunctProcess(row) || isInfrastructureProcess(row) || !processStartMatches(item.pid, item.processStartTime))
|
|
442
|
+
continue;
|
|
443
|
+
active.set(item.pid, {
|
|
444
|
+
...toBackgroundItem(row, "leased-descendant"),
|
|
445
|
+
processStartTime: item.processStartTime ?? currentProcessStartTime(item.pid)
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
else if (pidAlive(item.pid) && processStartMatches(item.pid, item.processStartTime)) {
|
|
449
|
+
active.set(item.pid, {
|
|
450
|
+
...item,
|
|
451
|
+
source: "leased-descendant"
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const item of explicitBackgroundLeases(backgroundLeaseDir, rows)) {
|
|
456
|
+
active.set(item.pid, item);
|
|
457
|
+
}
|
|
458
|
+
return Array.from(active.values()).sort((a, b) => a.pid - b.pid);
|
|
459
|
+
}
|
|
460
|
+
function explicitBackgroundLeases(backgroundLeaseDir, rows) {
|
|
461
|
+
if (!backgroundLeaseDir)
|
|
462
|
+
return [];
|
|
463
|
+
if (activeIo) {
|
|
464
|
+
return leaseItemsFromRaw(activeIo.leasesByDir.get(backgroundLeaseDir) ?? [], rows);
|
|
465
|
+
}
|
|
466
|
+
if (!existsSync(backgroundLeaseDir)) {
|
|
467
|
+
throw new Error(`Background lease directory does not exist: ${backgroundLeaseDir}`);
|
|
468
|
+
}
|
|
469
|
+
const leases = [];
|
|
470
|
+
for (const entry of readdirSync(backgroundLeaseDir, { withFileTypes: true })) {
|
|
471
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
472
|
+
continue;
|
|
473
|
+
leases.push(JSON.parse(readFileSync(join(backgroundLeaseDir, entry.name), "utf8")));
|
|
474
|
+
}
|
|
475
|
+
return leaseItemsFromRaw(leases, rows);
|
|
476
|
+
}
|
|
477
|
+
function leaseItemsFromRaw(leases, rows) {
|
|
478
|
+
const items = [];
|
|
479
|
+
for (const lease of leases) {
|
|
480
|
+
if (!Number.isInteger(lease.pid) || Number(lease.pid) < 1) {
|
|
481
|
+
throw new Error(`Invalid background lease pid: ${JSON.stringify(lease.pid)}`);
|
|
482
|
+
}
|
|
483
|
+
if (typeof lease.processStartTime !== "string" || lease.processStartTime.length === 0) {
|
|
484
|
+
throw new Error(`Invalid background lease processStartTime: ${JSON.stringify(lease.processStartTime)}`);
|
|
485
|
+
}
|
|
486
|
+
const pid = Number(lease.pid);
|
|
487
|
+
if (!pidAlive(pid) || !processStartMatches(pid, lease.processStartTime))
|
|
488
|
+
continue;
|
|
489
|
+
const row = rows.get(pid);
|
|
490
|
+
if (row && (isDefunctProcess(row) || isInfrastructureProcess(row)))
|
|
491
|
+
continue;
|
|
492
|
+
const base = row ? toBackgroundItem(row, "explicit-lease") : {
|
|
493
|
+
pid,
|
|
494
|
+
source: "explicit-lease"
|
|
495
|
+
};
|
|
496
|
+
items.push({
|
|
497
|
+
...base,
|
|
498
|
+
...(typeof lease.args === "string" ? { args: lease.args } : {}),
|
|
499
|
+
...(typeof lease.command === "string" ? { command: lease.command } : {}),
|
|
500
|
+
...(typeof lease.description === "string" ? { description: lease.description } : {}),
|
|
501
|
+
processStartTime: lease.processStartTime
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return items;
|
|
505
|
+
}
|
|
506
|
+
function collectDescendants(rootPid, rows) {
|
|
507
|
+
if (!rows.has(rootPid))
|
|
508
|
+
return [];
|
|
509
|
+
const childrenByParent = new Map();
|
|
510
|
+
for (const row of rows.values()) {
|
|
511
|
+
const children = childrenByParent.get(row.ppid);
|
|
512
|
+
if (children)
|
|
513
|
+
children.push(row);
|
|
514
|
+
else
|
|
515
|
+
childrenByParent.set(row.ppid, [row]);
|
|
516
|
+
}
|
|
517
|
+
const out = [];
|
|
518
|
+
const queue = [{ depth: 0, pid: rootPid }];
|
|
519
|
+
const seen = new Set();
|
|
520
|
+
while (queue.length > 0) {
|
|
521
|
+
const { depth, pid } = queue.shift();
|
|
522
|
+
if (seen.has(pid))
|
|
523
|
+
continue;
|
|
524
|
+
seen.add(pid);
|
|
525
|
+
const children = childrenByParent.get(pid) ?? [];
|
|
526
|
+
for (const child of children) {
|
|
527
|
+
out.push({ depth: depth + 1, row: child });
|
|
528
|
+
queue.push({ depth: depth + 1, pid: child.pid });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
function toBackgroundItem(row, source) {
|
|
534
|
+
return {
|
|
535
|
+
pid: row.pid,
|
|
536
|
+
ppid: row.ppid,
|
|
537
|
+
...(row.pgid !== undefined ? { pgid: row.pgid } : {}),
|
|
538
|
+
args: row.args,
|
|
539
|
+
command: row.command,
|
|
540
|
+
processStartTime: currentProcessStartTime(row.pid),
|
|
541
|
+
source
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function isInfrastructureProcess(row) {
|
|
545
|
+
const command = basename(row.command);
|
|
546
|
+
const args = row.args.trim();
|
|
547
|
+
return command === "tmux"
|
|
548
|
+
|| command === "reattach-to-user-namespace"
|
|
549
|
+
|| (command === "sleep" && /^sleep\s+3600(?:\s|$)/.test(args))
|
|
550
|
+
|| args.includes("@playwright/mcp")
|
|
551
|
+
|| command === "playwright-mcp"
|
|
552
|
+
|| /(?:^|[/\s])playwright-mcp(?:\s|$)/.test(args);
|
|
553
|
+
}
|
|
554
|
+
function basename(command) {
|
|
555
|
+
return command.split("/").at(-1) ?? command;
|
|
556
|
+
}
|
|
557
|
+
function isDefunctProcess(row) {
|
|
558
|
+
return row.stat?.includes("Z") || row.command.startsWith("(") || row.args.startsWith("(");
|
|
559
|
+
}
|
|
560
|
+
function agentIdForProcess(row) {
|
|
561
|
+
for (const agentId of KNOWN_AGENT_IDS) {
|
|
562
|
+
const escaped = agentId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
563
|
+
const re = new RegExp(`(?:^|[\\s/])${escaped}(?:\\.exe)?(?:\\s|$)`);
|
|
564
|
+
if (re.test(row.command) || re.test(row.args)) {
|
|
565
|
+
return agentId;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
function pidAlive(pid) {
|
|
571
|
+
try {
|
|
572
|
+
process.kill(pid, 0);
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function processStartMatches(pid, expected) {
|
|
580
|
+
if (!expected)
|
|
581
|
+
return true;
|
|
582
|
+
return currentProcessStartTime(pid) === expected;
|
|
583
|
+
}
|
|
584
|
+
function currentProcessStartTime(pid) {
|
|
585
|
+
// Pre-gathered (async) start-times when running the pure observe path.
|
|
586
|
+
if (activeIo)
|
|
587
|
+
return activeIo.startTimes.get(pid);
|
|
588
|
+
try {
|
|
589
|
+
const stdout = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
590
|
+
encoding: "utf8",
|
|
591
|
+
maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER,
|
|
592
|
+
timeout: 1000
|
|
593
|
+
}).trim();
|
|
594
|
+
return stdout || undefined;
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Batched async twin of currentProcessStartTime: one ps call yields start-times
|
|
601
|
+
// for every live pid, gathered off the event loop and fed in via ObserveIo.
|
|
602
|
+
export async function gatherProcessStartTimes() {
|
|
603
|
+
const startTimes = new Map();
|
|
604
|
+
let stdout;
|
|
605
|
+
try {
|
|
606
|
+
stdout = await execFileAsync("ps", ["-axo", "pid=,lstart="], { maxBuffer: PROCESS_SNAPSHOT_MAX_BUFFER, timeout: 5000 });
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return startTimes;
|
|
610
|
+
}
|
|
611
|
+
for (const line of stdout.split("\n")) {
|
|
612
|
+
const match = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
613
|
+
if (!match?.[2])
|
|
614
|
+
continue;
|
|
615
|
+
startTimes.set(Number(match[1]), match[2].trim());
|
|
616
|
+
}
|
|
617
|
+
return startTimes;
|
|
618
|
+
}
|
|
619
|
+
// Async twin of the lease file reads, gathered off the event loop. Returns the
|
|
620
|
+
// parsed lease files per directory; observeLifecycle applies the same liveness
|
|
621
|
+
// rules over them as the sync path.
|
|
622
|
+
export async function gatherBackgroundLeases(dirs) {
|
|
623
|
+
const byDir = new Map();
|
|
624
|
+
const unique = [...new Set(dirs.filter((dir) => Boolean(dir)))];
|
|
625
|
+
await Promise.all(unique.map(async (dir) => {
|
|
626
|
+
const leases = [];
|
|
627
|
+
let entries;
|
|
628
|
+
try {
|
|
629
|
+
entries = await readdirAsync(dir, { withFileTypes: true });
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
byDir.set(dir, leases);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
await Promise.all(entries.map(async (entry) => {
|
|
636
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
637
|
+
return;
|
|
638
|
+
try {
|
|
639
|
+
leases.push(JSON.parse(await readFileAsync(join(dir, entry.name), "utf8")));
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// Skip unreadable/!corrupt lease files.
|
|
643
|
+
}
|
|
644
|
+
}));
|
|
645
|
+
byDir.set(dir, leases);
|
|
646
|
+
}));
|
|
647
|
+
return byDir;
|
|
648
|
+
}
|
|
649
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
650
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
651
|
+
import { readdir as readdirAsync, readFile as readFileAsync } from "node:fs/promises";
|
|
652
|
+
import { join } from "node:path";
|
|
653
|
+
import { promisify } from "node:util";
|
|
654
|
+
const execFileRaw = promisify(execFile);
|
|
655
|
+
async function execFileAsync(command, args, options) {
|
|
656
|
+
const { stdout } = await execFileRaw(command, args, { encoding: "utf8", ...options });
|
|
657
|
+
return stdout;
|
|
658
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ProviderWindow } from "./types.js";
|
|
2
|
+
export declare function windowHostsSession(currentSessionId: string | null | undefined, agentSessionId: string): boolean;
|
|
3
|
+
export declare function findSessionWindow(windows: ProviderWindow[], agentSessionId: string): ProviderWindow | undefined;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { agentProviders, formatAgentSessionId } from "./agent-session-id.js";
|
|
2
|
+
// The session→window→terminal descent, in ONE place. Acting on a session is
|
|
3
|
+
// keyed by agentSessionId; this resolves the live window/terminal hosting it so
|
|
4
|
+
// no caller (CLI, SDK, frontend, provider socket) has to descend the tiers by
|
|
5
|
+
// hand. See the session-first principle in CLAUDE.md.
|
|
6
|
+
// A window hosts `agentSessionId` when its live currentSessionId matches. Accept
|
|
7
|
+
// the prefixed form (claude:uuid / codex:uuid / gemini:uuid) the index and
|
|
8
|
+
// triage views emit, or a bare uuid (match any agent prefix).
|
|
9
|
+
export function windowHostsSession(currentSessionId, agentSessionId) {
|
|
10
|
+
if (!currentSessionId)
|
|
11
|
+
return false;
|
|
12
|
+
if (currentSessionId === agentSessionId)
|
|
13
|
+
return true;
|
|
14
|
+
if (agentSessionId.includes(":"))
|
|
15
|
+
return false;
|
|
16
|
+
return agentProviders.some((agent) => currentSessionId === formatAgentSessionId(agent, agentSessionId));
|
|
17
|
+
}
|
|
18
|
+
export function findSessionWindow(windows, agentSessionId) {
|
|
19
|
+
return windows.find((windowInfo) => windowHostsSession(windowInfo.lifecycle?.currentSessionId, agentSessionId));
|
|
20
|
+
}
|