@idl3/claude-control 1.1.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/bin/claude-print-bridge.mjs +247 -0
- package/lib/claude-print.js +352 -0
- package/lib/codex-rpc.js +719 -0
- package/lib/codex.js +639 -74
- package/lib/pane-registry.js +58 -10
- package/lib/prompt.js +60 -21
- package/lib/sessions.js +300 -63
- package/lib/subagents.js +113 -0
- package/lib/tmux.js +68 -11
- package/lib/transcribe.js +1 -1
- package/lib/tui.js +10 -3
- package/lib/version.js +44 -8
- package/package.json +1 -1
- package/server.js +561 -36
- package/web/dist/assets/{core-CpT6tRRG.js → core-BPDebW1g.js} +1 -1
- package/web/dist/assets/index-B3rIEzoc.css +1 -0
- package/web/dist/assets/index-DIwGyVZ7.js +104 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CjOcrKRX.css +0 -1
- package/web/dist/assets/index-CxhR0MPg.js +0 -103
package/lib/sessions.js
CHANGED
|
@@ -18,7 +18,18 @@ import { parsePanePrompt } from './prompt.js';
|
|
|
18
18
|
import { assignTranscripts, parseEtime, fingerprintScore, shouldRebind } from './match.js';
|
|
19
19
|
import { pinKey } from './pins.js';
|
|
20
20
|
import { readPaneRegistry, gcPaneRegistry } from './pane-registry.js';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
matchesProcess as codexMatchesProcess,
|
|
23
|
+
processMatchKind as codexProcessMatchKind,
|
|
24
|
+
buildTranscriptIndex as buildCodexIndex,
|
|
25
|
+
readCodexTranscriptRecord,
|
|
26
|
+
parseTuiStatus as parseCodexTuiStatus,
|
|
27
|
+
parseCodexPrompt,
|
|
28
|
+
findOpenRollout,
|
|
29
|
+
readRolloutMeta,
|
|
30
|
+
} from './codex.js';
|
|
31
|
+
import { hasActiveSubAgents } from './subagents.js';
|
|
32
|
+
import { isCodexAppServerCapture } from './codex-rpc.js';
|
|
22
33
|
|
|
23
34
|
const execFile = promisify(_execFile);
|
|
24
35
|
|
|
@@ -26,6 +37,7 @@ const execFile = promisify(_execFile);
|
|
|
26
37
|
const CLAUDE_COMM_RE = /(^|\/)claude$/;
|
|
27
38
|
// Matches Codex CLI executable basename.
|
|
28
39
|
const CODEX_COMM_RE = /(^|\/)codex$/;
|
|
40
|
+
const CLAUDE_ARG_RE = /(^|[\s/])claude(?:\s|$)/;
|
|
29
41
|
|
|
30
42
|
// A pane is a Claude Code session when its process title is the Claude version
|
|
31
43
|
// (e.g. "2.1.162") — shells report zsh/bash/etc. A linked transcript also counts.
|
|
@@ -71,6 +83,31 @@ export function isCwdConsistent(recCwd, winCwd) {
|
|
|
71
83
|
|
|
72
84
|
const PENDING_QUESTION_MAX = 140; // truncate the surfaced question text
|
|
73
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
|
+
|
|
74
111
|
/**
|
|
75
112
|
* Walk a set of JSONL tail lines and decide whether an AskUserQuestion is still
|
|
76
113
|
* OPEN — i.e. an assistant `tool_use` block named "AskUserQuestion" exists whose
|
|
@@ -361,8 +398,14 @@ export class SessionRegistry extends EventEmitter {
|
|
|
361
398
|
this._ctxMap = new Map();
|
|
362
399
|
/** @type {Map<string, boolean>} target -> actively-generating flag */
|
|
363
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();
|
|
364
405
|
/** @type {Map<string, {pending:boolean, question:string|null}>} target -> pane-derived prompt */
|
|
365
406
|
this._panePromptMap = new Map();
|
|
407
|
+
/** @type {Map<string, {transcriptPath:string, sessionId?:string|null}>} target -> exact Codex rollout hint */
|
|
408
|
+
this._transcriptHintMap = new Map();
|
|
366
409
|
/** @type {Map<string, string>} target -> most-recent captured pane text (for fingerprint tiebreak) */
|
|
367
410
|
this._paneTextCache = new Map();
|
|
368
411
|
/** @type {number} monotonically-incrementing refresh() cycle counter */
|
|
@@ -415,15 +458,74 @@ export class SessionRegistry extends EventEmitter {
|
|
|
415
458
|
*/
|
|
416
459
|
setPending(id, pending) {
|
|
417
460
|
const session = this._sessions.find((s) => s.id === id);
|
|
461
|
+
this._pendingMap.set(id, !!pending);
|
|
418
462
|
if (!session) return;
|
|
463
|
+
const panePrompt = this._panePromptMap.get(id) ?? null;
|
|
419
464
|
const was = session.pending;
|
|
420
|
-
session.pending = !!pending;
|
|
421
|
-
this._pendingMap.set(id, !!pending);
|
|
465
|
+
session.pending = !!pending || !!panePrompt?.pending;
|
|
422
466
|
if (was !== session.pending) {
|
|
423
467
|
this._maybeEmit();
|
|
424
468
|
}
|
|
425
469
|
}
|
|
426
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
|
+
|
|
427
529
|
/**
|
|
428
530
|
* Rescan tmux windows and project directories. Returns the new session list.
|
|
429
531
|
*
|
|
@@ -460,10 +562,13 @@ export class SessionRegistry extends EventEmitter {
|
|
|
460
562
|
// only when ps is unavailable.
|
|
461
563
|
const paneProc = await this._buildPaneProc(panes);
|
|
462
564
|
const isClaudePane = (p) => {
|
|
565
|
+
if (p.ccAgent === 'claude') return true;
|
|
463
566
|
const info = paneProc.get(p.target);
|
|
464
567
|
return info ? info.isClaude : isClaudeCmd(p.cmd);
|
|
465
568
|
};
|
|
466
569
|
const paneKind = (p) => {
|
|
570
|
+
if (p.ccAgent === 'claude') return 'claude';
|
|
571
|
+
if (p.ccAgent === 'codex') return 'codex';
|
|
467
572
|
const info = paneProc.get(p.target);
|
|
468
573
|
if (info?.kind) return info.kind;
|
|
469
574
|
if (isClaudeCmd(p.cmd)) return 'claude';
|
|
@@ -476,7 +581,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
476
581
|
// the deterministic binding; everything below is fallback for panes with no
|
|
477
582
|
// hook record (sessions started before the hook was installed).
|
|
478
583
|
const paneReg = await readPaneRegistry();
|
|
479
|
-
gcPaneRegistry(
|
|
584
|
+
gcPaneRegistry().catch(() => {}); // prunes only pins whose transcript is gone
|
|
480
585
|
|
|
481
586
|
// Manual pins win first: a pinned pane is force-bound to its transcript and
|
|
482
587
|
// that transcript is removed from the auto-matcher pool. Pins are keyed by
|
|
@@ -499,8 +604,9 @@ export class SessionRegistry extends EventEmitter {
|
|
|
499
604
|
for (const p of claudePanes) {
|
|
500
605
|
if (pinnedByTarget.has(p.target)) continue;
|
|
501
606
|
const reg = p.paneId ? paneReg.get(p.paneId) : null;
|
|
502
|
-
|
|
503
|
-
|
|
607
|
+
const hint = this._transcriptHintMap.get(p.target) || reg;
|
|
608
|
+
if (!hint) continue;
|
|
609
|
+
const rec = await this._recordForPath(hint.transcriptPath);
|
|
504
610
|
if (rec) {
|
|
505
611
|
hookByTarget.set(p.target, rec);
|
|
506
612
|
pinnedPaths.add(rec.transcriptPath); // exclude from the auto-matcher pool
|
|
@@ -579,42 +685,121 @@ export class SessionRegistry extends EventEmitter {
|
|
|
579
685
|
// Discover Codex session transcripts and match them to Codex panes.
|
|
580
686
|
// The Claude assignment above is computed first and left untouched;
|
|
581
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.
|
|
582
694
|
const codexPanes = panes.filter((p) => paneKind(p) === 'codex');
|
|
583
695
|
if (codexPanes.length > 0) {
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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);
|
|
607
801
|
}
|
|
608
|
-
const
|
|
609
|
-
target: p.target,
|
|
610
|
-
windowName: p.windowName,
|
|
611
|
-
cwd: p.cwd,
|
|
612
|
-
projectDir: null,
|
|
613
|
-
procStartMs: paneProc.get(p.target)?.startMs ?? null,
|
|
614
|
-
capturedText: this._paneTextCache.get(p.target) ?? null,
|
|
615
|
-
}));
|
|
616
|
-
const codexAssignment = assignTranscripts(codexPaneInputs, codexCandidates);
|
|
617
|
-
for (const [t, rec] of codexAssignment) assignment.set(t, rec);
|
|
802
|
+
for (const [t, rec] of exactByTarget) assignment.set(t, rec);
|
|
618
803
|
}
|
|
619
804
|
// ── End Codex matching ───────────────────────────────────────────────────
|
|
620
805
|
|
|
@@ -628,13 +813,17 @@ export class SessionRegistry extends EventEmitter {
|
|
|
628
813
|
// Pending = subscribed-tailer pending (live modal) OR transcript-derived
|
|
629
814
|
// pending OR pane-derived prompt (a numbered picker on screen — catches
|
|
630
815
|
// questions even when the transcript isn't matched). Works for ANY session.
|
|
631
|
-
const panePrompt =
|
|
816
|
+
const panePrompt = this._panePromptMap.get(id) ?? null;
|
|
632
817
|
const pending =
|
|
633
818
|
(this._pendingMap.get(id) ?? false) ||
|
|
634
819
|
!!transcript?.transcriptPending ||
|
|
635
820
|
!!panePrompt?.pending;
|
|
636
821
|
const title = transcript?.customTitle || transcript?.aiTitle || null;
|
|
637
|
-
|
|
822
|
+
// Read the polled TUI status (model/ctx) for Claude AND Codex. Codex's
|
|
823
|
+
// _pollCtx populates _ctxMap with its model (ctxPct stays null — Codex's
|
|
824
|
+
// TUI has no context %). Without this, the assembly would discard the
|
|
825
|
+
// polled codex model and the rail would show no model for codex rows.
|
|
826
|
+
const ctx = isClaude || kind === 'codex' ? this._ctxMap.get(win.target) || {} : {};
|
|
638
827
|
|
|
639
828
|
return {
|
|
640
829
|
id,
|
|
@@ -660,11 +849,17 @@ export class SessionRegistry extends EventEmitter {
|
|
|
660
849
|
cmd: win.cmd,
|
|
661
850
|
isClaude,
|
|
662
851
|
kind,
|
|
852
|
+
transport: kind === 'claude' ? (win.ccTransport || 'tmux') : (win.ccTransport || null),
|
|
853
|
+
endpoint: win.ccEndpoint || null,
|
|
663
854
|
ccShell: !!win.ccShell, // a composer >_ sister shell pane
|
|
664
855
|
|
|
665
856
|
model: ctx.model || prettyModel(transcript?.model) || null,
|
|
666
857
|
ctxPct: ctx.ctxPct ?? null,
|
|
667
|
-
thinking: isClaude ? this._thinkingMap.get(win.target) ?? false : false,
|
|
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,
|
|
861
|
+
usagePct: transcript?.usagePct ?? null,
|
|
862
|
+
usageWindowMin: transcript?.usageWindowMin ?? null,
|
|
668
863
|
};
|
|
669
864
|
});
|
|
670
865
|
|
|
@@ -689,8 +884,13 @@ export class SessionRegistry extends EventEmitter {
|
|
|
689
884
|
sessions.map(async (s) => {
|
|
690
885
|
if (!this._tmux.isValidTarget(s.target)) return;
|
|
691
886
|
try {
|
|
887
|
+
if (s.transport === 'print') return;
|
|
692
888
|
const cap = await this._tmux.capturePane(s.target, 8);
|
|
693
|
-
|
|
889
|
+
// Codex panes use the codex header/footer parser (the Claude tui.js
|
|
890
|
+
// parser doesn't match codex's "model:"/footer formats). Codex has no
|
|
891
|
+
// ctx% in its TUI, so ctxPct stays null for codex (no faked value).
|
|
892
|
+
const { ctxPct, model } =
|
|
893
|
+
s.kind === 'codex' ? parseCodexTuiStatus(cap) : parseTuiStatus(cap);
|
|
694
894
|
this._ctxMap.set(s.target, { ctxPct, model });
|
|
695
895
|
// Merge into the live session object without a full rebuild.
|
|
696
896
|
if (ctxPct !== null) s.ctxPct = ctxPct;
|
|
@@ -720,17 +920,33 @@ export class SessionRegistry extends EventEmitter {
|
|
|
720
920
|
sessions.map(async (s) => {
|
|
721
921
|
if (!this._tmux.isValidTarget(s.target)) return;
|
|
722
922
|
try {
|
|
723
|
-
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
|
|
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);
|
|
729
934
|
this._thinkingMap.set(s.target, thinking);
|
|
935
|
+
this._compactingMap.set(s.target, compacting);
|
|
730
936
|
// Cache raw capture text for the content-fingerprint tiebreak in
|
|
731
937
|
// the next refresh() — cheap: already captured here.
|
|
732
938
|
this._paneTextCache.set(s.target, cap);
|
|
733
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
|
+
}
|
|
734
950
|
|
|
735
951
|
// Pane-derived question detection (Claude panes only): an on-screen
|
|
736
952
|
// numbered picker means a question is waiting — even if the transcript
|
|
@@ -743,6 +959,17 @@ export class SessionRegistry extends EventEmitter {
|
|
|
743
959
|
(this._pendingMap.get(s.target) ?? false) || rec.pending || s.pending;
|
|
744
960
|
s.pending = merged;
|
|
745
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;
|
|
746
973
|
}
|
|
747
974
|
} catch {
|
|
748
975
|
// pane gone / capture failed — leave previous value
|
|
@@ -866,18 +1093,18 @@ export class SessionRegistry extends EventEmitter {
|
|
|
866
1093
|
}
|
|
867
1094
|
|
|
868
1095
|
/**
|
|
869
|
-
* Classify each pane and resolve its
|
|
1096
|
+
* Classify each pane and resolve its agent-process start time in ONE `ps`
|
|
870
1097
|
* snapshot. A pane is a Claude session iff its process subtree (from the pane
|
|
871
1098
|
* shell pid) contains a `claude` descendant — far more reliable than the
|
|
872
1099
|
* `pane_current_command` version-regex, which flips to `node`/`git` while
|
|
873
|
-
* Claude runs a tool. The same walk yields the
|
|
1100
|
+
* Claude runs a tool. The same walk yields the agent start time (ms epoch)
|
|
874
1101
|
* for the start-time matching fallback.
|
|
875
1102
|
*
|
|
876
1103
|
* Best-effort: if `ps` is unavailable every pane maps to {isClaude:false,
|
|
877
1104
|
* startMs:null} and callers fall back to the cmd heuristic / other passes.
|
|
878
1105
|
*
|
|
879
1106
|
* @param {import('./tmux.js').Window[]} allPanes
|
|
880
|
-
* @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
|
|
881
1108
|
*/
|
|
882
1109
|
async _buildPaneProc(allPanes) {
|
|
883
1110
|
const out = new Map();
|
|
@@ -887,7 +1114,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
887
1114
|
try {
|
|
888
1115
|
const { stdout } = await execFile(
|
|
889
1116
|
'ps',
|
|
890
|
-
['-axo', 'pid=,ppid=,etime=,comm='],
|
|
1117
|
+
['-axo', 'pid=,ppid=,etime=,comm=,args='],
|
|
891
1118
|
{ timeout: 5000, maxBuffer: 8 * 1024 * 1024 },
|
|
892
1119
|
);
|
|
893
1120
|
rows = stdout.split('\n');
|
|
@@ -897,43 +1124,53 @@ export class SessionRegistry extends EventEmitter {
|
|
|
897
1124
|
|
|
898
1125
|
/** @type {Map<number, number[]>} ppid -> child pids */
|
|
899
1126
|
const children = new Map();
|
|
900
|
-
/** @type {Map<number, {etime:string, comm:string}>} */
|
|
1127
|
+
/** @type {Map<number, {etime:string, comm:string, args:string}>} */
|
|
901
1128
|
const info = new Map();
|
|
902
1129
|
for (const line of rows) {
|
|
903
|
-
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);
|
|
904
1131
|
if (!m) continue;
|
|
905
1132
|
const pid = Number(m[1]);
|
|
906
1133
|
const ppid = Number(m[2]);
|
|
907
|
-
info.set(pid, { etime: m[3], comm: m[4] });
|
|
1134
|
+
info.set(pid, { etime: m[3], comm: m[4], args: m[5] || '' });
|
|
908
1135
|
if (!children.has(ppid)) children.set(ppid, []);
|
|
909
1136
|
children.get(ppid).push(pid);
|
|
910
1137
|
}
|
|
911
1138
|
|
|
912
1139
|
const now = Date.now();
|
|
913
|
-
// 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.
|
|
914
1141
|
const findClaude = (rootPid) => {
|
|
915
1142
|
const queue = [rootPid];
|
|
916
1143
|
const seen = new Set();
|
|
1144
|
+
let codexFallback = null;
|
|
917
1145
|
while (queue.length) {
|
|
918
1146
|
const pid = queue.shift();
|
|
919
1147
|
if (seen.has(pid)) continue;
|
|
920
1148
|
seen.add(pid);
|
|
921
1149
|
const meta = info.get(pid);
|
|
922
|
-
if (meta && CLAUDE_COMM_RE.test(meta.comm)) {
|
|
1150
|
+
if (meta && (CLAUDE_COMM_RE.test(meta.comm) || CLAUDE_ARG_RE.test(meta.args))) {
|
|
923
1151
|
const sec = parseEtime(meta.etime);
|
|
924
|
-
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 };
|
|
925
1153
|
}
|
|
926
|
-
|
|
1154
|
+
const codexKind = meta
|
|
1155
|
+
? (CODEX_COMM_RE.test(meta.comm) ? 'direct' : codexProcessMatchKind(meta.args))
|
|
1156
|
+
: null;
|
|
1157
|
+
if (codexKind) {
|
|
927
1158
|
const sec = parseEtime(meta.etime);
|
|
928
|
-
|
|
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;
|
|
929
1166
|
}
|
|
930
1167
|
for (const c of children.get(pid) ?? []) queue.push(c);
|
|
931
1168
|
}
|
|
932
|
-
return { isClaude: false, isCodex: false, kind: null, startMs: null };
|
|
1169
|
+
return codexFallback || { isClaude: false, isCodex: false, kind: null, startMs: null, pid: null };
|
|
933
1170
|
};
|
|
934
1171
|
|
|
935
1172
|
for (const p of allPanes) {
|
|
936
|
-
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 });
|
|
937
1174
|
}
|
|
938
1175
|
return out;
|
|
939
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
|