nterminal 1.2.80 → 1.2.81

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-Dq1fT9EP.js +1 -0
  2. package/dist/client/assets/{TranscriptMarkdownBody-BFu2FzgT.js → TranscriptMarkdownBody-DDFnUWuE.js} +1 -1
  3. package/dist/client/assets/{index-BFs4imuQ.js → index-D7w92nCI.js} +1 -1
  4. package/dist/client/assets/index-Di6LPXrS.js +57 -0
  5. package/dist/client/assets/{index-rROkcwjD.css → index-hoeidjW4.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 +151 -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 +15 -0
  25. package/dist/server/terminal/subagentSources.js +801 -0
  26. package/dist/server/terminal/subagentSources.js.map +1 -0
  27. package/dist/server/terminal/transcriptHistory.d.ts +1 -0
  28. package/dist/server/terminal/transcriptHistory.js +24 -15
  29. package/dist/server/terminal/transcriptHistory.js.map +1 -1
  30. package/dist/shared/protocol.d.ts +18 -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,801 @@
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
+ const LIST_CACHE_TTL_MS = 4_000;
12
+ const LIST_LIMIT = 100;
13
+ // A transcript that claims "working" but has not been written for this long
14
+ // is treated as unknown: a killed CLI never appends its terminal record.
15
+ const WORKING_STALE_MS = 10 * 60_000;
16
+ const DESCRIPTION_LIMIT = 240;
17
+ const STATUS_KEY_LIMIT = 256;
18
+ const SCAN_STATE_LIMIT = 16;
19
+ const SCAN_CHUNK_BYTES = 1024 * 1024;
20
+ // The cross-id pattern is under 60 chars, so this overlap can never split it.
21
+ const CROSS_SCAN_CARRY_CHARS = 160;
22
+ // First scan of a Claude parent starts this far from the end. Banner records
23
+ // older than this predate anything worth listing, and an uncapped scan of a
24
+ // multi-hundred-MB transcript would sit on the request path.
25
+ const CROSS_SCAN_INITIAL_TAIL_BYTES = 64 * 1024 * 1024;
26
+ // First scan of a codex parent starts this far from the end; spawn events
27
+ // deeper in the rollout than this predate anything worth listing live.
28
+ const CODEX_SCAN_INITIAL_TAIL_BYTES = 16 * 1024 * 1024;
29
+ const CODEX_SCAN_MAX_LINE_CHARS = 512 * 1024;
30
+ const SESSION_META_SCAN_BYTES = 256 * 1024;
31
+ const CROSS_VERIFY_POSITIVE_TTL_MS = 10 * 60_000;
32
+ const CROSS_VERIFY_NEGATIVE_TTL_MS = 30_000;
33
+ const SQLITE_TIMEOUT_MS = 1_000;
34
+ // A child's transcript path never changes once created, so paging can skip
35
+ // full rediscovery for far longer than the status-freshness list cache.
36
+ const TRANSCRIPT_PATH_CACHE_TTL_MS = 10 * 60_000;
37
+ const listCache = new TtlCache();
38
+ const listInflight = new KeyedSingleFlight();
39
+ const transcriptPathCache = new TtlCache();
40
+ const crossVerificationCache = new TtlCache();
41
+ const statusTracker = new AgentTranscriptTracker();
42
+ const statusKeys = new Set();
43
+ const crossScanStates = new Map();
44
+ const codexScanStates = new Map();
45
+ export function clearSubagentCaches() {
46
+ listCache.clear();
47
+ listInflight.clear();
48
+ transcriptPathCache.clear();
49
+ crossVerificationCache.clear();
50
+ for (const key of statusKeys) {
51
+ statusTracker.reset(key);
52
+ }
53
+ statusKeys.clear();
54
+ crossScanStates.clear();
55
+ codexScanStates.clear();
56
+ ancestorIdCache.clear();
57
+ }
58
+ export function subagentSummary(record) {
59
+ const { filePath: _filePath, ...summary } = record;
60
+ return summary;
61
+ }
62
+ export async function listTerminalSubagents(source, options = {}) {
63
+ const key = `${source.provider}\0${source.sessionId}\0${source.filePath}`;
64
+ const cached = listCache.get(key);
65
+ if (cached) {
66
+ return cached.value;
67
+ }
68
+ return listInflight.run(key, async () => {
69
+ let records;
70
+ try {
71
+ records = source.provider === 'claude'
72
+ ? await listClaudeSubagents(source, options)
73
+ : await listCodexSubagents(source, options);
74
+ }
75
+ catch {
76
+ records = [];
77
+ }
78
+ records.sort(compareSubagents);
79
+ if (records.length > LIST_LIMIT) {
80
+ records.length = LIST_LIMIT;
81
+ }
82
+ listCache.set(key, records, LIST_CACHE_TTL_MS);
83
+ return records;
84
+ });
85
+ }
86
+ // Resolve one child's transcript source for paging without re-running full
87
+ // discovery on every page request. Falls back to the listing on a cache miss
88
+ // and invalidates when the cached file disappeared.
89
+ export async function resolveSubagentTranscriptSource(source, subagentId, options = {}) {
90
+ const key = `${source.provider}\0${source.sessionId}\0${source.filePath}\0${subagentId}`;
91
+ const cached = transcriptPathCache.get(key);
92
+ if (cached) {
93
+ try {
94
+ await stat(cached.value.filePath);
95
+ return cached.value;
96
+ }
97
+ catch {
98
+ transcriptPathCache.delete(key);
99
+ }
100
+ }
101
+ const records = await listTerminalSubagents(source, options);
102
+ const record = records.find((candidate) => candidate.id === subagentId);
103
+ if (!record?.filePath) {
104
+ return null;
105
+ }
106
+ const child = { provider: record.provider, sessionId: record.id, filePath: record.filePath };
107
+ transcriptPathCache.set(key, child, TRANSCRIPT_PATH_CACHE_TTL_MS);
108
+ return child;
109
+ }
110
+ function compareSubagents(a, b) {
111
+ const aWorking = a.state === 'working' ? 0 : 1;
112
+ const bWorking = b.state === 'working' ? 0 : 1;
113
+ if (aWorking !== bWorking) {
114
+ return aWorking - bWorking;
115
+ }
116
+ // ISO-8601 sorts lexicographically; unknown timestamps sink to the bottom.
117
+ return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
118
+ }
119
+ // --- Claude parents ---
120
+ async function listClaudeSubagents(source, options) {
121
+ const [native, cross] = await Promise.all([
122
+ listClaudeNativeSubagents(source, options).catch(() => []),
123
+ listClaudeCrossCodexSubagents(source, options).catch(() => [])
124
+ ]);
125
+ return [...native, ...cross];
126
+ }
127
+ async function listClaudeNativeSubagents(source, options) {
128
+ const subagentsDir = path.join(path.dirname(source.filePath), source.sessionId, 'subagents');
129
+ let entries;
130
+ try {
131
+ entries = await readdir(subagentsDir, { withFileTypes: true });
132
+ }
133
+ catch {
134
+ return [];
135
+ }
136
+ const transcripts = [];
137
+ for (const entry of entries) {
138
+ if (!entry.isFile())
139
+ continue;
140
+ const match = /^agent-([A-Za-z0-9_-]+)\.jsonl$/.exec(entry.name);
141
+ if (match) {
142
+ transcripts.push({ agentId: match[1], fileName: entry.name });
143
+ }
144
+ }
145
+ const records = await mapWithConcurrency(transcripts, 4, async ({ agentId, fileName }) => {
146
+ const filePath = path.join(subagentsDir, fileName);
147
+ let fileStat;
148
+ try {
149
+ fileStat = await stat(filePath);
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ const meta = await readClaudeSubagentMeta(path.join(subagentsDir, `agent-${agentId}.meta.json`));
155
+ const state = await readSubagentState({ provider: 'claude', sessionId: agentId, filePath }, fileStat.mtimeMs, options);
156
+ const record = {
157
+ id: agentId,
158
+ provider: 'claude',
159
+ kind: 'native',
160
+ name: meta?.agentType ?? 'subagent',
161
+ description: truncateDescription(meta?.description ?? null),
162
+ model: null,
163
+ spawnDepth: meta?.spawnDepth ?? 1,
164
+ state,
165
+ updatedAt: new Date(fileStat.mtimeMs).toISOString(),
166
+ hasTranscript: true,
167
+ filePath
168
+ };
169
+ return record;
170
+ });
171
+ return records.filter((record) => record !== null);
172
+ }
173
+ async function readClaudeSubagentMeta(metaPath) {
174
+ let parsed;
175
+ try {
176
+ parsed = JSON.parse(await readFile(metaPath, 'utf8'));
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ const record = readRecord(parsed);
182
+ if (!record) {
183
+ return null;
184
+ }
185
+ const spawnDepth = typeof record.spawnDepth === 'number' && Number.isInteger(record.spawnDepth) && record.spawnDepth > 0
186
+ ? record.spawnDepth
187
+ : null;
188
+ return {
189
+ agentType: readNonEmptyString(record.agentType),
190
+ description: readNonEmptyString(record.description),
191
+ spawnDepth
192
+ };
193
+ }
194
+ async function listClaudeCrossCodexSubagents(source, options) {
195
+ const ids = await collectCrossSessionIds(source.filePath);
196
+ if (ids.size === 0) {
197
+ return [];
198
+ }
199
+ const candidates = (await mapWithConcurrency([...ids], 4, (threadId) => verifyCrossCandidate(threadId, source, options))).filter((candidate) => candidate !== null);
200
+ if (candidates.length === 0) {
201
+ return [];
202
+ }
203
+ const details = await readCodexThreadDetails(candidates.map((candidate) => candidate.threadId), options);
204
+ const records = await mapWithConcurrency(candidates, 4, async (candidate) => {
205
+ let fileStat;
206
+ try {
207
+ fileStat = await stat(candidate.filePath);
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ const state = await readSubagentState({ provider: 'codex', sessionId: candidate.threadId, filePath: candidate.filePath }, fileStat.mtimeMs, options);
213
+ const detail = details.get(candidate.threadId);
214
+ const record = {
215
+ id: candidate.threadId,
216
+ provider: 'codex',
217
+ kind: 'cross',
218
+ name: 'codex exec',
219
+ description: truncateDescription(detail?.firstUserMessage ?? null),
220
+ model: detail?.model ?? null,
221
+ spawnDepth: 1,
222
+ state,
223
+ updatedAt: new Date(fileStat.mtimeMs).toISOString(),
224
+ hasTranscript: true,
225
+ filePath: candidate.filePath
226
+ };
227
+ return record;
228
+ });
229
+ return records.filter((record) => record !== null);
230
+ }
231
+ async function collectCrossSessionIds(parentFilePath) {
232
+ let fileStat;
233
+ try {
234
+ fileStat = await stat(parentFilePath);
235
+ }
236
+ catch {
237
+ return new Set();
238
+ }
239
+ let state = crossScanStates.get(parentFilePath);
240
+ if (!state || state.dev !== fileStat.dev || state.ino !== fileStat.ino || fileStat.size < state.offset) {
241
+ state = {
242
+ dev: fileStat.dev,
243
+ ino: fileStat.ino,
244
+ // The substring scan tolerates a mid-line start; the carry only needs
245
+ // to cover pattern-splitting at chunk boundaries.
246
+ offset: Math.max(0, fileStat.size - CROSS_SCAN_INITIAL_TAIL_BYTES),
247
+ carry: '',
248
+ decoder: new StringDecoder('utf8'),
249
+ ids: new Set()
250
+ };
251
+ }
252
+ touchScanState(crossScanStates, parentFilePath, state);
253
+ if (state.offset >= fileStat.size) {
254
+ return state.ids;
255
+ }
256
+ const pattern = new RegExp(`(?:session|thread) id: (${sessionIdPatternSource})`, 'g');
257
+ const handle = await openFile(parentFilePath, 'r');
258
+ try {
259
+ const buffer = Buffer.alloc(SCAN_CHUNK_BYTES);
260
+ while (state.offset < fileStat.size) {
261
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, fileStat.size - state.offset), state.offset);
262
+ if (bytesRead === 0) {
263
+ break;
264
+ }
265
+ state.offset += bytesRead;
266
+ const text = state.carry + state.decoder.write(buffer.subarray(0, bytesRead));
267
+ for (const match of text.matchAll(pattern)) {
268
+ state.ids.add(match[1].toLowerCase());
269
+ }
270
+ state.carry = text.slice(-CROSS_SCAN_CARRY_CHARS);
271
+ await yieldToEventLoop();
272
+ }
273
+ }
274
+ finally {
275
+ await handle.close().catch(() => undefined);
276
+ }
277
+ return state.ids;
278
+ }
279
+ async function verifyCrossCandidate(threadId, source, options) {
280
+ if (!isSessionId(threadId) || threadId === source.sessionId) {
281
+ return null;
282
+ }
283
+ const sessionsRoot = path.join(options.homeDir ?? os.homedir(), '.codex', 'sessions');
284
+ const parentProjectDir = path.basename(path.dirname(source.filePath));
285
+ const cacheKey = `${sessionsRoot}\0${threadId}\0${parentProjectDir}\0${source.sessionId}`;
286
+ const cached = crossVerificationCache.get(cacheKey);
287
+ if (cached) {
288
+ return cached.value;
289
+ }
290
+ const candidate = await verifyCrossCandidateUncached(threadId, source, sessionsRoot, parentProjectDir);
291
+ crossVerificationCache.set(cacheKey, candidate, candidate ? CROSS_VERIFY_POSITIVE_TTL_MS : CROSS_VERIFY_NEGATIVE_TTL_MS);
292
+ return candidate;
293
+ }
294
+ async function verifyCrossCandidateUncached(threadId, source, sessionsRoot, parentProjectDir) {
295
+ const matches = await findTranscriptFilesAsync('codex', threadId, sessionsRoot);
296
+ if (matches.length !== 1) {
297
+ return null;
298
+ }
299
+ const filePath = matches[0];
300
+ const meta = await readCodexSessionMeta(filePath, threadId);
301
+ // Only non-interactive `codex exec` rollouts qualify: an interactive codex
302
+ // session in the same project whose banner got quoted into the Claude
303
+ // conversation must not surface as a subagent of it.
304
+ if (!meta || meta.source !== 'exec' || !meta.cwd) {
305
+ return null;
306
+ }
307
+ const belongsToParent = encodeClaudeProjectPath(meta.cwd) === parentProjectDir || meta.cwd.includes(source.sessionId);
308
+ return belongsToParent ? { threadId, filePath } : null;
309
+ }
310
+ async function readCodexSessionMeta(filePath, threadId) {
311
+ let handle;
312
+ try {
313
+ handle = await openFile(filePath, 'r');
314
+ const buffer = Buffer.alloc(SESSION_META_SCAN_BYTES);
315
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
316
+ const lines = buffer.subarray(0, bytesRead).toString('utf8').split('\n');
317
+ for (const line of lines) {
318
+ // Forked rollouts replay ancestor session_meta records; only the one
319
+ // whose id matches the filename thread describes this rollout.
320
+ if (!line.includes('"session_meta"')) {
321
+ continue;
322
+ }
323
+ const record = parseJsonLine(line);
324
+ const payload = readRecord(record?.payload);
325
+ if (!payload || payload.id !== threadId) {
326
+ continue;
327
+ }
328
+ return {
329
+ cwd: readNonEmptyString(payload.cwd),
330
+ source: typeof payload.source === 'string' ? payload.source : null
331
+ };
332
+ }
333
+ return null;
334
+ }
335
+ catch {
336
+ return null;
337
+ }
338
+ finally {
339
+ await handle?.close().catch(() => undefined);
340
+ }
341
+ }
342
+ // --- Codex parents ---
343
+ async function listCodexSubagents(source, options) {
344
+ const [fromDb, fromRollout] = await Promise.all([
345
+ listCodexSubagentsFromStateDb(source, options).catch(() => []),
346
+ listCodexSubagentsFromRollout(source, options).catch(() => [])
347
+ ]);
348
+ const byId = new Map();
349
+ // DB rows carry richer metadata (nickname, model, prompt), but when the
350
+ // DB's rollout_path went stale the rollout scan may still have located the
351
+ // real transcript — keep that file and its snapshot-derived state.
352
+ for (const record of fromRollout) {
353
+ byId.set(record.id, record);
354
+ }
355
+ for (const record of fromDb) {
356
+ const fromScan = byId.get(record.id);
357
+ if (fromScan?.hasTranscript && !record.hasTranscript) {
358
+ byId.set(record.id, {
359
+ ...record,
360
+ filePath: fromScan.filePath,
361
+ hasTranscript: true,
362
+ state: fromScan.state !== 'unknown' ? fromScan.state : record.state,
363
+ updatedAt: fromScan.updatedAt ?? record.updatedAt
364
+ });
365
+ }
366
+ else {
367
+ byId.set(record.id, record);
368
+ }
369
+ }
370
+ return [...byId.values()];
371
+ }
372
+ async function listCodexSubagentsFromStateDb(source, options) {
373
+ const output = await runStateDbQuery(codexDescendantsSql(source.sessionId), options);
374
+ if (output === null) {
375
+ return [];
376
+ }
377
+ const rows = [];
378
+ for (const line of output.split('\n')) {
379
+ const row = parseCodexThreadRow(line);
380
+ if (row && row.id !== source.sessionId) {
381
+ rows.push(row);
382
+ }
383
+ }
384
+ const records = await mapWithConcurrency(rows, 4, async (row) => {
385
+ let fileStat = null;
386
+ if (row.rolloutPath) {
387
+ try {
388
+ fileStat = await stat(row.rolloutPath);
389
+ }
390
+ catch {
391
+ fileStat = null;
392
+ }
393
+ }
394
+ let state;
395
+ if (fileStat) {
396
+ state = await readSubagentState({ provider: 'codex', sessionId: row.id, filePath: row.rolloutPath }, fileStat.mtimeMs, options);
397
+ }
398
+ else {
399
+ state = 'unknown';
400
+ }
401
+ if (row.edgeStatus === 'closed' && (state === 'working' || state === 'unknown')) {
402
+ // The spawn edge closing is the parent-side signal that this child's
403
+ // run ended even when its rollout tail never recorded task_complete.
404
+ state = 'done';
405
+ }
406
+ const name = row.nickname || leafAgentPathSegment(row.agentPath) || 'agent';
407
+ const record = {
408
+ id: row.id,
409
+ provider: 'codex',
410
+ kind: 'native',
411
+ name: row.role ? `${name} (${row.role})` : name,
412
+ description: truncateDescription(row.firstUserMessage || null),
413
+ model: row.model || null,
414
+ spawnDepth: row.depth,
415
+ state,
416
+ updatedAt: fileStat
417
+ ? new Date(fileStat.mtimeMs).toISOString()
418
+ : row.updatedAtMs > 0
419
+ ? new Date(row.updatedAtMs).toISOString()
420
+ : null,
421
+ hasTranscript: fileStat !== null,
422
+ filePath: fileStat ? row.rolloutPath : null
423
+ };
424
+ return record;
425
+ });
426
+ return records;
427
+ }
428
+ function codexDescendantsSql(threadId) {
429
+ return [
430
+ 'WITH RECURSIVE descendants(id, depth, status) AS (',
431
+ ` SELECT child_thread_id, 1, status FROM thread_spawn_edges WHERE parent_thread_id = ${sqlString(threadId)}`,
432
+ ' UNION ALL',
433
+ ' SELECT e.child_thread_id, d.depth + 1, e.status',
434
+ ' FROM thread_spawn_edges e JOIN descendants d ON e.parent_thread_id = d.id',
435
+ ' WHERE d.depth < 6',
436
+ ')',
437
+ "SELECT t.id || char(9) || d.depth || char(9) || d.status || char(9) || COALESCE(t.agent_nickname, '')",
438
+ " || char(9) || COALESCE(t.agent_path, '') || char(9) || COALESCE(t.agent_role, '')",
439
+ " || char(9) || COALESCE(t.model, '') || char(9) || COALESCE(t.updated_at_ms, t.updated_at * 1000, 0)",
440
+ ` || char(9) || substr(replace(replace(COALESCE(t.first_user_message, ''), char(9), ' '), char(10), ' '), 1, ${DESCRIPTION_LIMIT})`,
441
+ " || char(9) || COALESCE(t.rollout_path, '')",
442
+ 'FROM descendants d JOIN threads t ON t.id = d.id',
443
+ 'LIMIT 200;'
444
+ ].join('\n');
445
+ }
446
+ function parseCodexThreadRow(line) {
447
+ // Strip only line terminators: a full trim() would eat the trailing tab
448
+ // that marks an empty last field (NULL rollout_path) and drop the row.
449
+ const trimmed = line.replace(/[\r\n]+$/, '');
450
+ if (!trimmed.trim()) {
451
+ return null;
452
+ }
453
+ const parts = trimmed.split('\t');
454
+ if (parts.length < 10) {
455
+ return null;
456
+ }
457
+ const [id, depthRaw, edgeStatus, nickname, agentPath, role, model, updatedRaw, firstUserMessage, ...rolloutParts] = parts;
458
+ if (!id || !isSessionId(id)) {
459
+ return null;
460
+ }
461
+ const depth = Number(depthRaw);
462
+ const updatedAtMs = Number(updatedRaw);
463
+ // Empty when threads.rollout_path was NULL: the child still gets a row
464
+ // (metadata + edge status), just without an openable transcript.
465
+ const rolloutPath = rolloutParts.join('\t');
466
+ return {
467
+ id,
468
+ depth: Number.isInteger(depth) && depth > 0 ? depth : 1,
469
+ edgeStatus: edgeStatus ?? '',
470
+ nickname: nickname ?? '',
471
+ agentPath: agentPath ?? '',
472
+ role: role ?? '',
473
+ model: model ?? '',
474
+ updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : 0,
475
+ firstUserMessage: firstUserMessage ?? '',
476
+ rolloutPath
477
+ };
478
+ }
479
+ async function listCodexSubagentsFromRollout(source, options) {
480
+ const children = await collectCodexSubagentActivity(source.filePath);
481
+ if (children.size === 0) {
482
+ return [];
483
+ }
484
+ // sub_agent_activity also references ancestors (e.g. the /root
485
+ // coordinator) when this thread is itself a subagent — those are not
486
+ // children of this pane's conversation.
487
+ const ancestorIds = await readCodexAncestorIds(source.filePath);
488
+ const sessionsRoot = path.join(options.homeDir ?? os.homedir(), '.codex', 'sessions');
489
+ const records = await mapWithConcurrency([...children.entries()], 4, async ([threadId, info]) => {
490
+ if (threadId === source.sessionId || ancestorIds.has(threadId)) {
491
+ return null;
492
+ }
493
+ const matches = await findTranscriptFilesAsync('codex', threadId, sessionsRoot);
494
+ const filePath = matches.length === 1 ? matches[0] : null;
495
+ let fileStat = null;
496
+ if (filePath) {
497
+ try {
498
+ fileStat = await stat(filePath);
499
+ }
500
+ catch {
501
+ fileStat = null;
502
+ }
503
+ }
504
+ let state = 'unknown';
505
+ if (filePath && fileStat) {
506
+ state = await readSubagentState({ provider: 'codex', sessionId: threadId, filePath }, fileStat.mtimeMs, options);
507
+ }
508
+ if (state === 'unknown' && info.lastKind === 'interrupted') {
509
+ state = 'stopped';
510
+ }
511
+ const record = {
512
+ id: threadId,
513
+ provider: 'codex',
514
+ kind: 'native',
515
+ name: leafAgentPathSegment(info.agentPath) || 'agent',
516
+ description: info.agentPath ? truncateDescription(info.agentPath) : null,
517
+ model: null,
518
+ spawnDepth: agentPathDepth(info.agentPath),
519
+ state,
520
+ updatedAt: fileStat ? new Date(fileStat.mtimeMs).toISOString() : null,
521
+ hasTranscript: filePath !== null && fileStat !== null,
522
+ filePath: fileStat ? filePath : null
523
+ };
524
+ return record;
525
+ });
526
+ return records.filter((record) => record !== null);
527
+ }
528
+ const ancestorIdCache = new Map();
529
+ const ANCESTOR_CACHE_LIMIT = 32;
530
+ async function readCodexAncestorIds(filePath) {
531
+ const ids = new Set();
532
+ let handle;
533
+ try {
534
+ // Ancestor session_meta records are the first lines of the rollout and
535
+ // never change, so one read per file identity is enough.
536
+ const fileStat = await stat(filePath);
537
+ const cached = ancestorIdCache.get(filePath);
538
+ if (cached && cached.dev === fileStat.dev && cached.ino === fileStat.ino) {
539
+ return cached.ids;
540
+ }
541
+ handle = await openFile(filePath, 'r');
542
+ const buffer = Buffer.alloc(SESSION_META_SCAN_BYTES);
543
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
544
+ for (const line of buffer.subarray(0, bytesRead).toString('utf8').split('\n')) {
545
+ // Forked/subagent rollouts replay every ancestor's session_meta at the
546
+ // top, so this single pass yields the whole ancestor chain.
547
+ if (!line.includes('"session_meta"')) {
548
+ continue;
549
+ }
550
+ const payload = readRecord(parseJsonLine(line)?.payload);
551
+ if (!payload) {
552
+ continue;
553
+ }
554
+ for (const key of ['id', 'session_id', 'parent_thread_id', 'forked_from_id']) {
555
+ const value = payload[key];
556
+ if (typeof value === 'string' && isSessionId(value)) {
557
+ ids.add(value.toLowerCase());
558
+ }
559
+ }
560
+ }
561
+ ancestorIdCache.delete(filePath);
562
+ ancestorIdCache.set(filePath, { dev: fileStat.dev, ino: fileStat.ino, ids });
563
+ while (ancestorIdCache.size > ANCESTOR_CACHE_LIMIT) {
564
+ const oldest = ancestorIdCache.keys().next().value;
565
+ if (typeof oldest !== 'string' || oldest === filePath) {
566
+ break;
567
+ }
568
+ ancestorIdCache.delete(oldest);
569
+ }
570
+ }
571
+ catch {
572
+ // Fall through with what we collected; exclusion is best-effort.
573
+ }
574
+ finally {
575
+ await handle?.close().catch(() => undefined);
576
+ }
577
+ return ids;
578
+ }
579
+ async function collectCodexSubagentActivity(parentFilePath) {
580
+ let fileStat;
581
+ try {
582
+ fileStat = await stat(parentFilePath);
583
+ }
584
+ catch {
585
+ return new Map();
586
+ }
587
+ let state = codexScanStates.get(parentFilePath);
588
+ if (!state || state.dev !== fileStat.dev || state.ino !== fileStat.ino || fileStat.size < state.offset) {
589
+ state = {
590
+ dev: fileStat.dev,
591
+ ino: fileStat.ino,
592
+ offset: Math.max(0, fileStat.size - CODEX_SCAN_INITIAL_TAIL_BYTES),
593
+ remainder: '',
594
+ // A tail start lands mid-line; treat everything up to the first
595
+ // newline as an oversized fragment to discard.
596
+ skippingOversizedLine: fileStat.size > CODEX_SCAN_INITIAL_TAIL_BYTES,
597
+ decoder: new StringDecoder('utf8'),
598
+ children: new Map()
599
+ };
600
+ }
601
+ touchScanState(codexScanStates, parentFilePath, state);
602
+ if (state.offset >= fileStat.size) {
603
+ return state.children;
604
+ }
605
+ const handle = await openFile(parentFilePath, 'r');
606
+ try {
607
+ const buffer = Buffer.alloc(SCAN_CHUNK_BYTES);
608
+ while (state.offset < fileStat.size) {
609
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, fileStat.size - state.offset), state.offset);
610
+ if (bytesRead === 0) {
611
+ break;
612
+ }
613
+ state.offset += bytesRead;
614
+ consumeCodexScanText(state, state.decoder.write(buffer.subarray(0, bytesRead)));
615
+ await yieldToEventLoop();
616
+ }
617
+ }
618
+ finally {
619
+ await handle.close().catch(() => undefined);
620
+ }
621
+ return state.children;
622
+ }
623
+ function consumeCodexScanText(state, text) {
624
+ let cursor = 0;
625
+ while (cursor < text.length) {
626
+ const newline = text.indexOf('\n', cursor);
627
+ const end = newline === -1 ? text.length : newline;
628
+ const fragment = text.slice(cursor, end);
629
+ if (state.skippingOversizedLine) {
630
+ // Discard until the line ends; activity records are always small.
631
+ }
632
+ else if (state.remainder.length + fragment.length > CODEX_SCAN_MAX_LINE_CHARS) {
633
+ state.skippingOversizedLine = true;
634
+ state.remainder = '';
635
+ }
636
+ else {
637
+ state.remainder += fragment;
638
+ }
639
+ if (newline === -1) {
640
+ break;
641
+ }
642
+ if (!state.skippingOversizedLine && state.remainder.includes('"sub_agent_activity"')) {
643
+ recordCodexSubagentActivityLine(state, state.remainder);
644
+ }
645
+ state.remainder = '';
646
+ state.skippingOversizedLine = false;
647
+ cursor = newline + 1;
648
+ }
649
+ }
650
+ function recordCodexSubagentActivityLine(state, line) {
651
+ const record = parseJsonLine(line);
652
+ const payload = readRecord(record?.payload);
653
+ if (!payload || payload.type !== 'sub_agent_activity') {
654
+ return;
655
+ }
656
+ const threadId = typeof payload.agent_thread_id === 'string' ? payload.agent_thread_id.toLowerCase() : '';
657
+ if (!isSessionId(threadId)) {
658
+ return;
659
+ }
660
+ const existing = state.children.get(threadId) ?? { agentPath: null, lastKind: null };
661
+ existing.agentPath = readNonEmptyString(payload.agent_path) ?? existing.agentPath;
662
+ existing.lastKind = readNonEmptyString(payload.kind) ?? existing.lastKind;
663
+ state.children.set(threadId, existing);
664
+ }
665
+ // --- shared helpers ---
666
+ async function readSubagentState(child, mtimeMs, options) {
667
+ const key = `subagent\0${child.provider}\0${child.sessionId}\0${child.filePath}`;
668
+ rememberStatusKey(key);
669
+ let state;
670
+ try {
671
+ state = (await statusTracker.readSnapshot(key, child)).status.state;
672
+ }
673
+ catch {
674
+ return 'unknown';
675
+ }
676
+ const now = options.now ?? Date.now();
677
+ if (state === 'working' && now - mtimeMs > WORKING_STALE_MS) {
678
+ return 'unknown';
679
+ }
680
+ return state;
681
+ }
682
+ function rememberStatusKey(key) {
683
+ if (statusKeys.has(key)) {
684
+ statusKeys.delete(key);
685
+ statusKeys.add(key);
686
+ return;
687
+ }
688
+ statusKeys.add(key);
689
+ while (statusKeys.size > STATUS_KEY_LIMIT) {
690
+ const oldest = statusKeys.values().next().value;
691
+ if (typeof oldest !== 'string') {
692
+ break;
693
+ }
694
+ statusKeys.delete(oldest);
695
+ statusTracker.reset(oldest);
696
+ }
697
+ }
698
+ async function readCodexThreadDetails(threadIds, options) {
699
+ const details = new Map();
700
+ const validIds = threadIds.filter((threadId) => isSessionId(threadId));
701
+ if (validIds.length === 0) {
702
+ return details;
703
+ }
704
+ const sql = [
705
+ "SELECT id || char(9) || COALESCE(model, '')",
706
+ ` || char(9) || substr(replace(replace(COALESCE(first_user_message, ''), char(9), ' '), char(10), ' '), 1, ${DESCRIPTION_LIMIT})`,
707
+ `FROM threads WHERE id IN (${validIds.map(sqlString).join(', ')});`
708
+ ].join('\n');
709
+ const output = await runStateDbQuery(sql, options);
710
+ if (output === null) {
711
+ return details;
712
+ }
713
+ for (const line of output.split('\n')) {
714
+ const trimmed = line.replace(/[\r\n]+$/, '');
715
+ if (!trimmed.trim())
716
+ continue;
717
+ const [id, model, firstUserMessage] = trimmed.split('\t');
718
+ if (!id || !isSessionId(id))
719
+ continue;
720
+ details.set(id, {
721
+ model: model || null,
722
+ firstUserMessage: firstUserMessage || null
723
+ });
724
+ }
725
+ return details;
726
+ }
727
+ async function runStateDbQuery(sql, options) {
728
+ const stateDb = path.join(options.homeDir ?? os.homedir(), '.codex', 'state_5.sqlite');
729
+ if (options.runSqlite) {
730
+ return await options.runSqlite(stateDb, sql);
731
+ }
732
+ try {
733
+ await stat(stateDb);
734
+ }
735
+ catch {
736
+ return null;
737
+ }
738
+ try {
739
+ return (await runCommand('sqlite3', ['-batch', '-noheader', stateDb, sql], { timeoutMs: SQLITE_TIMEOUT_MS })).stdout;
740
+ }
741
+ catch {
742
+ return null;
743
+ }
744
+ }
745
+ function touchScanState(states, key, state) {
746
+ states.delete(key);
747
+ states.set(key, state);
748
+ while (states.size > SCAN_STATE_LIMIT) {
749
+ const oldest = states.keys().next().value;
750
+ if (typeof oldest !== 'string' || oldest === key) {
751
+ break;
752
+ }
753
+ states.delete(oldest);
754
+ }
755
+ }
756
+ function leafAgentPathSegment(agentPath) {
757
+ if (!agentPath) {
758
+ return '';
759
+ }
760
+ const segments = agentPath.split('/').filter(Boolean);
761
+ return segments[segments.length - 1] ?? '';
762
+ }
763
+ function agentPathDepth(agentPath) {
764
+ if (!agentPath) {
765
+ return 1;
766
+ }
767
+ // agent_path is rooted at /root; each further segment is one spawn level.
768
+ const segments = agentPath.split('/').filter(Boolean);
769
+ return Math.max(1, segments.length - 1);
770
+ }
771
+ function truncateDescription(value) {
772
+ if (!value) {
773
+ return null;
774
+ }
775
+ const compact = value.replace(/\s+/g, ' ').trim();
776
+ if (!compact) {
777
+ return null;
778
+ }
779
+ return compact.length > DESCRIPTION_LIMIT ? `${compact.slice(0, DESCRIPTION_LIMIT - 1)}…` : compact;
780
+ }
781
+ function yieldToEventLoop() {
782
+ return new Promise((resolve) => setImmediate(resolve));
783
+ }
784
+ function parseJsonLine(line) {
785
+ if (!line) {
786
+ return null;
787
+ }
788
+ try {
789
+ return readRecord(JSON.parse(line));
790
+ }
791
+ catch {
792
+ return null;
793
+ }
794
+ }
795
+ function readRecord(value) {
796
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
797
+ }
798
+ function readNonEmptyString(value) {
799
+ return typeof value === 'string' && value.trim() ? value : null;
800
+ }
801
+ //# sourceMappingURL=subagentSources.js.map