nterminal 1.2.80 → 1.2.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/client/assets/MarkdownPreview-C-ZqElTb.js +1 -0
  2. package/dist/client/assets/{TranscriptMarkdownBody-BFu2FzgT.js → TranscriptMarkdownBody-UgmTgEmq.js} +1 -1
  3. package/dist/client/assets/index-CEMuRNoD.js +57 -0
  4. package/dist/client/assets/{index-BFs4imuQ.js → index-i3y_vda1.js} +1 -1
  5. package/dist/client/assets/{index-rROkcwjD.css → index-yKhwlegq.css} +1 -1
  6. package/dist/client/index.html +2 -2
  7. package/dist/server/agent/agentRoutes.js +41 -1
  8. package/dist/server/agent/agentRoutes.js.map +1 -1
  9. package/dist/server/routes/agentManagementRoutes.js +29 -0
  10. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  11. package/dist/server/routes/terminalRoutes.js +51 -0
  12. package/dist/server/routes/terminalRoutes.js.map +1 -1
  13. package/dist/server/routes/terminalWebSocket.js +1 -1
  14. package/dist/server/routes/terminalWebSocket.js.map +1 -1
  15. package/dist/server/terminal/TerminalManager.d.ts +17 -2
  16. package/dist/server/terminal/TerminalManager.js +155 -62
  17. package/dist/server/terminal/TerminalManager.js.map +1 -1
  18. package/dist/server/terminal/TmuxPtyAdapter.d.ts +6 -0
  19. package/dist/server/terminal/TmuxPtyAdapter.js +29 -2
  20. package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -1
  21. package/dist/server/terminal/codexTranscriptSource.d.ts +2 -2
  22. package/dist/server/terminal/codexTranscriptSource.js +1 -1
  23. package/dist/server/terminal/codexTranscriptSource.js.map +1 -1
  24. package/dist/server/terminal/subagentSources.d.ts +20 -0
  25. package/dist/server/terminal/subagentSources.js +1072 -0
  26. package/dist/server/terminal/subagentSources.js.map +1 -0
  27. package/dist/server/terminal/transcriptHistory.d.ts +2 -0
  28. package/dist/server/terminal/transcriptHistory.js +62 -37
  29. package/dist/server/terminal/transcriptHistory.js.map +1 -1
  30. package/dist/shared/protocol.d.ts +19 -0
  31. package/dist/shared/protocol.js.map +1 -1
  32. package/package.json +1 -1
  33. package/dist/client/assets/MarkdownPreview-D2UZ6wJU.js +0 -1
  34. package/dist/client/assets/index-4MeJRcb6.js +0 -57
@@ -0,0 +1,1072 @@
1
+ import { open as openFile, readFile, readdir, stat } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { StringDecoder } from 'node:string_decoder';
5
+ import { AgentTranscriptTracker } from './agentTranscriptStatus.js';
6
+ import { KeyedSingleFlight, mapWithConcurrency, runCommand } from './asyncCommand.js';
7
+ import { sqlString } from './codexTranscriptSource.js';
8
+ import { isSessionId, sessionIdPatternSource } from './sessionId.js';
9
+ import { TtlCache } from './ttlCache.js';
10
+ import { encodeClaudeProjectPath, findTranscriptFilesAsync } from './TmuxPtyAdapter.js';
11
+ // The dialog polls every five seconds. Keep a small overlap so a poll that
12
+ // lands on schedule can reuse the prior discovery instead of spawning sqlite
13
+ // and re-statting every child on every tick.
14
+ const LIST_CACHE_TTL_MS = 7_000;
15
+ const LIST_LIMIT = 100;
16
+ // A transcript that claims "working" but has not been written for this long
17
+ // is treated as unknown: a killed CLI never appends its terminal record.
18
+ const WORKING_STALE_MS = 10 * 60_000;
19
+ const DESCRIPTION_LIMIT = 240;
20
+ const STATUS_KEY_LIMIT = 256;
21
+ const SCAN_STATE_LIMIT = 16;
22
+ const SCAN_CHUNK_BYTES = 1024 * 1024;
23
+ // The cross-id pattern is under 60 chars, so this overlap can never split it.
24
+ const CROSS_SCAN_CARRY_CHARS = 160;
25
+ // First scan of a Claude parent starts this far from the end. Banner records
26
+ // older than this predate anything worth listing, and an uncapped scan of a
27
+ // multi-hundred-MB transcript would sit on the request path.
28
+ const CROSS_SCAN_INITIAL_TAIL_BYTES = 64 * 1024 * 1024;
29
+ // First scan of a codex parent starts this far from the end; spawn events
30
+ // deeper in the rollout than this predate anything worth listing live.
31
+ const CODEX_SCAN_INITIAL_TAIL_BYTES = 16 * 1024 * 1024;
32
+ const CODEX_SCAN_MAX_LINE_CHARS = 512 * 1024;
33
+ const SESSION_META_SCAN_BYTES = 256 * 1024;
34
+ const CROSS_VERIFY_POSITIVE_TTL_MS = 10 * 60_000;
35
+ const CROSS_VERIFY_NEGATIVE_TTL_MS = 30_000;
36
+ const SQLITE_TIMEOUT_MS = 1_000;
37
+ // A child's transcript path never changes once created, so paging can skip
38
+ // full rediscovery for far longer than the status-freshness list cache.
39
+ const TRANSCRIPT_PATH_CACHE_TTL_MS = 10 * 60_000;
40
+ // Codex copies the forked prefix in one burst, then the child works in real
41
+ // time, so the seam is the first pause in an otherwise continuous timeline.
42
+ // Measured across teammate rollouts: consecutive copied records are at most
43
+ // 1ms apart, the copy takes ~0.7-0.9s, and the pause before the child's first
44
+ // record is 94-117ms. The gap threshold sits between those two, and the probe
45
+ // step stays below it so a step can never jump over the seam.
46
+ const FORK_SEAM_GAP_MS = 50;
47
+ const FORK_SEAM_STEP_MS = 40;
48
+ // The step walks the burst at 40ms a time; the cap only exists so a rollout
49
+ // with an unexpected shape cannot spin.
50
+ const FORK_SEAM_MAX_ATTEMPTS = 256;
51
+ const FORK_PREVIOUS_RECORD_LOOKBACK_BYTES = 1024 * 1024;
52
+ const FORK_PROBE_BYTES = 64 * 1024;
53
+ const listCache = new TtlCache();
54
+ const listInflight = new KeyedSingleFlight();
55
+ const transcriptPathCache = new TtlCache();
56
+ const forkBoundaryCache = new Map();
57
+ const FORK_BOUNDARY_CACHE_LIMIT = 64;
58
+ const crossVerificationCache = new TtlCache();
59
+ const statusTracker = new AgentTranscriptTracker();
60
+ const statusKeys = new Set();
61
+ const crossScanStates = new Map();
62
+ const codexScanStates = new Map();
63
+ export function clearSubagentCaches() {
64
+ listCache.clear();
65
+ listInflight.clear();
66
+ transcriptPathCache.clear();
67
+ crossVerificationCache.clear();
68
+ for (const key of statusKeys) {
69
+ statusTracker.reset(key);
70
+ }
71
+ statusKeys.clear();
72
+ crossScanStates.clear();
73
+ codexScanStates.clear();
74
+ ancestorIdCache.clear();
75
+ forkBoundaryCache.clear();
76
+ }
77
+ export function subagentSummary(record) {
78
+ const { filePath: _filePath, ...summary } = record;
79
+ return summary;
80
+ }
81
+ export async function listTerminalSubagents(source, options = {}) {
82
+ const key = `${source.provider}\0${source.sessionId}\0${source.filePath}`;
83
+ const cached = listCache.get(key);
84
+ if (cached) {
85
+ return cached.value;
86
+ }
87
+ return listInflight.run(key, async () => {
88
+ const records = source.provider === 'claude'
89
+ ? await listClaudeSubagents(source, options)
90
+ : await listCodexSubagents(source, options);
91
+ records.sort(compareSubagents);
92
+ if (records.length > LIST_LIMIT) {
93
+ records.length = LIST_LIMIT;
94
+ }
95
+ listCache.set(key, records, LIST_CACHE_TTL_MS);
96
+ return records;
97
+ });
98
+ }
99
+ // Resolve one child's transcript source for paging without re-running full
100
+ // discovery on every page request. Falls back to the listing on a cache miss
101
+ // and invalidates when the cached file disappeared.
102
+ export async function resolveSubagentTranscriptSource(source, subagentId, options = {}) {
103
+ const key = `${source.provider}\0${source.sessionId}\0${source.filePath}\0${subagentId}`;
104
+ const cached = transcriptPathCache.get(key);
105
+ if (cached) {
106
+ try {
107
+ await stat(cached.value.source.filePath);
108
+ return cached.value;
109
+ }
110
+ catch {
111
+ transcriptPathCache.delete(key);
112
+ }
113
+ }
114
+ const records = await listTerminalSubagents(source, options);
115
+ const record = records.find((candidate) => candidate.id === subagentId);
116
+ if (!record?.filePath) {
117
+ return null;
118
+ }
119
+ const child = { provider: record.provider, sessionId: record.id, filePath: record.filePath };
120
+ const boundary = record.provider === 'codex'
121
+ ? await resolveCodexForkBoundary(child.filePath, child.sessionId)
122
+ : { offset: 0, settled: true };
123
+ const target = { source: child, startOffset: boundary.offset };
124
+ // An unsettled boundary belongs to a teammate that has not written past its
125
+ // fork burst yet; hold it only briefly so the seam is picked up as soon as
126
+ // the child starts working.
127
+ transcriptPathCache.set(key, target, boundary.settled ? TRANSCRIPT_PATH_CACHE_TTL_MS : LIST_CACHE_TTL_MS);
128
+ return target;
129
+ }
130
+ // Codex teammates start life as a fork: the rollout opens with a verbatim
131
+ // copy of the parent thread, written in one burst stamped at fork time, and
132
+ // only then records the child's own turns. Locating that seam lets the viewer
133
+ // show the child's conversation instead of its inherited history.
134
+ export async function resolveCodexForkBoundaryOffset(filePath, threadId) {
135
+ return (await resolveCodexForkBoundary(filePath, threadId)).offset;
136
+ }
137
+ async function resolveCodexForkBoundary(filePath, threadId) {
138
+ let fileStat;
139
+ try {
140
+ fileStat = await stat(filePath);
141
+ }
142
+ catch {
143
+ return { offset: 0, settled: false };
144
+ }
145
+ const cached = forkBoundaryCache.get(filePath);
146
+ if (cached && cached.dev === fileStat.dev && cached.ino === fileStat.ino) {
147
+ return { offset: cached.offset, settled: true };
148
+ }
149
+ let handle;
150
+ let offset = 0;
151
+ try {
152
+ handle = await openFile(filePath, 'r');
153
+ const forkedAtMs = await readCodexForkTimestampMs(handle, threadId);
154
+ if (forkedAtMs === null) {
155
+ // Not a fork: the whole rollout belongs to this thread, and that stays
156
+ // true for the life of the file.
157
+ rememberForkBoundary(filePath, { dev: fileStat.dev, ino: fileStat.ino, offset: 0 });
158
+ return { offset: 0, settled: true };
159
+ }
160
+ const seam = await findForkSeamAsync(handle, fileStat.size, forkedAtMs);
161
+ if (seam === null) {
162
+ // The child has not written past its fork burst yet. Showing inherited
163
+ // history for now beats caching a zero that would outlive the reason
164
+ // for it — the next request re-resolves.
165
+ return { offset: 0, settled: false };
166
+ }
167
+ offset = seam;
168
+ rememberForkBoundary(filePath, { dev: fileStat.dev, ino: fileStat.ino, offset });
169
+ }
170
+ catch {
171
+ return { offset: 0, settled: false };
172
+ }
173
+ finally {
174
+ await handle?.close().catch(() => undefined);
175
+ }
176
+ return { offset, settled: true };
177
+ }
178
+ // Steps the probe threshold forward until the record it lands on is separated
179
+ // from its predecessor by a real pause. Inside the copied prefix consecutive
180
+ // records are microseconds apart, so only the seam clears the gap.
181
+ async function findForkSeamAsync(handle, fileSize, forkedAtMs) {
182
+ let threshold = forkedAtMs + FORK_SEAM_STEP_MS;
183
+ let searchFrom = 0;
184
+ let earliestCandidate = null;
185
+ for (let attempt = 0; attempt < FORK_SEAM_MAX_ATTEMPTS; attempt += 1) {
186
+ const crossing = await findFirstRecordAfterAsync(handle, fileSize, threshold, searchFrom);
187
+ if (crossing === null || crossing <= 0) {
188
+ return earliestCandidate;
189
+ }
190
+ if (earliestCandidate === null) {
191
+ earliestCandidate = crossing;
192
+ }
193
+ const crossingTimestampMs = (await readRecordTimestampAtAsync(handle, crossing, fileSize))?.timestampMs ?? null;
194
+ const previousTimestampMs = await readPreviousRecordTimestampMsAsync(handle, crossing);
195
+ if (crossingTimestampMs === null || previousTimestampMs === null) {
196
+ return crossing;
197
+ }
198
+ if (crossingTimestampMs - previousTimestampMs >= FORK_SEAM_GAP_MS) {
199
+ return crossing;
200
+ }
201
+ // Still inside the copied prefix. The record just tested is a floor for
202
+ // the next search, so the scanned range shrinks with every step.
203
+ threshold = crossingTimestampMs + FORK_SEAM_STEP_MS;
204
+ searchFrom = crossing;
205
+ }
206
+ // Out of attempts: fall back to the earliest cut, which can only leave some
207
+ // inherited history visible rather than hiding the child's own turns.
208
+ return earliestCandidate;
209
+ }
210
+ async function readPreviousRecordTimestampMsAsync(handle, offset) {
211
+ const start = Math.max(0, offset - FORK_PREVIOUS_RECORD_LOOKBACK_BYTES);
212
+ if (offset <= start) {
213
+ return null;
214
+ }
215
+ const buffer = await readBufferAsync(handle, start, offset - start);
216
+ if (!buffer || buffer.length < 2) {
217
+ return null;
218
+ }
219
+ // `offset` is a record start, so the final byte here closes the record
220
+ // before it.
221
+ const previousEnd = buffer[buffer.length - 1] === 0x0a ? buffer.length - 1 : buffer.length;
222
+ const previousStart = buffer.lastIndexOf(0x0a, previousEnd - 1) + 1;
223
+ if (previousStart <= 0 && start !== 0) {
224
+ return null;
225
+ }
226
+ return recordTimestampMs(buffer.subarray(previousStart, previousEnd));
227
+ }
228
+ function rememberForkBoundary(filePath, entry) {
229
+ forkBoundaryCache.delete(filePath);
230
+ forkBoundaryCache.set(filePath, entry);
231
+ while (forkBoundaryCache.size > FORK_BOUNDARY_CACHE_LIMIT) {
232
+ const oldest = forkBoundaryCache.keys().next().value;
233
+ if (typeof oldest !== 'string' || oldest === filePath) {
234
+ break;
235
+ }
236
+ forkBoundaryCache.delete(oldest);
237
+ }
238
+ }
239
+ // Returns null for a thread that was not forked, so its whole rollout shows.
240
+ async function readCodexForkTimestampMs(handle, threadId) {
241
+ const buffer = Buffer.alloc(SESSION_META_SCAN_BYTES);
242
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
243
+ for (const line of buffer.subarray(0, bytesRead).toString('utf8').split('\n')) {
244
+ if (!line.includes('"session_meta"')) {
245
+ continue;
246
+ }
247
+ const record = parseJsonLine(line);
248
+ const payload = readRecord(record?.payload);
249
+ if (!payload || payload.id !== threadId) {
250
+ continue;
251
+ }
252
+ if (!readNonEmptyString(payload.forked_from_id)) {
253
+ return null;
254
+ }
255
+ const timestamp = Date.parse(readNonEmptyString(record?.timestamp) ?? readNonEmptyString(payload.timestamp) ?? '');
256
+ return Number.isFinite(timestamp) ? timestamp : null;
257
+ }
258
+ return null;
259
+ }
260
+ // Binary search by byte offset: the rollout is append-ordered, so record
261
+ // timestamps are non-decreasing and the first record past the fork window can
262
+ // be found without reading the (often hundreds of MB) inherited prefix.
263
+ // All arithmetic stays on raw bytes — decoding a window that starts or ends
264
+ // mid-character would shift every offset derived from it.
265
+ async function findFirstRecordAfterAsync(handle, fileSize, thresholdMs, fromOffset = 0) {
266
+ let low = fromOffset;
267
+ let high = fileSize;
268
+ // Remembered because a probe reports the first record at or after `middle`,
269
+ // which may itself be the crossing — the window below `middle` would then
270
+ // no longer contain it.
271
+ let candidate = null;
272
+ while (high - low > FORK_PROBE_BYTES) {
273
+ const middle = low + Math.floor((high - low) / 2);
274
+ const probe = await readRecordTimestampAtAsync(handle, middle, fileSize);
275
+ if (probe !== null && probe.timestampMs <= thresholdMs) {
276
+ // Both branches move past `middle`, so a multi-megabyte record that
277
+ // makes every probe in a range report the same offset cannot stall the
278
+ // search.
279
+ low = Math.min(Math.max(probe.startOffset + 1, middle + 1), high);
280
+ continue;
281
+ }
282
+ // A probe that cannot be read (a record larger than the largest window)
283
+ // searches earlier: cutting early only leaks a little inherited history,
284
+ // while cutting late would hide the child's own conversation.
285
+ if (probe !== null) {
286
+ candidate = probe.startOffset;
287
+ }
288
+ high = middle;
289
+ }
290
+ const buffer = await readBufferAsync(handle, low, Math.min(FORK_PROBE_BYTES * 2, fileSize - low));
291
+ if (!buffer) {
292
+ return candidate;
293
+ }
294
+ let cursor = low === 0 ? 0 : buffer.indexOf(0x0a) + 1;
295
+ while (cursor > 0 || low === 0) {
296
+ if (cursor >= buffer.length) {
297
+ break;
298
+ }
299
+ const end = buffer.indexOf(0x0a, cursor);
300
+ const timestampMs = recordTimestampMs(buffer.subarray(cursor, end === -1 ? buffer.length : end));
301
+ if (timestampMs !== null && timestampMs > thresholdMs) {
302
+ return low + cursor;
303
+ }
304
+ if (end === -1) {
305
+ break;
306
+ }
307
+ cursor = end + 1;
308
+ }
309
+ return candidate;
310
+ }
311
+ async function readRecordTimestampAtAsync(handle, offset, fileSize) {
312
+ // Grow the probe when a single record is larger than the window; rollouts
313
+ // routinely carry multi-megabyte tool outputs.
314
+ for (const size of [FORK_PROBE_BYTES, FORK_PROBE_BYTES * 16, FORK_PROBE_BYTES * 64]) {
315
+ const buffer = await readBufferAsync(handle, offset, Math.min(size, fileSize - offset));
316
+ if (!buffer) {
317
+ return null;
318
+ }
319
+ const newlineIndex = offset === 0 ? -1 : buffer.indexOf(0x0a);
320
+ if (offset !== 0 && newlineIndex === -1) {
321
+ if (buffer.length < size) {
322
+ return null;
323
+ }
324
+ continue;
325
+ }
326
+ const lineStart = newlineIndex + 1;
327
+ const end = buffer.indexOf(0x0a, lineStart);
328
+ const timestampMs = recordTimestampMs(buffer.subarray(lineStart, end === -1 ? buffer.length : end));
329
+ if (timestampMs === null) {
330
+ if (end === -1 && buffer.length === size) {
331
+ continue;
332
+ }
333
+ return null;
334
+ }
335
+ return { startOffset: offset + lineStart, timestampMs };
336
+ }
337
+ return null;
338
+ }
339
+ function recordTimestampMs(line) {
340
+ if (line.length === 0) {
341
+ return null;
342
+ }
343
+ const record = parseJsonLine(line.toString('utf8'));
344
+ const timestamp = Date.parse(readNonEmptyString(record?.timestamp) ?? '');
345
+ return Number.isFinite(timestamp) ? timestamp : null;
346
+ }
347
+ async function readBufferAsync(handle, offset, length) {
348
+ if (length <= 0) {
349
+ return null;
350
+ }
351
+ const buffer = Buffer.alloc(length);
352
+ let total = 0;
353
+ while (total < length) {
354
+ const { bytesRead } = await handle.read(buffer, total, length - total, offset + total);
355
+ if (bytesRead === 0) {
356
+ break;
357
+ }
358
+ total += bytesRead;
359
+ }
360
+ return total > 0 ? buffer.subarray(0, total) : null;
361
+ }
362
+ function compareSubagents(a, b) {
363
+ const aWorking = a.state === 'working' ? 0 : 1;
364
+ const bWorking = b.state === 'working' ? 0 : 1;
365
+ if (aWorking !== bWorking) {
366
+ return aWorking - bWorking;
367
+ }
368
+ // ISO-8601 sorts lexicographically; unknown timestamps sink to the bottom.
369
+ return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
370
+ }
371
+ function isMissingPathError(error) {
372
+ return typeof error === 'object' && error !== null &&
373
+ 'code' in error && error.code === 'ENOENT';
374
+ }
375
+ // --- Claude parents ---
376
+ async function listClaudeSubagents(source, options) {
377
+ const [native, cross] = await Promise.all([
378
+ listClaudeNativeSubagents(source, options),
379
+ listClaudeCrossCodexSubagents(source, options)
380
+ ]);
381
+ return [...native, ...cross];
382
+ }
383
+ async function listClaudeNativeSubagents(source, options) {
384
+ const subagentsDir = path.join(path.dirname(source.filePath), source.sessionId, 'subagents');
385
+ let entries;
386
+ try {
387
+ entries = await readdir(subagentsDir, { withFileTypes: true });
388
+ }
389
+ catch (error) {
390
+ if (isMissingPathError(error)) {
391
+ return [];
392
+ }
393
+ throw error;
394
+ }
395
+ const transcripts = [];
396
+ for (const entry of entries) {
397
+ if (!entry.isFile())
398
+ continue;
399
+ const match = /^agent-([A-Za-z0-9_-]+)\.jsonl$/.exec(entry.name);
400
+ if (match) {
401
+ transcripts.push({ agentId: match[1], fileName: entry.name });
402
+ }
403
+ }
404
+ const records = await mapWithConcurrency(transcripts, 4, async ({ agentId, fileName }) => {
405
+ const filePath = path.join(subagentsDir, fileName);
406
+ let fileStat;
407
+ try {
408
+ fileStat = await stat(filePath);
409
+ }
410
+ catch {
411
+ return null;
412
+ }
413
+ const meta = await readClaudeSubagentMeta(path.join(subagentsDir, `agent-${agentId}.meta.json`));
414
+ const state = await readSubagentState({ provider: 'claude', sessionId: agentId, filePath }, fileStat.mtimeMs, options);
415
+ const record = {
416
+ id: agentId,
417
+ provider: 'claude',
418
+ kind: 'native',
419
+ name: meta?.agentType ?? 'subagent',
420
+ description: truncateDescription(meta?.description ?? null),
421
+ model: null,
422
+ spawnDepth: meta?.spawnDepth ?? 1,
423
+ parentId: meta?.parentAgentId ?? null,
424
+ state,
425
+ updatedAt: new Date(fileStat.mtimeMs).toISOString(),
426
+ hasTranscript: true,
427
+ filePath
428
+ };
429
+ return record;
430
+ });
431
+ return records.filter((record) => record !== null);
432
+ }
433
+ async function readClaudeSubagentMeta(metaPath) {
434
+ let parsed;
435
+ try {
436
+ parsed = JSON.parse(await readFile(metaPath, 'utf8'));
437
+ }
438
+ catch {
439
+ return null;
440
+ }
441
+ const record = readRecord(parsed);
442
+ if (!record) {
443
+ return null;
444
+ }
445
+ const spawnDepth = typeof record.spawnDepth === 'number' && Number.isInteger(record.spawnDepth) && record.spawnDepth > 0
446
+ ? record.spawnDepth
447
+ : null;
448
+ return {
449
+ agentType: readNonEmptyString(record.agentType),
450
+ description: readNonEmptyString(record.description),
451
+ spawnDepth,
452
+ parentAgentId: readNonEmptyString(record.parentAgentId)
453
+ };
454
+ }
455
+ async function listClaudeCrossCodexSubagents(source, options) {
456
+ const ids = await collectCrossSessionIds(source.filePath);
457
+ if (ids.size === 0) {
458
+ return [];
459
+ }
460
+ const candidates = (await mapWithConcurrency([...ids], 4, (threadId) => verifyCrossCandidate(threadId, source, options))).filter((candidate) => candidate !== null);
461
+ if (candidates.length === 0) {
462
+ return [];
463
+ }
464
+ const details = await readCodexThreadDetails(candidates.map((candidate) => candidate.threadId), options);
465
+ const records = await mapWithConcurrency(candidates, 4, async (candidate) => {
466
+ let fileStat;
467
+ try {
468
+ fileStat = await stat(candidate.filePath);
469
+ }
470
+ catch {
471
+ return null;
472
+ }
473
+ const state = await readSubagentState({ provider: 'codex', sessionId: candidate.threadId, filePath: candidate.filePath }, fileStat.mtimeMs, options);
474
+ const detail = details.get(candidate.threadId);
475
+ const record = {
476
+ id: candidate.threadId,
477
+ provider: 'codex',
478
+ kind: 'cross',
479
+ name: 'codex exec',
480
+ description: truncateDescription(detail?.firstUserMessage ?? null),
481
+ model: detail?.model ?? null,
482
+ spawnDepth: 1,
483
+ parentId: null,
484
+ state,
485
+ updatedAt: new Date(fileStat.mtimeMs).toISOString(),
486
+ hasTranscript: true,
487
+ filePath: candidate.filePath
488
+ };
489
+ return record;
490
+ });
491
+ return records.filter((record) => record !== null);
492
+ }
493
+ async function collectCrossSessionIds(parentFilePath) {
494
+ let fileStat;
495
+ try {
496
+ fileStat = await stat(parentFilePath);
497
+ }
498
+ catch {
499
+ return new Set();
500
+ }
501
+ let state = crossScanStates.get(parentFilePath);
502
+ if (!state || state.dev !== fileStat.dev || state.ino !== fileStat.ino || fileStat.size < state.offset) {
503
+ state = {
504
+ dev: fileStat.dev,
505
+ ino: fileStat.ino,
506
+ // The substring scan tolerates a mid-line start; the carry only needs
507
+ // to cover pattern-splitting at chunk boundaries.
508
+ offset: Math.max(0, fileStat.size - CROSS_SCAN_INITIAL_TAIL_BYTES),
509
+ carry: '',
510
+ decoder: new StringDecoder('utf8'),
511
+ ids: new Set()
512
+ };
513
+ }
514
+ touchScanState(crossScanStates, parentFilePath, state);
515
+ if (state.offset >= fileStat.size) {
516
+ return state.ids;
517
+ }
518
+ const pattern = new RegExp(`(?:session|thread) id: (${sessionIdPatternSource})`, 'g');
519
+ const handle = await openFile(parentFilePath, 'r');
520
+ try {
521
+ const buffer = Buffer.alloc(SCAN_CHUNK_BYTES);
522
+ while (state.offset < fileStat.size) {
523
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, fileStat.size - state.offset), state.offset);
524
+ if (bytesRead === 0) {
525
+ break;
526
+ }
527
+ state.offset += bytesRead;
528
+ const text = state.carry + state.decoder.write(buffer.subarray(0, bytesRead));
529
+ for (const match of text.matchAll(pattern)) {
530
+ state.ids.add(match[1].toLowerCase());
531
+ }
532
+ state.carry = text.slice(-CROSS_SCAN_CARRY_CHARS);
533
+ await yieldToEventLoop();
534
+ }
535
+ }
536
+ finally {
537
+ await handle.close().catch(() => undefined);
538
+ }
539
+ return state.ids;
540
+ }
541
+ async function verifyCrossCandidate(threadId, source, options) {
542
+ if (!isSessionId(threadId) || threadId === source.sessionId) {
543
+ return null;
544
+ }
545
+ const sessionsRoot = path.join(options.homeDir ?? os.homedir(), '.codex', 'sessions');
546
+ const parentProjectDir = path.basename(path.dirname(source.filePath));
547
+ const cacheKey = `${sessionsRoot}\0${threadId}\0${parentProjectDir}\0${source.sessionId}`;
548
+ const cached = crossVerificationCache.get(cacheKey);
549
+ if (cached) {
550
+ return cached.value;
551
+ }
552
+ const candidate = await verifyCrossCandidateUncached(threadId, source, sessionsRoot, parentProjectDir);
553
+ crossVerificationCache.set(cacheKey, candidate, candidate ? CROSS_VERIFY_POSITIVE_TTL_MS : CROSS_VERIFY_NEGATIVE_TTL_MS);
554
+ return candidate;
555
+ }
556
+ async function verifyCrossCandidateUncached(threadId, source, sessionsRoot, parentProjectDir) {
557
+ const matches = await findTranscriptFilesAsync('codex', threadId, sessionsRoot);
558
+ if (matches.length !== 1) {
559
+ return null;
560
+ }
561
+ const filePath = matches[0];
562
+ const meta = await readCodexSessionMeta(filePath, threadId);
563
+ // Only non-interactive `codex exec` rollouts qualify: an interactive codex
564
+ // session in the same project whose banner got quoted into the Claude
565
+ // conversation must not surface as a subagent of it.
566
+ if (!meta || meta.source !== 'exec' || !meta.cwd) {
567
+ return null;
568
+ }
569
+ const belongsToParent = encodeClaudeProjectPath(meta.cwd) === parentProjectDir || meta.cwd.includes(source.sessionId);
570
+ return belongsToParent ? { threadId, filePath } : null;
571
+ }
572
+ async function readCodexSessionMeta(filePath, threadId) {
573
+ let handle;
574
+ try {
575
+ handle = await openFile(filePath, 'r');
576
+ const buffer = Buffer.alloc(SESSION_META_SCAN_BYTES);
577
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
578
+ const lines = buffer.subarray(0, bytesRead).toString('utf8').split('\n');
579
+ for (const line of lines) {
580
+ // Forked rollouts replay ancestor session_meta records; only the one
581
+ // whose id matches the filename thread describes this rollout.
582
+ if (!line.includes('"session_meta"')) {
583
+ continue;
584
+ }
585
+ const record = parseJsonLine(line);
586
+ const payload = readRecord(record?.payload);
587
+ if (!payload || payload.id !== threadId) {
588
+ continue;
589
+ }
590
+ return {
591
+ cwd: readNonEmptyString(payload.cwd),
592
+ source: typeof payload.source === 'string' ? payload.source : null
593
+ };
594
+ }
595
+ return null;
596
+ }
597
+ catch {
598
+ return null;
599
+ }
600
+ finally {
601
+ await handle?.close().catch(() => undefined);
602
+ }
603
+ }
604
+ // --- Codex parents ---
605
+ async function listCodexSubagents(source, options) {
606
+ const [fromDb, fromRollout] = await Promise.all([
607
+ listCodexSubagentsFromStateDb(source, options),
608
+ listCodexSubagentsFromRollout(source, options)
609
+ ]);
610
+ const byId = new Map();
611
+ // DB rows carry richer metadata (nickname, model, prompt), but when the
612
+ // DB's rollout_path went stale the rollout scan may still have located the
613
+ // real transcript — keep that file and its snapshot-derived state.
614
+ for (const record of fromRollout) {
615
+ byId.set(record.id, record);
616
+ }
617
+ for (const record of fromDb) {
618
+ const fromScan = byId.get(record.id);
619
+ if (fromScan?.hasTranscript && !record.hasTranscript) {
620
+ byId.set(record.id, {
621
+ ...record,
622
+ filePath: fromScan.filePath,
623
+ hasTranscript: true,
624
+ state: fromScan.state !== 'unknown' ? fromScan.state : record.state,
625
+ updatedAt: fromScan.updatedAt ?? record.updatedAt
626
+ });
627
+ }
628
+ else {
629
+ byId.set(record.id, record);
630
+ }
631
+ }
632
+ return [...byId.values()];
633
+ }
634
+ async function listCodexSubagentsFromStateDb(source, options) {
635
+ const output = await runStateDbQuery(codexDescendantsSql(source.sessionId), options);
636
+ if (output === null) {
637
+ return [];
638
+ }
639
+ const rows = [];
640
+ for (const line of output.split('\n')) {
641
+ const row = parseCodexThreadRow(line);
642
+ if (row && row.id !== source.sessionId) {
643
+ rows.push(row);
644
+ }
645
+ }
646
+ const records = await mapWithConcurrency(rows, 4, async (row) => {
647
+ let fileStat = null;
648
+ if (row.rolloutPath) {
649
+ try {
650
+ fileStat = await stat(row.rolloutPath);
651
+ }
652
+ catch {
653
+ fileStat = null;
654
+ }
655
+ }
656
+ let state;
657
+ if (fileStat) {
658
+ state = await readSubagentState({ provider: 'codex', sessionId: row.id, filePath: row.rolloutPath }, fileStat.mtimeMs, options);
659
+ }
660
+ else {
661
+ state = 'unknown';
662
+ }
663
+ if (row.edgeStatus === 'closed' && (state === 'working' || state === 'unknown')) {
664
+ // The spawn edge closing is the parent-side signal that this child's
665
+ // run ended even when its rollout tail never recorded task_complete.
666
+ state = 'done';
667
+ }
668
+ const name = row.nickname || leafAgentPathSegment(row.agentPath) || 'agent';
669
+ const record = {
670
+ id: row.id,
671
+ provider: 'codex',
672
+ kind: 'native',
673
+ name: row.role ? `${name} (${row.role})` : name,
674
+ // first_user_message on a teammate row is the parent prompt it forked
675
+ // with, which the transcript no longer shows — describing the row by its
676
+ // place in the agent tree keeps the list and the conversation in sync.
677
+ description: truncateDescription(row.agentPath || row.firstUserMessage || null),
678
+ model: row.model || null,
679
+ spawnDepth: row.depth,
680
+ // A direct child's parent is the pane session itself, which is not a
681
+ // row in this list — report it as top level.
682
+ parentId: row.parentId && row.parentId !== source.sessionId ? row.parentId : null,
683
+ state,
684
+ updatedAt: fileStat
685
+ ? new Date(fileStat.mtimeMs).toISOString()
686
+ : row.updatedAtMs > 0
687
+ ? new Date(row.updatedAtMs).toISOString()
688
+ : null,
689
+ hasTranscript: fileStat !== null,
690
+ filePath: fileStat ? row.rolloutPath : null
691
+ };
692
+ return record;
693
+ });
694
+ return records;
695
+ }
696
+ function codexDescendantsSql(threadId) {
697
+ return [
698
+ 'WITH RECURSIVE descendants(id, parent, depth, status) AS (',
699
+ ` SELECT child_thread_id, parent_thread_id, 1, status FROM thread_spawn_edges WHERE parent_thread_id = ${sqlString(threadId)}`,
700
+ ' UNION ALL',
701
+ ' SELECT e.child_thread_id, e.parent_thread_id, d.depth + 1, e.status',
702
+ ' FROM thread_spawn_edges e JOIN descendants d ON e.parent_thread_id = d.id',
703
+ ' WHERE d.depth < 6',
704
+ ')',
705
+ "SELECT t.id || char(9) || d.parent || char(9) || d.depth || char(9) || d.status || char(9) || COALESCE(t.agent_nickname, '')",
706
+ " || char(9) || COALESCE(t.agent_path, '') || char(9) || COALESCE(t.agent_role, '')",
707
+ " || char(9) || COALESCE(t.model, '') || char(9) || COALESCE(t.updated_at_ms, t.updated_at * 1000, 0)",
708
+ ` || char(9) || substr(replace(replace(COALESCE(t.first_user_message, ''), char(9), ' '), char(10), ' '), 1, ${DESCRIPTION_LIMIT})`,
709
+ " || char(9) || COALESCE(t.rollout_path, '')",
710
+ 'FROM descendants d JOIN threads t ON t.id = d.id',
711
+ 'LIMIT 200;'
712
+ ].join('\n');
713
+ }
714
+ function parseCodexThreadRow(line) {
715
+ // Strip only line terminators: a full trim() would eat the trailing tab
716
+ // that marks an empty last field (NULL rollout_path) and drop the row.
717
+ const trimmed = line.replace(/[\r\n]+$/, '');
718
+ if (!trimmed.trim()) {
719
+ return null;
720
+ }
721
+ const parts = trimmed.split('\t');
722
+ if (parts.length < 11) {
723
+ return null;
724
+ }
725
+ const [id, parentId, depthRaw, edgeStatus, nickname, agentPath, role, model, updatedRaw, firstUserMessage, ...rolloutParts] = parts;
726
+ if (!id || !isSessionId(id)) {
727
+ return null;
728
+ }
729
+ const depth = Number(depthRaw);
730
+ const updatedAtMs = Number(updatedRaw);
731
+ // Empty when threads.rollout_path was NULL: the child still gets a row
732
+ // (metadata + edge status), just without an openable transcript.
733
+ const rolloutPath = rolloutParts.join('\t');
734
+ return {
735
+ id,
736
+ parentId: parentId ?? '',
737
+ depth: Number.isInteger(depth) && depth > 0 ? depth : 1,
738
+ edgeStatus: edgeStatus ?? '',
739
+ nickname: nickname ?? '',
740
+ agentPath: agentPath ?? '',
741
+ role: role ?? '',
742
+ model: model ?? '',
743
+ updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : 0,
744
+ firstUserMessage: firstUserMessage ?? '',
745
+ rolloutPath
746
+ };
747
+ }
748
+ async function listCodexSubagentsFromRollout(source, options) {
749
+ const children = await collectCodexSubagentActivity(source.filePath);
750
+ if (children.size === 0) {
751
+ return [];
752
+ }
753
+ // sub_agent_activity also references ancestors (e.g. the /root
754
+ // coordinator) when this thread is itself a subagent — those are not
755
+ // children of this pane's conversation.
756
+ const ancestorIds = await readCodexAncestorIds(source.filePath);
757
+ const sessionsRoot = path.join(options.homeDir ?? os.homedir(), '.codex', 'sessions');
758
+ const records = await mapWithConcurrency([...children.entries()], 4, async ([threadId, info]) => {
759
+ if (threadId === source.sessionId || ancestorIds.has(threadId)) {
760
+ return null;
761
+ }
762
+ const matches = await findTranscriptFilesAsync('codex', threadId, sessionsRoot);
763
+ const filePath = matches.length === 1 ? matches[0] : null;
764
+ let fileStat = null;
765
+ if (filePath) {
766
+ try {
767
+ fileStat = await stat(filePath);
768
+ }
769
+ catch {
770
+ fileStat = null;
771
+ }
772
+ }
773
+ let state = 'unknown';
774
+ if (filePath && fileStat) {
775
+ state = await readSubagentState({ provider: 'codex', sessionId: threadId, filePath }, fileStat.mtimeMs, options);
776
+ }
777
+ if (state === 'unknown' && info.lastKind === 'interrupted') {
778
+ state = 'stopped';
779
+ }
780
+ const record = {
781
+ id: threadId,
782
+ provider: 'codex',
783
+ kind: 'native',
784
+ name: leafAgentPathSegment(info.agentPath) || 'agent',
785
+ description: info.agentPath ? truncateDescription(info.agentPath) : null,
786
+ model: null,
787
+ spawnDepth: agentPathDepth(info.agentPath),
788
+ // Rollout activity events only name direct children of this thread.
789
+ parentId: null,
790
+ state,
791
+ updatedAt: fileStat ? new Date(fileStat.mtimeMs).toISOString() : null,
792
+ hasTranscript: filePath !== null && fileStat !== null,
793
+ filePath: fileStat ? filePath : null
794
+ };
795
+ return record;
796
+ });
797
+ return records.filter((record) => record !== null);
798
+ }
799
+ const ancestorIdCache = new Map();
800
+ const ANCESTOR_CACHE_LIMIT = 32;
801
+ async function readCodexAncestorIds(filePath) {
802
+ const ids = new Set();
803
+ let handle;
804
+ try {
805
+ // Ancestor session_meta records are the first lines of the rollout and
806
+ // never change, so one read per file identity is enough.
807
+ const fileStat = await stat(filePath);
808
+ const cached = ancestorIdCache.get(filePath);
809
+ if (cached && cached.dev === fileStat.dev && cached.ino === fileStat.ino) {
810
+ return cached.ids;
811
+ }
812
+ handle = await openFile(filePath, 'r');
813
+ const buffer = Buffer.alloc(SESSION_META_SCAN_BYTES);
814
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
815
+ for (const line of buffer.subarray(0, bytesRead).toString('utf8').split('\n')) {
816
+ // Forked/subagent rollouts replay every ancestor's session_meta at the
817
+ // top, so this single pass yields the whole ancestor chain.
818
+ if (!line.includes('"session_meta"')) {
819
+ continue;
820
+ }
821
+ const payload = readRecord(parseJsonLine(line)?.payload);
822
+ if (!payload) {
823
+ continue;
824
+ }
825
+ for (const key of ['id', 'session_id', 'parent_thread_id', 'forked_from_id']) {
826
+ const value = payload[key];
827
+ if (typeof value === 'string' && isSessionId(value)) {
828
+ ids.add(value.toLowerCase());
829
+ }
830
+ }
831
+ }
832
+ ancestorIdCache.delete(filePath);
833
+ ancestorIdCache.set(filePath, { dev: fileStat.dev, ino: fileStat.ino, ids });
834
+ while (ancestorIdCache.size > ANCESTOR_CACHE_LIMIT) {
835
+ const oldest = ancestorIdCache.keys().next().value;
836
+ if (typeof oldest !== 'string' || oldest === filePath) {
837
+ break;
838
+ }
839
+ ancestorIdCache.delete(oldest);
840
+ }
841
+ }
842
+ catch {
843
+ // Fall through with what we collected; exclusion is best-effort.
844
+ }
845
+ finally {
846
+ await handle?.close().catch(() => undefined);
847
+ }
848
+ return ids;
849
+ }
850
+ async function collectCodexSubagentActivity(parentFilePath) {
851
+ let fileStat;
852
+ try {
853
+ fileStat = await stat(parentFilePath);
854
+ }
855
+ catch {
856
+ return new Map();
857
+ }
858
+ let state = codexScanStates.get(parentFilePath);
859
+ if (!state || state.dev !== fileStat.dev || state.ino !== fileStat.ino || fileStat.size < state.offset) {
860
+ state = {
861
+ dev: fileStat.dev,
862
+ ino: fileStat.ino,
863
+ offset: Math.max(0, fileStat.size - CODEX_SCAN_INITIAL_TAIL_BYTES),
864
+ remainder: '',
865
+ // A tail start lands mid-line; treat everything up to the first
866
+ // newline as an oversized fragment to discard.
867
+ skippingOversizedLine: fileStat.size > CODEX_SCAN_INITIAL_TAIL_BYTES,
868
+ decoder: new StringDecoder('utf8'),
869
+ children: new Map()
870
+ };
871
+ }
872
+ touchScanState(codexScanStates, parentFilePath, state);
873
+ if (state.offset >= fileStat.size) {
874
+ return state.children;
875
+ }
876
+ const handle = await openFile(parentFilePath, 'r');
877
+ try {
878
+ const buffer = Buffer.alloc(SCAN_CHUNK_BYTES);
879
+ while (state.offset < fileStat.size) {
880
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, fileStat.size - state.offset), state.offset);
881
+ if (bytesRead === 0) {
882
+ break;
883
+ }
884
+ state.offset += bytesRead;
885
+ consumeCodexScanText(state, state.decoder.write(buffer.subarray(0, bytesRead)));
886
+ await yieldToEventLoop();
887
+ }
888
+ }
889
+ finally {
890
+ await handle.close().catch(() => undefined);
891
+ }
892
+ return state.children;
893
+ }
894
+ function consumeCodexScanText(state, text) {
895
+ let cursor = 0;
896
+ while (cursor < text.length) {
897
+ const newline = text.indexOf('\n', cursor);
898
+ const end = newline === -1 ? text.length : newline;
899
+ const fragment = text.slice(cursor, end);
900
+ if (state.skippingOversizedLine) {
901
+ // Discard until the line ends; activity records are always small.
902
+ }
903
+ else if (state.remainder.length + fragment.length > CODEX_SCAN_MAX_LINE_CHARS) {
904
+ state.skippingOversizedLine = true;
905
+ state.remainder = '';
906
+ }
907
+ else {
908
+ state.remainder += fragment;
909
+ }
910
+ if (newline === -1) {
911
+ break;
912
+ }
913
+ if (!state.skippingOversizedLine && state.remainder.includes('"sub_agent_activity"')) {
914
+ recordCodexSubagentActivityLine(state, state.remainder);
915
+ }
916
+ state.remainder = '';
917
+ state.skippingOversizedLine = false;
918
+ cursor = newline + 1;
919
+ }
920
+ }
921
+ function recordCodexSubagentActivityLine(state, line) {
922
+ const record = parseJsonLine(line);
923
+ const payload = readRecord(record?.payload);
924
+ if (!payload || payload.type !== 'sub_agent_activity') {
925
+ return;
926
+ }
927
+ const threadId = typeof payload.agent_thread_id === 'string' ? payload.agent_thread_id.toLowerCase() : '';
928
+ if (!isSessionId(threadId)) {
929
+ return;
930
+ }
931
+ const existing = state.children.get(threadId) ?? { agentPath: null, lastKind: null };
932
+ existing.agentPath = readNonEmptyString(payload.agent_path) ?? existing.agentPath;
933
+ existing.lastKind = readNonEmptyString(payload.kind) ?? existing.lastKind;
934
+ state.children.set(threadId, existing);
935
+ }
936
+ // --- shared helpers ---
937
+ async function readSubagentState(child, mtimeMs, options) {
938
+ const key = `subagent\0${child.provider}\0${child.sessionId}\0${child.filePath}`;
939
+ rememberStatusKey(key);
940
+ let state;
941
+ try {
942
+ state = (await statusTracker.readSnapshot(key, child)).status.state;
943
+ }
944
+ catch {
945
+ return 'unknown';
946
+ }
947
+ const now = options.now ?? Date.now();
948
+ if (state === 'working' && now - mtimeMs > WORKING_STALE_MS) {
949
+ return 'unknown';
950
+ }
951
+ return state;
952
+ }
953
+ function rememberStatusKey(key) {
954
+ if (statusKeys.has(key)) {
955
+ statusKeys.delete(key);
956
+ statusKeys.add(key);
957
+ return;
958
+ }
959
+ statusKeys.add(key);
960
+ while (statusKeys.size > STATUS_KEY_LIMIT) {
961
+ const oldest = statusKeys.values().next().value;
962
+ if (typeof oldest !== 'string') {
963
+ break;
964
+ }
965
+ statusKeys.delete(oldest);
966
+ statusTracker.reset(oldest);
967
+ }
968
+ }
969
+ async function readCodexThreadDetails(threadIds, options) {
970
+ const details = new Map();
971
+ const validIds = threadIds.filter((threadId) => isSessionId(threadId));
972
+ if (validIds.length === 0) {
973
+ return details;
974
+ }
975
+ const sql = [
976
+ "SELECT id || char(9) || COALESCE(model, '')",
977
+ ` || char(9) || substr(replace(replace(COALESCE(first_user_message, ''), char(9), ' '), char(10), ' '), 1, ${DESCRIPTION_LIMIT})`,
978
+ `FROM threads WHERE id IN (${validIds.map(sqlString).join(', ')});`
979
+ ].join('\n');
980
+ const output = await runStateDbQuery(sql, options);
981
+ if (output === null) {
982
+ return details;
983
+ }
984
+ for (const line of output.split('\n')) {
985
+ const trimmed = line.replace(/[\r\n]+$/, '');
986
+ if (!trimmed.trim())
987
+ continue;
988
+ const [id, model, firstUserMessage] = trimmed.split('\t');
989
+ if (!id || !isSessionId(id))
990
+ continue;
991
+ details.set(id, {
992
+ model: model || null,
993
+ firstUserMessage: firstUserMessage || null
994
+ });
995
+ }
996
+ return details;
997
+ }
998
+ async function runStateDbQuery(sql, options) {
999
+ const stateDb = path.join(options.homeDir ?? os.homedir(), '.codex', 'state_5.sqlite');
1000
+ if (options.runSqlite) {
1001
+ return await options.runSqlite(stateDb, sql);
1002
+ }
1003
+ try {
1004
+ await stat(stateDb);
1005
+ }
1006
+ catch {
1007
+ return null;
1008
+ }
1009
+ try {
1010
+ return (await runCommand('sqlite3', ['-batch', '-noheader', stateDb, sql], { timeoutMs: SQLITE_TIMEOUT_MS })).stdout;
1011
+ }
1012
+ catch {
1013
+ return null;
1014
+ }
1015
+ }
1016
+ function touchScanState(states, key, state) {
1017
+ states.delete(key);
1018
+ states.set(key, state);
1019
+ while (states.size > SCAN_STATE_LIMIT) {
1020
+ const oldest = states.keys().next().value;
1021
+ if (typeof oldest !== 'string' || oldest === key) {
1022
+ break;
1023
+ }
1024
+ states.delete(oldest);
1025
+ }
1026
+ }
1027
+ function leafAgentPathSegment(agentPath) {
1028
+ if (!agentPath) {
1029
+ return '';
1030
+ }
1031
+ const segments = agentPath.split('/').filter(Boolean);
1032
+ return segments[segments.length - 1] ?? '';
1033
+ }
1034
+ function agentPathDepth(agentPath) {
1035
+ if (!agentPath) {
1036
+ return 1;
1037
+ }
1038
+ // agent_path is rooted at /root; each further segment is one spawn level.
1039
+ const segments = agentPath.split('/').filter(Boolean);
1040
+ return Math.max(1, segments.length - 1);
1041
+ }
1042
+ function truncateDescription(value) {
1043
+ if (!value) {
1044
+ return null;
1045
+ }
1046
+ const compact = value.replace(/\s+/g, ' ').trim();
1047
+ if (!compact) {
1048
+ return null;
1049
+ }
1050
+ return compact.length > DESCRIPTION_LIMIT ? `${compact.slice(0, DESCRIPTION_LIMIT - 1)}…` : compact;
1051
+ }
1052
+ function yieldToEventLoop() {
1053
+ return new Promise((resolve) => setImmediate(resolve));
1054
+ }
1055
+ function parseJsonLine(line) {
1056
+ if (!line) {
1057
+ return null;
1058
+ }
1059
+ try {
1060
+ return readRecord(JSON.parse(line));
1061
+ }
1062
+ catch {
1063
+ return null;
1064
+ }
1065
+ }
1066
+ function readRecord(value) {
1067
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
1068
+ }
1069
+ function readNonEmptyString(value) {
1070
+ return typeof value === 'string' && value.trim() ? value : null;
1071
+ }
1072
+ //# sourceMappingURL=subagentSources.js.map