@idl3/claude-control 1.3.0 → 1.4.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
@@ -20,9 +20,16 @@ import { pinKey } from './pins.js';
20
20
  import { readPaneRegistry, gcPaneRegistry } from './pane-registry.js';
21
21
  import {
22
22
  matchesProcess as codexMatchesProcess,
23
+ processMatchKind as codexProcessMatchKind,
23
24
  buildTranscriptIndex as buildCodexIndex,
25
+ readCodexTranscriptRecord,
24
26
  parseTuiStatus as parseCodexTuiStatus,
27
+ parseCodexPrompt,
28
+ findOpenRollout,
29
+ readRolloutMeta,
25
30
  } from './codex.js';
31
+ import { hasActiveSubAgents } from './subagents.js';
32
+ import { isCodexAppServerCapture } from './codex-rpc.js';
26
33
 
27
34
  const execFile = promisify(_execFile);
28
35
 
@@ -30,6 +37,7 @@ const execFile = promisify(_execFile);
30
37
  const CLAUDE_COMM_RE = /(^|\/)claude$/;
31
38
  // Matches Codex CLI executable basename.
32
39
  const CODEX_COMM_RE = /(^|\/)codex$/;
40
+ const CLAUDE_ARG_RE = /(^|[\s/])claude(?:\s|$)/;
33
41
 
34
42
  // A pane is a Claude Code session when its process title is the Claude version
35
43
  // (e.g. "2.1.162") — shells report zsh/bash/etc. A linked transcript also counts.
@@ -75,6 +83,31 @@ export function isCwdConsistent(recCwd, winCwd) {
75
83
 
76
84
  const PENDING_QUESTION_MAX = 140; // truncate the surfaced question text
77
85
 
86
+ function codexRecordToCandidate(rec) {
87
+ return {
88
+ transcriptPath: rec.transcriptPath,
89
+ cwd: rec.cwd,
90
+ projectDir: null, // triggers isCwdConsistent scope fallback in match.js
91
+ birthtimeMs: rec.mtime,
92
+ mtimeMs: rec.mtime,
93
+ lastActivityMs: rec.lastActivityMs ?? rec.mtime,
94
+ customTitle: rec.customTitle,
95
+ aiTitle: rec.aiTitle,
96
+ recentText: null,
97
+ // Pass through for later session assembly
98
+ sessionId: rec.sessionId,
99
+ lastActivity: rec.lastActivity,
100
+ model: rec.model,
101
+ transcriptPending: rec.transcriptPending,
102
+ pendingToolUseId: rec.pendingToolUseId,
103
+ pendingQuestion: rec.pendingQuestion,
104
+ agentType: rec.agentType,
105
+ usagePct: rec.usagePct ?? null,
106
+ usageWindowMin: rec.usageWindowMin ?? null,
107
+ mtime: rec.mtime,
108
+ };
109
+ }
110
+
78
111
  /**
79
112
  * Walk a set of JSONL tail lines and decide whether an AskUserQuestion is still
80
113
  * OPEN — i.e. an assistant `tool_use` block named "AskUserQuestion" exists whose
@@ -365,8 +398,14 @@ export class SessionRegistry extends EventEmitter {
365
398
  this._ctxMap = new Map();
366
399
  /** @type {Map<string, boolean>} target -> actively-generating flag */
367
400
  this._thinkingMap = new Map();
401
+ /** @type {Map<string, boolean>} target -> compacting-conversation flag */
402
+ this._compactingMap = new Map();
403
+ /** @type {Map<string, boolean>} target -> has a sub-agent actively running */
404
+ this._subAgentActiveMap = new Map();
368
405
  /** @type {Map<string, {pending:boolean, question:string|null}>} target -> pane-derived prompt */
369
406
  this._panePromptMap = new Map();
407
+ /** @type {Map<string, {transcriptPath:string, sessionId?:string|null}>} target -> exact Codex rollout hint */
408
+ this._transcriptHintMap = new Map();
370
409
  /** @type {Map<string, string>} target -> most-recent captured pane text (for fingerprint tiebreak) */
371
410
  this._paneTextCache = new Map();
372
411
  /** @type {number} monotonically-incrementing refresh() cycle counter */
@@ -419,15 +458,74 @@ export class SessionRegistry extends EventEmitter {
419
458
  */
420
459
  setPending(id, pending) {
421
460
  const session = this._sessions.find((s) => s.id === id);
461
+ this._pendingMap.set(id, !!pending);
422
462
  if (!session) return;
463
+ const panePrompt = this._panePromptMap.get(id) ?? null;
423
464
  const was = session.pending;
424
- session.pending = !!pending;
425
- this._pendingMap.set(id, !!pending);
465
+ session.pending = !!pending || !!panePrompt?.pending;
426
466
  if (was !== session.pending) {
427
467
  this._maybeEmit();
428
468
  }
429
469
  }
430
470
 
471
+ /**
472
+ * Set a structured prompt surfaced by a non-transcript transport such as
473
+ * Codex app-server. Pane-scraped Claude prompts still flow through _pollThinking().
474
+ *
475
+ * @param {string} id
476
+ * @param {{question?: string|null}|null} prompt
477
+ */
478
+ setPrompt(id, prompt) {
479
+ const rec = { pending: !!prompt, question: prompt?.question ?? null };
480
+ if (rec.pending) this._panePromptMap.set(id, rec);
481
+ else this._panePromptMap.delete(id);
482
+
483
+ const session = this._sessions.find((s) => s.id === id);
484
+ if (!session) return;
485
+ const wasPending = session.pending;
486
+ const wasQuestion = session.pendingQuestion ?? null;
487
+ session.pending = (this._pendingMap.get(id) ?? false) || rec.pending;
488
+ session.pendingQuestion = rec.question;
489
+ if (wasPending !== session.pending || wasQuestion !== session.pendingQuestion) {
490
+ this._maybeEmit();
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Set active-generation state from a structured transport.
496
+ *
497
+ * @param {string} id
498
+ * @param {boolean} thinking
499
+ */
500
+ setThinking(id, thinking) {
501
+ const session = this._sessions.find((s) => s.id === id);
502
+ const next = !!thinking;
503
+ this._thinkingMap.set(id, next);
504
+ if (!session) return;
505
+ const was = session.thinking;
506
+ session.thinking = next;
507
+ if (was !== next) {
508
+ this._maybeEmit();
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Bind a pane target to an exact transcript path discovered from a structured
514
+ * transport (currently Codex app-server's thread.path). This is authoritative
515
+ * for that target and avoids cwd/time ambiguity.
516
+ *
517
+ * @param {string} id
518
+ * @param {{transcriptPath?: string|null, sessionId?: string|null}|null} hint
519
+ */
520
+ setTranscriptHint(id, hint) {
521
+ if (!hint?.transcriptPath) this._transcriptHintMap.delete(id);
522
+ else this._transcriptHintMap.set(id, {
523
+ transcriptPath: hint.transcriptPath,
524
+ sessionId: hint.sessionId ?? null,
525
+ });
526
+ this.refresh().catch(() => {});
527
+ }
528
+
431
529
  /**
432
530
  * Rescan tmux windows and project directories. Returns the new session list.
433
531
  *
@@ -464,10 +562,13 @@ export class SessionRegistry extends EventEmitter {
464
562
  // only when ps is unavailable.
465
563
  const paneProc = await this._buildPaneProc(panes);
466
564
  const isClaudePane = (p) => {
565
+ if (p.ccAgent === 'claude') return true;
467
566
  const info = paneProc.get(p.target);
468
567
  return info ? info.isClaude : isClaudeCmd(p.cmd);
469
568
  };
470
569
  const paneKind = (p) => {
570
+ if (p.ccAgent === 'claude') return 'claude';
571
+ if (p.ccAgent === 'codex') return 'codex';
471
572
  const info = paneProc.get(p.target);
472
573
  if (info?.kind) return info.kind;
473
574
  if (isClaudeCmd(p.cmd)) return 'claude';
@@ -503,8 +604,9 @@ export class SessionRegistry extends EventEmitter {
503
604
  for (const p of claudePanes) {
504
605
  if (pinnedByTarget.has(p.target)) continue;
505
606
  const reg = p.paneId ? paneReg.get(p.paneId) : null;
506
- if (!reg) continue;
507
- const rec = await this._recordForPath(reg.transcriptPath);
607
+ const hint = this._transcriptHintMap.get(p.target) || reg;
608
+ if (!hint) continue;
609
+ const rec = await this._recordForPath(hint.transcriptPath);
508
610
  if (rec) {
509
611
  hookByTarget.set(p.target, rec);
510
612
  pinnedPaths.add(rec.transcriptPath); // exclude from the auto-matcher pool
@@ -583,44 +685,121 @@ export class SessionRegistry extends EventEmitter {
583
685
  // Discover Codex session transcripts and match them to Codex panes.
584
686
  // The Claude assignment above is computed first and left untouched;
585
687
  // codex results are merged in after.
688
+ //
689
+ // Binding strategy (authoritative → heuristic):
690
+ // 1. lsof: the live codex process holds its rollout file OPEN — lsof on
691
+ // its pid gives the exact path. Zero-ambiguity; self-heals on resume.
692
+ // 2. cwd + mtime heuristic (assignTranscripts): fallback for panes whose
693
+ // pid is unknown or whose lsof call failed.
586
694
  const codexPanes = panes.filter((p) => paneKind(p) === 'codex');
587
695
  if (codexPanes.length > 0) {
588
- const codexIndex = await buildCodexIndex({ codexSessionsRoot: this._codexSessionsRoot });
589
- const codexCandidates = [];
590
- for (const rec of codexIndex.byCwd.values()) {
591
- codexCandidates.push({
592
- transcriptPath: rec.transcriptPath,
593
- cwd: rec.cwd,
594
- projectDir: null, // triggers isCwdConsistent scope fallback in match.js
595
- birthtimeMs: rec.mtime,
596
- mtimeMs: rec.mtime,
597
- lastActivityMs: rec.lastActivityMs ?? rec.mtime,
598
- customTitle: rec.customTitle,
599
- aiTitle: rec.aiTitle,
600
- recentText: null,
601
- // Pass through for later session assembly
602
- sessionId: rec.sessionId,
603
- lastActivity: rec.lastActivity,
604
- model: rec.model,
605
- transcriptPending: rec.transcriptPending,
606
- pendingToolUseId: rec.pendingToolUseId,
607
- pendingQuestion: rec.pendingQuestion,
608
- agentType: rec.agentType,
609
- usagePct: rec.usagePct ?? null,
610
- usageWindowMin: rec.usageWindowMin ?? null,
611
- mtime: rec.mtime,
612
- });
696
+ // --- Phase 1: exact binding via RPC hints, pane registry, then lsof -------
697
+ const exactByTarget = new Map();
698
+ const exactPaths = new Set();
699
+ const appServerTargets = new Set();
700
+ const markAppServerIfVisible = async (p) => {
701
+ let capture = this._paneTextCache.get(p.target) ?? '';
702
+ if (!capture && this._tmux?.capturePane) {
703
+ try {
704
+ capture = await this._tmux.capturePane(p.target, 80, false, true);
705
+ this._paneTextCache.set(p.target, capture);
706
+ } catch {
707
+ capture = '';
708
+ }
709
+ }
710
+ if (isCodexAppServerCapture(capture)) appServerTargets.add(p.target);
711
+ };
712
+ await Promise.all(
713
+ codexPanes.map(async (p) => {
714
+ try {
715
+ const runtimeHint = this._transcriptHintMap.get(p.target);
716
+ if (runtimeHint?.transcriptPath) {
717
+ const rec = await readCodexTranscriptRecord(runtimeHint.transcriptPath);
718
+ if (rec && isCwdConsistent(rec.cwd, p.cwd)) {
719
+ exactByTarget.set(p.target, codexRecordToCandidate({
720
+ ...rec,
721
+ sessionId: rec.sessionId ?? runtimeHint.sessionId ?? null,
722
+ }));
723
+ exactPaths.add(rec.transcriptPath);
724
+ return;
725
+ }
726
+ }
727
+
728
+ const reg = p.paneId ? paneReg.get(p.paneId) : null;
729
+ const procInfo = paneProc.get(p.target);
730
+ const codexPid = procInfo?.pid ?? null;
731
+ if (!codexPid) {
732
+ await markAppServerIfVisible(p);
733
+ if (appServerTargets.has(p.target)) return;
734
+ if (reg?.transcriptPath) {
735
+ const rec = await readCodexTranscriptRecord(reg.transcriptPath);
736
+ if (rec && isCwdConsistent(rec.cwd, p.cwd)) {
737
+ exactByTarget.set(p.target, codexRecordToCandidate({
738
+ ...rec,
739
+ sessionId: rec.sessionId ?? reg.sessionId ?? null,
740
+ }));
741
+ exactPaths.add(rec.transcriptPath);
742
+ }
743
+ }
744
+ return;
745
+ }
746
+ const rolloutPath = await findOpenRollout(codexPid);
747
+ if (!rolloutPath || exactPaths.has(rolloutPath)) {
748
+ await markAppServerIfVisible(p);
749
+ if (appServerTargets.has(p.target)) return;
750
+ if (reg?.transcriptPath) {
751
+ const rec = await readCodexTranscriptRecord(reg.transcriptPath);
752
+ if (rec && isCwdConsistent(rec.cwd, p.cwd)) {
753
+ exactByTarget.set(p.target, codexRecordToCandidate({
754
+ ...rec,
755
+ sessionId: rec.sessionId ?? reg.sessionId ?? null,
756
+ }));
757
+ exactPaths.add(rec.transcriptPath);
758
+ }
759
+ }
760
+ return;
761
+ }
762
+ const rec = await readCodexTranscriptRecord(rolloutPath);
763
+ if (!rec) return;
764
+ if (!isCwdConsistent(rec.cwd, p.cwd)) {
765
+ await markAppServerIfVisible(p);
766
+ return;
767
+ }
768
+ exactByTarget.set(p.target, codexRecordToCandidate(rec));
769
+ exactPaths.add(rec.transcriptPath);
770
+ } catch {
771
+ // exact lookup failed; heuristic fallback below can still bind it
772
+ }
773
+ }),
774
+ );
775
+
776
+ // --- Phase 2: heuristic fallback for unresolved panes ------------------
777
+ const unresolved = codexPanes.filter((p) =>
778
+ !exactByTarget.has(p.target) && !appServerTargets.has(p.target),
779
+ );
780
+ if (unresolved.length > 0) {
781
+ const codexIndex = await buildCodexIndex({ codexSessionsRoot: this._codexSessionsRoot });
782
+ const codexCandidates = [];
783
+ // Use every active rollout, not only the newest per cwd. Legacy TUI and
784
+ // RPC app-server panes often share one cwd; collapsing by cwd can leave
785
+ // the older still-live pane with no fallback candidate.
786
+ for (const rec of codexIndex.byPath.values()) {
787
+ if (!exactPaths.has(rec.transcriptPath)) {
788
+ codexCandidates.push(codexRecordToCandidate(rec));
789
+ }
790
+ }
791
+ const codexPaneInputs = unresolved.map((p) => ({
792
+ target: p.target,
793
+ windowName: p.windowName,
794
+ cwd: p.cwd,
795
+ projectDir: null,
796
+ procStartMs: paneProc.get(p.target)?.startMs ?? null,
797
+ capturedText: this._paneTextCache.get(p.target) ?? null,
798
+ }));
799
+ const codexAssignment = assignTranscripts(codexPaneInputs, codexCandidates);
800
+ for (const [t, rec] of codexAssignment) assignment.set(t, rec);
613
801
  }
614
- const codexPaneInputs = codexPanes.map((p) => ({
615
- target: p.target,
616
- windowName: p.windowName,
617
- cwd: p.cwd,
618
- projectDir: null,
619
- procStartMs: paneProc.get(p.target)?.startMs ?? null,
620
- capturedText: this._paneTextCache.get(p.target) ?? null,
621
- }));
622
- const codexAssignment = assignTranscripts(codexPaneInputs, codexCandidates);
623
- for (const [t, rec] of codexAssignment) assignment.set(t, rec);
802
+ for (const [t, rec] of exactByTarget) assignment.set(t, rec);
624
803
  }
625
804
  // ── End Codex matching ───────────────────────────────────────────────────
626
805
 
@@ -634,7 +813,7 @@ export class SessionRegistry extends EventEmitter {
634
813
  // Pending = subscribed-tailer pending (live modal) OR transcript-derived
635
814
  // pending OR pane-derived prompt (a numbered picker on screen — catches
636
815
  // questions even when the transcript isn't matched). Works for ANY session.
637
- const panePrompt = isClaude ? this._panePromptMap.get(id) : null;
816
+ const panePrompt = this._panePromptMap.get(id) ?? null;
638
817
  const pending =
639
818
  (this._pendingMap.get(id) ?? false) ||
640
819
  !!transcript?.transcriptPending ||
@@ -670,11 +849,15 @@ export class SessionRegistry extends EventEmitter {
670
849
  cmd: win.cmd,
671
850
  isClaude,
672
851
  kind,
852
+ transport: kind === 'claude' ? (win.ccTransport || 'tmux') : (win.ccTransport || null),
853
+ endpoint: win.ccEndpoint || null,
673
854
  ccShell: !!win.ccShell, // a composer >_ sister shell pane
674
855
 
675
856
  model: ctx.model || prettyModel(transcript?.model) || null,
676
857
  ctxPct: ctx.ctxPct ?? null,
677
858
  thinking: (isClaude || kind === 'codex') ? this._thinkingMap.get(win.target) ?? false : false,
859
+ compacting: (isClaude || kind === 'codex') ? this._compactingMap.get(win.target) ?? false : false,
860
+ subAgentActive: isClaude ? this._subAgentActiveMap.get(win.target) ?? false : false,
678
861
  usagePct: transcript?.usagePct ?? null,
679
862
  usageWindowMin: transcript?.usageWindowMin ?? null,
680
863
  };
@@ -701,6 +884,7 @@ export class SessionRegistry extends EventEmitter {
701
884
  sessions.map(async (s) => {
702
885
  if (!this._tmux.isValidTarget(s.target)) return;
703
886
  try {
887
+ if (s.transport === 'print') return;
704
888
  const cap = await this._tmux.capturePane(s.target, 8);
705
889
  // Codex panes use the codex header/footer parser (the Claude tui.js
706
890
  // parser doesn't match codex's "model:"/footer formats). Codex has no
@@ -736,17 +920,33 @@ export class SessionRegistry extends EventEmitter {
736
920
  sessions.map(async (s) => {
737
921
  if (!this._tmux.isValidTarget(s.target)) return;
738
922
  try {
739
- // Capture enough of the bottom to catch BOTH the working line ("esc to
740
- // interrupt", which can sit several rows above the input box) AND a TUI
741
- // question picker (parsePanePrompt scans ~18 bottom rows). One capture
742
- // feeds both keeping activity detection robust without extra execs.
743
- const cap = await this._tmux.capturePane(s.target, 26);
744
- const { thinking } = parseTuiStatus(cap);
923
+ if (s.transport === 'print') return;
924
+ // Capture the VISIBLE pane only (no scrollback). One capture feeds the
925
+ // working line ("esc to interrupt"), the TUI question picker
926
+ // (parsePanePrompt), and the codex prompt parse. Scrollback MUST be
927
+ // excluded: a `-S -N` window pulls in an already-answered picker frozen
928
+ // in history (still showing its ❯ cursor + "esc to cancel" footer),
929
+ // which re-fires the prompt after it was answered and lets stray
930
+ // numbered prose look like a live menu. The live picker is always on
931
+ // the visible screen, so visible-only is both sufficient and accurate.
932
+ const cap = await this._tmux.capturePane(s.target, 26, false, false, { visibleOnly: true });
933
+ const { thinking, compacting } = parseTuiStatus(cap);
745
934
  this._thinkingMap.set(s.target, thinking);
935
+ this._compactingMap.set(s.target, compacting);
746
936
  // Cache raw capture text for the content-fingerprint tiebreak in
747
937
  // the next refresh() — cheap: already captured here.
748
938
  this._paneTextCache.set(s.target, cap);
749
939
  s.thinking = thinking;
940
+ s.compacting = compacting;
941
+
942
+ // Sub-agent activity (Claude only) — scan the session's subagents dir so
943
+ // the rail's "cloning" state lights for EVERY window, not just the one a
944
+ // client subscribed to (the SubAgentsWatcher is subscription-scoped).
945
+ if (s.kind === 'claude') {
946
+ const subActive = hasActiveSubAgents(s.transcriptPath);
947
+ this._subAgentActiveMap.set(s.target, subActive);
948
+ s.subAgentActive = subActive;
949
+ }
750
950
 
751
951
  // Pane-derived question detection (Claude panes only): an on-screen
752
952
  // numbered picker means a question is waiting — even if the transcript
@@ -759,6 +959,17 @@ export class SessionRegistry extends EventEmitter {
759
959
  (this._pendingMap.get(s.target) ?? false) || rec.pending || s.pending;
760
960
  s.pending = merged;
761
961
  if (rec.pending && !s.pendingQuestion) s.pendingQuestion = rec.question;
962
+ } else if (s.kind === 'codex') {
963
+ // Pane-derived question detection for Codex panes: parseCodexPrompt
964
+ // detects approval modals and planning questions on screen. Feeds the
965
+ // same _panePromptMap so refresh() can surface the ASK sidebar icon.
966
+ const prompt = parseCodexPrompt(cap);
967
+ const rec = { pending: !!prompt, question: prompt?.question ?? null };
968
+ this._panePromptMap.set(s.target, rec);
969
+ const merged =
970
+ (this._pendingMap.get(s.target) ?? false) || rec.pending || s.pending;
971
+ s.pending = merged;
972
+ if (rec.pending && !s.pendingQuestion) s.pendingQuestion = rec.question;
762
973
  }
763
974
  } catch {
764
975
  // pane gone / capture failed — leave previous value
@@ -882,18 +1093,18 @@ export class SessionRegistry extends EventEmitter {
882
1093
  }
883
1094
 
884
1095
  /**
885
- * Classify each pane and resolve its claude-process start time in ONE `ps`
1096
+ * Classify each pane and resolve its agent-process start time in ONE `ps`
886
1097
  * snapshot. A pane is a Claude session iff its process subtree (from the pane
887
1098
  * shell pid) contains a `claude` descendant — far more reliable than the
888
1099
  * `pane_current_command` version-regex, which flips to `node`/`git` while
889
- * Claude runs a tool. The same walk yields the claude start time (ms epoch)
1100
+ * Claude runs a tool. The same walk yields the agent start time (ms epoch)
890
1101
  * for the start-time matching fallback.
891
1102
  *
892
1103
  * Best-effort: if `ps` is unavailable every pane maps to {isClaude:false,
893
1104
  * startMs:null} and callers fall back to the cmd heuristic / other passes.
894
1105
  *
895
1106
  * @param {import('./tmux.js').Window[]} allPanes
896
- * @returns {Promise<Map<string, {isClaude: boolean, startMs: number|null}>>} target -> info
1107
+ * @returns {Promise<Map<string, {isClaude: boolean, isCodex: boolean, kind: string|null, startMs: number|null}>>} target -> info
897
1108
  */
898
1109
  async _buildPaneProc(allPanes) {
899
1110
  const out = new Map();
@@ -903,7 +1114,7 @@ export class SessionRegistry extends EventEmitter {
903
1114
  try {
904
1115
  const { stdout } = await execFile(
905
1116
  'ps',
906
- ['-axo', 'pid=,ppid=,etime=,comm='],
1117
+ ['-axo', 'pid=,ppid=,etime=,comm=,args='],
907
1118
  { timeout: 5000, maxBuffer: 8 * 1024 * 1024 },
908
1119
  );
909
1120
  rows = stdout.split('\n');
@@ -913,43 +1124,53 @@ export class SessionRegistry extends EventEmitter {
913
1124
 
914
1125
  /** @type {Map<number, number[]>} ppid -> child pids */
915
1126
  const children = new Map();
916
- /** @type {Map<number, {etime:string, comm:string}>} */
1127
+ /** @type {Map<number, {etime:string, comm:string, args:string}>} */
917
1128
  const info = new Map();
918
1129
  for (const line of rows) {
919
- const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
1130
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
920
1131
  if (!m) continue;
921
1132
  const pid = Number(m[1]);
922
1133
  const ppid = Number(m[2]);
923
- info.set(pid, { etime: m[3], comm: m[4] });
1134
+ info.set(pid, { etime: m[3], comm: m[4], args: m[5] || '' });
924
1135
  if (!children.has(ppid)) children.set(ppid, []);
925
1136
  children.get(ppid).push(pid);
926
1137
  }
927
1138
 
928
1139
  const now = Date.now();
929
- // BFS from the pane shell pid for a `claude` or `codex` descendant; return its start.
1140
+ // BFS from the pane shell pid for a `claude` or `codex` descendant; return its start + pid.
930
1141
  const findClaude = (rootPid) => {
931
1142
  const queue = [rootPid];
932
1143
  const seen = new Set();
1144
+ let codexFallback = null;
933
1145
  while (queue.length) {
934
1146
  const pid = queue.shift();
935
1147
  if (seen.has(pid)) continue;
936
1148
  seen.add(pid);
937
1149
  const meta = info.get(pid);
938
- if (meta && CLAUDE_COMM_RE.test(meta.comm)) {
1150
+ if (meta && (CLAUDE_COMM_RE.test(meta.comm) || CLAUDE_ARG_RE.test(meta.args))) {
939
1151
  const sec = parseEtime(meta.etime);
940
- return { isClaude: true, isCodex: false, kind: 'claude', startMs: sec == null ? null : now - sec * 1000 };
1152
+ return { isClaude: true, isCodex: false, kind: 'claude', startMs: sec == null ? null : now - sec * 1000, pid };
941
1153
  }
942
- if (meta && CODEX_COMM_RE.test(meta.comm)) {
1154
+ const codexKind = meta
1155
+ ? (CODEX_COMM_RE.test(meta.comm) ? 'direct' : codexProcessMatchKind(meta.args))
1156
+ : null;
1157
+ if (codexKind) {
943
1158
  const sec = parseEtime(meta.etime);
944
- return { isClaude: false, isCodex: true, kind: 'codex', startMs: sec == null ? null : now - sec * 1000 };
1159
+ const codexInfo = { isClaude: false, isCodex: true, kind: 'codex', startMs: sec == null ? null : now - sec * 1000, pid };
1160
+ // npm/nvm installs launch Codex as `node .../bin/codex`, which then
1161
+ // spawns the native Codex child. The native child holds the rollout
1162
+ // file open, so prefer it for lsof-based transcript binding while
1163
+ // keeping the wrapper as a fallback when no child is visible yet.
1164
+ if (codexKind === 'direct') return codexInfo;
1165
+ if (!codexFallback) codexFallback = codexInfo;
945
1166
  }
946
1167
  for (const c of children.get(pid) ?? []) queue.push(c);
947
1168
  }
948
- return { isClaude: false, isCodex: false, kind: null, startMs: null };
1169
+ return codexFallback || { isClaude: false, isCodex: false, kind: null, startMs: null, pid: null };
949
1170
  };
950
1171
 
951
1172
  for (const p of allPanes) {
952
- out.set(p.target, p.panePid ? findClaude(p.panePid) : { isClaude: false, isCodex: false, kind: null, startMs: null });
1173
+ out.set(p.target, p.panePid ? findClaude(p.panePid) : { isClaude: false, isCodex: false, kind: null, startMs: null, pid: null });
953
1174
  }
954
1175
  return out;
955
1176
  }
package/lib/subagents.js CHANGED
@@ -37,6 +37,42 @@ const RUNNING_WINDOW_MS = 45_000;
37
37
  // (possibly premature, e.g. background-launch-ack) doneByParent flag.
38
38
  const ACTIVE_WINDOW_MS = 20_000;
39
39
 
40
+ const SUBAGENT_JSONL_RE = /^agent-.+\.jsonl$/;
41
+
42
+ /**
43
+ * Cheap probe: does this parent transcript have a sub-agent that's actively
44
+ * running RIGHT NOW? True when any agent-*.jsonl in its subagents dir was written
45
+ * within `windowMs` (live sub-agents append every few seconds). Used by the
46
+ * session poll to light the rail's "cloning" state for EVERY window — not just
47
+ * the one a client subscribed to (the SubAgentsWatcher is subscription-scoped).
48
+ * readdir + a stat per file; no tailing, no buffering. Returns false on any
49
+ * error / missing dir (the common no-sub-agents case, fast ENOENT).
50
+ *
51
+ * @param {string} transcriptPath absolute path to the PARENT transcript (.jsonl)
52
+ * @param {number} [windowMs]
53
+ * @returns {boolean}
54
+ */
55
+ export function hasActiveSubAgents(transcriptPath, windowMs = RUNNING_WINDOW_MS) {
56
+ if (!transcriptPath) return false;
57
+ const dir = path.join(transcriptPath.replace(/\.jsonl$/, ''), 'subagents');
58
+ let entries;
59
+ try {
60
+ entries = fs.readdirSync(dir);
61
+ } catch {
62
+ return false; // no subagents dir → none running
63
+ }
64
+ const cutoff = Date.now() - windowMs;
65
+ for (const name of entries) {
66
+ if (!SUBAGENT_JSONL_RE.test(name)) continue;
67
+ try {
68
+ if (fs.statSync(path.join(dir, name)).mtimeMs >= cutoff) return true;
69
+ } catch {
70
+ // file vanished between readdir and stat — skip
71
+ }
72
+ }
73
+ return false;
74
+ }
75
+
40
76
  // ---------------------------------------------------------------------------
41
77
  // Agent definition front-matter cache + discovery
42
78
  // ---------------------------------------------------------------------------
@@ -245,6 +281,83 @@ export function _bustAgentsCache() {
245
281
  _agentsCache.clear();
246
282
  }
247
283
 
284
+ export class CodexSubAgentsWatcher extends EventEmitter {
285
+ constructor() {
286
+ super();
287
+ /** @type {Map<string, {agentId:string, agentPath:string, status:'running'|'done', state:string, result:string|null, error:string|null, rawStatus:any, messages:any[], createdAtMs:number, updatedAtMs:number}>} */
288
+ this._agents = new Map();
289
+ this._stopped = false;
290
+ }
291
+
292
+ snapshot() {
293
+ return [...this._agents.values()].map((a) => this._entry(a));
294
+ }
295
+
296
+ poll() {
297
+ // Codex sub-agent notifications arrive inline via transcript/RPC events.
298
+ }
299
+
300
+ markDone() {
301
+ // Completion is driven by Codex notification status, not parent tool_result ids.
302
+ }
303
+
304
+ ingest(update) {
305
+ if (this._stopped || !update?.agentId) return null;
306
+ const now = Date.now();
307
+ const prev = this._agents.get(update.agentId);
308
+ const status = update.status === 'done' ? 'done' : 'running';
309
+ const state = update.state || status;
310
+ const text =
311
+ update.error ? `Codex sub-agent ${state}: ${update.error}` :
312
+ update.result ? String(update.result) :
313
+ state === 'running' ? 'Codex sub-agent running…' :
314
+ `Codex sub-agent ${state}`;
315
+ const message = {
316
+ uuid: `codex-subagent-${update.agentId}-${now}`,
317
+ role: 'assistant',
318
+ ts: update.ts ?? now,
319
+ blocks: [{ kind: update.error ? 'text' : 'text', text }],
320
+ rawType: 'codex_subagent_notification',
321
+ };
322
+ const next = {
323
+ agentId: update.agentId,
324
+ agentPath: update.agentPath,
325
+ status,
326
+ state,
327
+ result: update.result ?? null,
328
+ error: update.error ?? null,
329
+ rawStatus: update.rawStatus ?? null,
330
+ messages: prev ? [...prev.messages, message].slice(-200) : [message],
331
+ createdAtMs: prev?.createdAtMs ?? now,
332
+ updatedAtMs: now,
333
+ };
334
+ this._agents.set(update.agentId, next);
335
+ const entry = this._entry(next);
336
+ this.emit('change', entry);
337
+ return entry;
338
+ }
339
+
340
+ stop() {
341
+ this._stopped = true;
342
+ this._agents.clear();
343
+ }
344
+
345
+ _entry(a) {
346
+ return {
347
+ agentId: a.agentId,
348
+ toolUseId: null,
349
+ agentType: 'codex',
350
+ description: a.agentPath,
351
+ status: a.status,
352
+ messages: a.messages.slice(),
353
+ createdAt: a.createdAtMs,
354
+ model: null,
355
+ def: null,
356
+ nested: [],
357
+ };
358
+ }
359
+ }
360
+
248
361
  export class SubAgentsWatcher extends EventEmitter {
249
362
  /**
250
363
  * @param {string} transcriptPath absolute path to the PARENT transcript