@idl3/claude-control 0.1.2 → 0.1.3

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/lib/sessions.js CHANGED
@@ -10,8 +10,17 @@
10
10
  import { EventEmitter } from 'node:events';
11
11
  import fs from 'node:fs/promises';
12
12
  import path from 'node:path';
13
+ import { execFile as _execFile } from 'node:child_process';
14
+ import { promisify } from 'node:util';
13
15
 
14
16
  import { parseTuiStatus, prettyModel } from './tui.js';
17
+ import { assignTranscripts, parseEtime } from './match.js';
18
+ import { pinKey } from './pins.js';
19
+
20
+ const execFile = promisify(_execFile);
21
+
22
+ // Matches Claude Code's executable basename (e.g. /Users/x/.local/bin/claude).
23
+ const CLAUDE_COMM_RE = /(^|\/)claude$/;
15
24
 
16
25
  // A pane is a Claude Code session when its process title is the Claude version
17
26
  // (e.g. "2.1.162") — shells report zsh/bash/etc. A linked transcript also counts.
@@ -151,11 +160,12 @@ async function readTail(filePath, maxBytes) {
151
160
  * Parse the tail buffer of a JSONL file and return the last record that has a
152
161
  * truthy `.cwd` field, plus basic metadata.
153
162
  *
154
- * @param {string} filePath Absolute path of the .jsonl file
155
- * @param {number} mtime mtime (ms since epoch) of the file
156
- * @returns {Promise<{cwd:string, sessionId:string|null, lastActivity:string|null, transcriptPath:string, mtime:number}|null>}
163
+ * @param {string} filePath Absolute path of the .jsonl file
164
+ * @param {number} mtime mtime (ms since epoch) of the file
165
+ * @param {number} [birthtime] birthtime (ms since epoch) of the file
166
+ * @returns {Promise<object|null>}
157
167
  */
158
- async function extractTailRecord(filePath, mtime) {
168
+ async function extractTailRecord(filePath, mtime, birthtime = null) {
159
169
  const buf = await readTail(filePath, TAIL_BYTES);
160
170
  if (!buf) return null;
161
171
 
@@ -169,11 +179,13 @@ async function extractTailRecord(filePath, mtime) {
169
179
  cwd: null,
170
180
  sessionId: null,
171
181
  lastActivity: null,
182
+ lastActivityMs: null,
172
183
  model: null,
173
184
  aiTitle: null,
174
185
  customTitle: null,
175
186
  transcriptPath: filePath,
176
187
  mtime,
188
+ birthtimeMs: birthtime,
177
189
  transcriptPending: false,
178
190
  pendingToolUseId: null,
179
191
  pendingQuestion: null,
@@ -196,7 +208,11 @@ async function extractTailRecord(filePath, mtime) {
196
208
  let rec;
197
209
  try { rec = JSON.parse(line); } catch { continue; }
198
210
  if (!rec || typeof rec !== 'object') continue;
199
- if (base.lastActivity === null && typeof rec.timestamp === 'string') base.lastActivity = rec.timestamp;
211
+ if (base.lastActivity === null && typeof rec.timestamp === 'string') {
212
+ base.lastActivity = rec.timestamp;
213
+ const t = Date.parse(rec.timestamp);
214
+ base.lastActivityMs = Number.isNaN(t) ? null : t;
215
+ }
200
216
  if (base.sessionId === null && typeof rec.sessionId === 'string') base.sessionId = rec.sessionId;
201
217
  if (base.customTitle === null && rec.type === 'custom-title' && rec.customTitle) base.customTitle = rec.customTitle;
202
218
  if (base.aiTitle === null && rec.type === 'ai-title' && rec.aiTitle) base.aiTitle = rec.aiTitle;
@@ -210,40 +226,83 @@ async function extractTailRecord(filePath, mtime) {
210
226
  }
211
227
 
212
228
  // ---------------------------------------------------------------------------
213
- // findNewestJsonlreturns { path, mtime } or null
229
+ // findRecentJsonlnewest-mtime *.jsonl files in a dir (top K)
214
230
  // ---------------------------------------------------------------------------
215
231
 
216
232
  /**
217
- * Given a directory, find the *.jsonl file with the newest mtime.
233
+ * Given a directory, return its `k` *.jsonl files with the newest mtime, each
234
+ * with `birthtimeMs`. We need more than one because multiple Claude sessions can
235
+ * share a directory — each needs its own transcript candidate.
218
236
  *
219
237
  * @param {string} dir
220
- * @returns {Promise<{filePath:string, mtime:number}|null>}
238
+ * @param {number} k
239
+ * @returns {Promise<Array<{filePath:string, mtime:number, birthtimeMs:number}>>}
221
240
  */
222
- async function findNewestJsonl(dir) {
241
+ async function findRecentJsonl(dir, k) {
223
242
  let entries;
224
243
  try {
225
244
  entries = await fs.readdir(dir);
226
245
  } catch {
227
- return null;
246
+ return [];
228
247
  }
229
248
 
230
- let newest = null;
231
-
232
- await Promise.all(
249
+ const stats = await Promise.all(
233
250
  entries
234
251
  .filter((e) => e.endsWith('.jsonl'))
235
252
  .map(async (e) => {
236
253
  const full = path.join(dir, e);
237
- let st;
238
- try { st = await fs.stat(full); } catch { return; }
239
- const mtime = st.mtimeMs;
240
- if (!newest || mtime > newest.mtime) {
241
- newest = { filePath: full, mtime };
254
+ try {
255
+ const st = await fs.stat(full);
256
+ return { filePath: full, mtime: st.mtimeMs, birthtimeMs: st.birthtimeMs };
257
+ } catch {
258
+ return null;
242
259
  }
243
260
  }),
244
261
  );
245
262
 
246
- return newest;
263
+ return stats
264
+ .filter(Boolean)
265
+ .sort((a, b) => b.mtime - a.mtime)
266
+ .slice(0, Math.max(1, k));
267
+ }
268
+
269
+ /**
270
+ * List recent transcripts across all project dirs for the manual-pin picker.
271
+ * Takes the newest .jsonl per project dir (bounded), tail-parses each for a
272
+ * title/cwd/sessionId, and returns the most recently active `limit`.
273
+ *
274
+ * @param {{ projectsRoot: string, limit?: number }} opts
275
+ * @returns {Promise<Array<{transcriptPath,title,sessionId,cwd,lastActivity,mtime}>>}
276
+ */
277
+ export async function listRecentTranscripts({ projectsRoot, limit = 60 }) {
278
+ let dirs;
279
+ try {
280
+ const entries = await fs.readdir(projectsRoot, { withFileTypes: true });
281
+ dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(projectsRoot, e.name));
282
+ } catch {
283
+ return [];
284
+ }
285
+
286
+ const out = [];
287
+ await Promise.all(
288
+ dirs.map(async (dir) => {
289
+ const recent = await findRecentJsonl(dir, 1);
290
+ if (!recent.length) return;
291
+ const r = recent[0];
292
+ const rec = await extractTailRecord(r.filePath, r.mtime, r.birthtimeMs);
293
+ if (!rec) return;
294
+ out.push({
295
+ transcriptPath: rec.transcriptPath,
296
+ title: rec.customTitle || rec.aiTitle || null,
297
+ sessionId: rec.sessionId,
298
+ cwd: rec.cwd,
299
+ lastActivity: rec.lastActivity,
300
+ mtime: r.mtime,
301
+ });
302
+ }),
303
+ );
304
+
305
+ return out.sort((a, b) => b.mtime - a.mtime).slice(0, limit);
247
306
  }
248
307
 
249
308
  // ---------------------------------------------------------------------------
@@ -260,13 +319,15 @@ export class SessionRegistry extends EventEmitter {
260
319
  this._tmux = tmux;
261
320
  this._debounceMs = debounceMs;
262
321
 
322
+ /** @type {Record<string,string>} pin key (windowId.paneIndex) -> transcript path */
323
+ this._pins = {};
263
324
  /** @type {Session[]} */
264
325
  this._sessions = [];
265
326
  /** @type {string|null} — last JSON snapshot for change detection */
266
327
  this._lastEmitted = null;
267
328
  /** @type {Map<string, boolean>} id -> pending flag */
268
329
  this._pendingMap = new Map();
269
- /** @type {Map<string, {ctxPct:number|null, model:string|null}>} windowId -> TUI status */
330
+ /** @type {Map<string, {ctxPct:number|null, model:string|null}>} target -> TUI status */
270
331
  this._ctxMap = new Map();
271
332
  /** @type {ReturnType<setInterval>|null} */
272
333
  this._interval = null;
@@ -283,6 +344,16 @@ export class SessionRegistry extends EventEmitter {
283
344
  return this._sessions;
284
345
  }
285
346
 
347
+ /**
348
+ * Replace the manual pin map (windowId.paneIndex -> transcript path) and
349
+ * re-reconcile so the change shows immediately.
350
+ * @param {Record<string,string>} pins
351
+ */
352
+ setPins(pins) {
353
+ this._pins = pins || {};
354
+ this.refresh().catch(() => {});
355
+ }
356
+
286
357
  /**
287
358
  * Set the pending flag for a session (called by server when tailer fires
288
359
  * 'pending'). Emits 'change' if the flag actually flipped.
@@ -307,41 +378,69 @@ export class SessionRegistry extends EventEmitter {
307
378
  * @returns {Promise<Session[]>}
308
379
  */
309
380
  async refresh() {
310
- const [allWindows, transcriptIndex] = await Promise.all([
311
- this._listWindows(),
312
- this._buildTranscriptIndex(),
313
- ]);
381
+ const allPanes = await this._listWindows();
314
382
 
315
383
  // Grouped tmux sessions (e.g. a `_mobile` mirror of session `0`) expose the
316
- // SAME underlying window under multiple session names — identical window_id.
317
- // Collapse those so the UI shows each real window once (keeping the first,
318
- // which is the primary session by tmux's list ordering).
319
- const seenWindowIds = new Set();
320
- const windows = allWindows.filter((w) => {
321
- if (seenWindowIds.has(w.windowId)) return false;
322
- seenWindowIds.add(w.windowId);
384
+ // SAME underlying pane under multiple session names — identical
385
+ // (window_id, pane_index). Collapse those so the UI shows each real pane
386
+ // once (keeping the first, which is the primary session by tmux ordering).
387
+ const seenPanes = new Set();
388
+ const panes = allPanes.filter((p) => {
389
+ const key = `${p.windowId}.${p.paneIndex}`;
390
+ if (seenPanes.has(key)) return false;
391
+ seenPanes.add(key);
323
392
  return true;
324
393
  });
325
394
 
326
- const sessions = windows.map((win) => {
327
- // Primary match: directory-name encoding (survives mid-session `cd`).
328
- // encodeCwd is lossy ('/' and '.' both -> '-'), so a byDir hit is only
329
- // trusted when the cwd recorded inside the transcript is consistent with
330
- // this window's cwd equal, a descendant (the agent cd'd into a subdir),
331
- // or absent. An unrelated sibling (e.g. my.lib vs my-lib) is rejected and
332
- // falls through to the exact-cwd index.
333
- const byDirHit = transcriptIndex.byDir.get(encodeCwd(win.cwd));
334
- const transcript =
335
- (byDirHit && isCwdConsistent(byDirHit.cwd, win.cwd) ? byDirHit : null) ??
336
- transcriptIndex.byCwd.get(win.cwd) ??
337
- null;
395
+ // Only Claude panes have transcripts to match (shells don't).
396
+ const claudePanes = panes.filter((p) => isClaudeCmd(p.cmd));
397
+
398
+ // Manual pins win first: a pinned pane is force-bound to its transcript and
399
+ // that transcript is removed from the auto-matcher pool. Pins are keyed by
400
+ // the stable windowId.paneIndex (the target renumbers; the window id doesn't).
401
+ const pinnedByTarget = new Map();
402
+ const pinnedPaths = new Set();
403
+ for (const p of claudePanes) {
404
+ const pinned = this._pins[pinKey(p.windowId, p.paneIndex)];
405
+ if (!pinned) continue;
406
+ const rec = await this._recordForPath(pinned);
407
+ if (rec) {
408
+ pinnedByTarget.set(p.target, rec);
409
+ pinnedPaths.add(rec.transcriptPath);
410
+ }
411
+ }
412
+
413
+ // Auto-match the rest with the deterministic 1:1 matcher (pinned panes and
414
+ // pinned transcripts excluded so nothing double-binds or gets stolen).
415
+ const autoPanes = claudePanes.filter((p) => !pinnedByTarget.has(p.target));
416
+ const [candidatesRaw, procStart] = await Promise.all([
417
+ this._buildCandidates(autoPanes),
418
+ this._buildProcStart(autoPanes),
419
+ ]);
420
+ const candidates = candidatesRaw.filter((c) => !pinnedPaths.has(c.transcriptPath));
421
+ const assignment = assignTranscripts(
422
+ autoPanes.map((p) => ({
423
+ target: p.target,
424
+ windowName: p.windowName,
425
+ cwd: p.cwd,
426
+ procStartMs: procStart.get(p.target) ?? null,
427
+ })),
428
+ candidates,
429
+ );
430
+ for (const [target, rec] of pinnedByTarget) assignment.set(target, rec);
431
+
432
+ const sessions = panes.map((win) => {
433
+ const transcript = isClaudeCmd(win.cmd)
434
+ ? assignment.get(win.target) ?? null
435
+ : null;
436
+ const isPinned = pinnedByTarget.has(win.target);
338
437
  const id = win.target;
339
438
  // Pending = subscribed-tailer pending (live modal) OR transcript-derived
340
439
  // pending (works for ANY session, even unsubscribed ones, for push).
341
440
  const pending =
342
441
  (this._pendingMap.get(id) ?? false) || !!transcript?.transcriptPending;
343
442
  const title = transcript?.customTitle || transcript?.aiTitle || null;
344
- const ctx = this._ctxMap.get(win.windowId) || {};
443
+ const ctx = this._ctxMap.get(win.target) || {};
345
444
 
346
445
  return {
347
446
  id,
@@ -358,6 +457,7 @@ export class SessionRegistry extends EventEmitter {
358
457
  active: win.active,
359
458
  cwd: win.cwd,
360
459
  transcriptPath: transcript?.transcriptPath ?? null,
460
+ pinned: isPinned,
361
461
  lastActivity: transcript?.lastActivity ?? null,
362
462
  pending,
363
463
  pendingQuestion: transcript?.pendingQuestion ?? null,
@@ -368,33 +468,8 @@ export class SessionRegistry extends EventEmitter {
368
468
  };
369
469
  });
370
470
 
371
- // Dedup transcript collisions: when multiple tmux windows share a cwd they
372
- // all match the SAME newest transcript for that dir → the rail shows the
373
- // same title twice and two sessions tail one file. Keep the transcript on
374
- // the best (active, else first-seen) session; strip it from the others so
375
- // they stay distinct, transcript-less live panes (still subscribable).
376
- const seenTranscript = new Set();
377
- const byPriority = [...sessions].sort((a, b) =>
378
- a.active === b.active ? 0 : a.active ? -1 : 1,
379
- );
380
- for (const s of byPriority) {
381
- if (!s.transcriptPath) continue;
382
- if (seenTranscript.has(s.transcriptPath)) {
383
- s.transcriptPath = null;
384
- s.sessionId = null;
385
- s.lastActivity = null;
386
- s.title = null;
387
- s.name = s.tmuxName || s.target;
388
- // This session no longer owns the transcript, so its transcript-derived
389
- // pending is bogus; drop it (the owning session keeps the real one).
390
- s.pending = this._pendingMap.get(s.id) ?? false;
391
- s.pendingQuestion = null;
392
- } else {
393
- seenTranscript.add(s.transcriptPath);
394
- }
395
- }
396
-
397
- // Only surface Claude sessions; skip plain shell panes.
471
+ // Only surface Claude sessions; skip plain shell panes. (assignTranscripts
472
+ // already guarantees 1:1, so no post-hoc collision dedup is needed.)
398
473
  this._sessions = sessions.filter((s) => isClaudeCmd(s.cmd) || s.transcriptPath);
399
474
  this._maybeEmit();
400
475
  return this._sessions;
@@ -413,7 +488,7 @@ export class SessionRegistry extends EventEmitter {
413
488
  try {
414
489
  const cap = await this._tmux.capturePane(s.target, 8);
415
490
  const { ctxPct, model } = parseTuiStatus(cap);
416
- this._ctxMap.set(s.windowId, { ctxPct, model });
491
+ this._ctxMap.set(s.target, { ctxPct, model });
417
492
  // Merge into the live session object without a full rebuild.
418
493
  if (ctxPct !== null) s.ctxPct = ctxPct;
419
494
  if (model) s.model = model;
@@ -468,51 +543,119 @@ export class SessionRegistry extends EventEmitter {
468
543
  }
469
544
 
470
545
  /**
471
- * Scan all immediate subdirectories of projectsRoot. For each, find the
472
- * newest *.jsonl and extract the last record that carries a .cwd field.
473
- * Returns a Map keyed by cwd (keeping the newest mtime entry per cwd).
546
+ * Build the transcript candidate pool for the given Claude panes. A session's
547
+ * transcript lives in the project dir `encodeCwd(launchCwd)`, where launchCwd
548
+ * is the pane's current path. For each such dir we read the newest few
549
+ * transcripts (enough for every pane sharing that dir) so the matcher has a
550
+ * real choice when sessions collide on a directory. Tail-only reads (≤64 KB).
474
551
  *
475
- * @returns {Promise<Map<string, {cwd:string, sessionId:string|null, lastActivity:string|null, transcriptPath:string, mtime:number}>>}
552
+ * @param {import('./tmux.js').Window[]} claudePanes
553
+ * @returns {Promise<object[]>} candidate records (see extractTailRecord)
476
554
  */
477
- async _buildTranscriptIndex() {
478
- /** @type {{byDir: Map<string, object>, byCwd: Map<string, object>}} */
479
- const index = { byDir: new Map(), byCwd: new Map() };
480
-
481
- let projectEntries;
555
+ /**
556
+ * Build a transcript record for an exact path (for manual pins). Stats the
557
+ * file for mtime/birthtime, then tail-parses it. Null if unreadable.
558
+ * @param {string} filePath
559
+ * @returns {Promise<object|null>}
560
+ */
561
+ async _recordForPath(filePath) {
482
562
  try {
483
- const entries = await fs.readdir(this._projectsRoot, { withFileTypes: true });
484
- projectEntries = entries
485
- .filter((e) => e.isDirectory())
486
- .map((e) => ({ name: e.name, dir: path.join(this._projectsRoot, e.name) }));
563
+ const st = await fs.stat(filePath);
564
+ return await extractTailRecord(filePath, st.mtimeMs, st.birthtimeMs);
487
565
  } catch {
488
- return index;
566
+ return null;
567
+ }
568
+ }
569
+
570
+ async _buildCandidates(claudePanes) {
571
+ // dir name -> how many panes launched there (so we fetch enough candidates).
572
+ const dirCounts = new Map();
573
+ for (const p of claudePanes) {
574
+ const dir = encodeCwd(p.cwd);
575
+ dirCounts.set(dir, (dirCounts.get(dir) ?? 0) + 1);
489
576
  }
490
577
 
578
+ const candidates = [];
491
579
  await Promise.all(
492
- projectEntries.map(async ({ name, dir }) => {
493
- const newest = await findNewestJsonl(dir);
494
- if (!newest) return;
580
+ [...dirCounts.entries()].map(async ([name, count]) => {
581
+ const dir = path.join(this._projectsRoot, name);
582
+ // A small buffer beyond the pane count tolerates resume/compaction
583
+ // spawning a fresh file mid-session.
584
+ const recent = await findRecentJsonl(dir, count + 2);
585
+ const recs = await Promise.all(
586
+ recent.map((r) =>
587
+ extractTailRecord(r.filePath, r.mtime, r.birthtimeMs),
588
+ ),
589
+ );
590
+ for (const rec of recs) if (rec) candidates.push(rec);
591
+ }),
592
+ );
495
593
 
496
- const rec = await extractTailRecord(newest.filePath, newest.mtime);
497
- if (!rec) return;
594
+ return candidates;
595
+ }
498
596
 
499
- // Primary key: the project directory name (Claude Code's cwd encoding).
500
- const byDirExisting = index.byDir.get(name);
501
- if (!byDirExisting || newest.mtime > byDirExisting.mtime) {
502
- index.byDir.set(name, rec);
503
- }
597
+ /**
598
+ * Resolve each Claude pane's claude-process start time (ms epoch) for the
599
+ * start-time matching pass. One `ps` snapshot, then walk the process tree from
600
+ * each pane's shell pid to its `claude` descendant. Best-effort: panes whose
601
+ * proc can't be found map to null and fall through to other match passes.
602
+ *
603
+ * @param {import('./tmux.js').Window[]} claudePanes
604
+ * @returns {Promise<Map<string, number|null>>} target -> startMs
605
+ */
606
+ async _buildProcStart(claudePanes) {
607
+ const out = new Map();
608
+ if (claudePanes.length === 0) return out;
609
+
610
+ let rows;
611
+ try {
612
+ const { stdout } = await execFile(
613
+ 'ps',
614
+ ['-axo', 'pid=,ppid=,etime=,comm='],
615
+ { timeout: 5000, maxBuffer: 8 * 1024 * 1024 },
616
+ );
617
+ rows = stdout.split('\n');
618
+ } catch {
619
+ return out; // ps unavailable — every pane falls back to null
620
+ }
621
+
622
+ /** @type {Map<number, number[]>} ppid -> child pids */
623
+ const children = new Map();
624
+ /** @type {Map<number, {etime:string, comm:string}>} */
625
+ const info = new Map();
626
+ for (const line of rows) {
627
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
628
+ if (!m) continue;
629
+ const pid = Number(m[1]);
630
+ const ppid = Number(m[2]);
631
+ info.set(pid, { etime: m[3], comm: m[4] });
632
+ if (!children.has(ppid)) children.set(ppid, []);
633
+ children.get(ppid).push(pid);
634
+ }
504
635
 
505
- // Secondary key: the exact cwd recorded inside the transcript, when present.
506
- if (rec.cwd) {
507
- const byCwdExisting = index.byCwd.get(rec.cwd);
508
- if (!byCwdExisting || newest.mtime > byCwdExisting.mtime) {
509
- index.byCwd.set(rec.cwd, rec);
510
- }
636
+ const now = Date.now();
637
+ const findClaudeStart = (rootPid) => {
638
+ // BFS for a descendant whose command basename is `claude`.
639
+ const queue = [rootPid];
640
+ const seen = new Set();
641
+ while (queue.length) {
642
+ const pid = queue.shift();
643
+ if (seen.has(pid)) continue;
644
+ seen.add(pid);
645
+ const meta = info.get(pid);
646
+ if (meta && CLAUDE_COMM_RE.test(meta.comm)) {
647
+ const sec = parseEtime(meta.etime);
648
+ return sec == null ? null : now - sec * 1000;
511
649
  }
512
- }),
513
- );
650
+ for (const c of children.get(pid) ?? []) queue.push(c);
651
+ }
652
+ return null;
653
+ };
514
654
 
515
- return index;
655
+ for (const p of claudePanes) {
656
+ out.set(p.target, p.panePid ? findClaudeStart(p.panePid) : null);
657
+ }
658
+ return out;
516
659
  }
517
660
 
518
661
  /**
@@ -0,0 +1,154 @@
1
+ /**
2
+ * lib/subagents.js — watch a session's sub-agent (Task/Agent) transcripts.
3
+ *
4
+ * Claude Code writes each sub-agent's conversation to a sibling of the parent
5
+ * transcript:
6
+ * <project>/<sessionId>.jsonl ← parent
7
+ * <project>/<sessionId>/subagents/agent-<id>.jsonl ← sub-agent transcript
8
+ * <project>/<sessionId>/subagents/agent-<id>.meta.json
9
+ * { agentType, description, toolUseId } ← links to the parent's
10
+ * Task tool-call
11
+ *
12
+ * This watcher discovers those files (lazily — polled when the parent transcript
13
+ * grows, which is exactly when sub-agents spawn), tails each one with the same
14
+ * bounded TranscriptTailer the main transcript uses, and emits a 'change' event
15
+ * carrying the full sub-agent entry whenever it appears or grows. The server
16
+ * relays each entry to subscribed clients as a `subagent` frame.
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { EventEmitter } from 'node:events';
22
+
23
+ import { TranscriptTailer } from './transcript.js';
24
+
25
+ const META_RE = /^agent-(.+)\.meta\.json$/;
26
+ // A sub-agent whose transcript hasn't grown in this long is treated as finished,
27
+ // even if we never saw the parent's tool_result (e.g. it predates the parent's
28
+ // bounded message buffer). Live sub-agents append continuously.
29
+ const RUNNING_WINDOW_MS = 45_000;
30
+
31
+ export class SubAgentsWatcher extends EventEmitter {
32
+ /**
33
+ * @param {string} transcriptPath absolute path to the PARENT transcript
34
+ * @param {{ maxBuffer?: number }} [opts]
35
+ */
36
+ constructor(transcriptPath, { maxBuffer = 200 } = {}) {
37
+ super();
38
+ // <project>/<sessionId>.jsonl → <project>/<sessionId>/subagents
39
+ this._dir = path.join(transcriptPath.replace(/\.jsonl$/, ''), 'subagents');
40
+ this._maxBuffer = maxBuffer;
41
+ /** @type {Map<string, {agentId, toolUseId, agentType, description, status, tailer}>} */
42
+ this._agents = new Map(); // keyed by agentId
43
+ this._stopped = false;
44
+ }
45
+
46
+ /** Current sub-agents (snapshot), each with its buffered messages. */
47
+ snapshot() {
48
+ return [...this._agents.values()].map((a) => this._entry(a));
49
+ }
50
+
51
+ /**
52
+ * Rescan the subagents dir for new agent files. Cheap; safe to call often.
53
+ * Call on each parent-transcript append (when sub-agents are spawned) and once
54
+ * at subscribe time.
55
+ */
56
+ poll() {
57
+ if (this._stopped) return;
58
+ let entries;
59
+ try {
60
+ entries = fs.readdirSync(this._dir);
61
+ } catch {
62
+ return; // dir doesn't exist yet (no sub-agents) — nothing to do
63
+ }
64
+ for (const name of entries) {
65
+ const m = META_RE.exec(name);
66
+ if (!m) continue;
67
+ const agentId = m[1];
68
+ if (this._agents.has(agentId)) continue;
69
+ this._track(agentId);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Mark a sub-agent finished (the parent transcript produced a tool_result for
75
+ * its toolUseId — the authoritative "done" signal). Idempotent. Accepts a Set
76
+ * or a single id so the server can sweep its whole buffer at subscribe time.
77
+ * @param {string|Set<string>} toolUseIds
78
+ */
79
+ markDone(toolUseIds) {
80
+ const has = (id) =>
81
+ toolUseIds instanceof Set ? toolUseIds.has(id) : toolUseIds === id;
82
+ for (const a of this._agents.values()) {
83
+ if (a.toolUseId && has(a.toolUseId) && !a.doneByParent) {
84
+ a.doneByParent = true;
85
+ this.emit('change', this._entry(a));
86
+ }
87
+ }
88
+ }
89
+
90
+ stop() {
91
+ this._stopped = true;
92
+ for (const a of this._agents.values()) a.tailer?.stop();
93
+ this._agents.clear();
94
+ }
95
+
96
+ // -- internals --
97
+
98
+ _track(agentId) {
99
+ const metaPath = path.join(this._dir, `agent-${agentId}.meta.json`);
100
+ const jsonlPath = path.join(this._dir, `agent-${agentId}.jsonl`);
101
+ let meta = {};
102
+ try {
103
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) || {};
104
+ } catch {
105
+ return; // meta not readable yet — a later poll retries
106
+ }
107
+
108
+ const tailer = new TranscriptTailer(jsonlPath, { maxBuffer: this._maxBuffer });
109
+ const agent = {
110
+ agentId,
111
+ jsonlPath,
112
+ toolUseId: meta.toolUseId ?? null,
113
+ agentType: meta.agentType ?? null,
114
+ description: meta.description ?? null,
115
+ doneByParent: false,
116
+ tailer,
117
+ };
118
+ this._agents.set(agentId, agent);
119
+
120
+ tailer.on('append', () => this.emit('change', this._entry(agent)));
121
+ tailer.on('error', () => {}); // best-effort; a missing file just yields no messages
122
+ tailer
123
+ .start()
124
+ .then(() => this.emit('change', this._entry(agent)))
125
+ .catch(() => {});
126
+ }
127
+
128
+ /**
129
+ * Status: done if the parent confirmed it (authoritative), otherwise inferred
130
+ * from transcript freshness — a live sub-agent's file is actively appended; a
131
+ * finished one goes static. This keeps historical sub-agents correctly "done"
132
+ * even when their parent tool_result predates the bounded message buffer.
133
+ */
134
+ _statusFor(a) {
135
+ if (a.doneByParent) return 'done';
136
+ try {
137
+ const mtimeMs = fs.statSync(a.jsonlPath).mtimeMs;
138
+ return Date.now() - mtimeMs < RUNNING_WINDOW_MS ? 'running' : 'done';
139
+ } catch {
140
+ return 'done';
141
+ }
142
+ }
143
+
144
+ _entry(a) {
145
+ return {
146
+ agentId: a.agentId,
147
+ toolUseId: a.toolUseId,
148
+ agentType: a.agentType,
149
+ description: a.description,
150
+ status: this._statusFor(a),
151
+ messages: a.tailer ? a.tailer.getMessages() : [],
152
+ };
153
+ }
154
+ }