@idl3/claude-control 1.3.0 → 1.4.5
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 +755 -0
- package/lib/codex.js +553 -89
- package/lib/pane-registry.js +38 -3
- package/lib/prompt.js +60 -21
- package/lib/sessions.js +289 -60
- package/lib/subagents.js +113 -0
- package/lib/tmux.js +68 -11
- package/lib/transcribe.js +1 -1
- package/lib/tui.js +21 -3
- package/lib/version.js +44 -8
- package/package.json +1 -1
- package/server.js +568 -39
- package/web/dist/assets/{core-C29-1O9j.js → core-CXYe4Mpr.js} +1 -1
- package/web/dist/assets/index-B15X7siX.js +104 -0
- package/web/dist/assets/index-BT4vDWJt.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CT-y6LU4.css +0 -1
- package/web/dist/assets/index-DzIDTXLS.js +0 -103
package/lib/sessions.js
CHANGED
|
@@ -20,9 +20,15 @@ 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';
|
|
26
32
|
|
|
27
33
|
const execFile = promisify(_execFile);
|
|
28
34
|
|
|
@@ -30,6 +36,12 @@ const execFile = promisify(_execFile);
|
|
|
30
36
|
const CLAUDE_COMM_RE = /(^|\/)claude$/;
|
|
31
37
|
// Matches Codex CLI executable basename.
|
|
32
38
|
const CODEX_COMM_RE = /(^|\/)codex$/;
|
|
39
|
+
const CLAUDE_ARG_RE = /(^|[\s/])claude(?:\s|$)/;
|
|
40
|
+
|
|
41
|
+
function isCodexAppServerArgs(args) {
|
|
42
|
+
const s = String(args || '');
|
|
43
|
+
return /\bapp-server\b/.test(s) && /\b--listen\b/.test(s);
|
|
44
|
+
}
|
|
33
45
|
|
|
34
46
|
// A pane is a Claude Code session when its process title is the Claude version
|
|
35
47
|
// (e.g. "2.1.162") — shells report zsh/bash/etc. A linked transcript also counts.
|
|
@@ -75,6 +87,31 @@ export function isCwdConsistent(recCwd, winCwd) {
|
|
|
75
87
|
|
|
76
88
|
const PENDING_QUESTION_MAX = 140; // truncate the surfaced question text
|
|
77
89
|
|
|
90
|
+
function codexRecordToCandidate(rec) {
|
|
91
|
+
return {
|
|
92
|
+
transcriptPath: rec.transcriptPath,
|
|
93
|
+
cwd: rec.cwd,
|
|
94
|
+
projectDir: null, // triggers isCwdConsistent scope fallback in match.js
|
|
95
|
+
birthtimeMs: rec.mtime,
|
|
96
|
+
mtimeMs: rec.mtime,
|
|
97
|
+
lastActivityMs: rec.lastActivityMs ?? rec.mtime,
|
|
98
|
+
customTitle: rec.customTitle,
|
|
99
|
+
aiTitle: rec.aiTitle,
|
|
100
|
+
recentText: null,
|
|
101
|
+
// Pass through for later session assembly
|
|
102
|
+
sessionId: rec.sessionId,
|
|
103
|
+
lastActivity: rec.lastActivity,
|
|
104
|
+
model: rec.model,
|
|
105
|
+
transcriptPending: rec.transcriptPending,
|
|
106
|
+
pendingToolUseId: rec.pendingToolUseId,
|
|
107
|
+
pendingQuestion: rec.pendingQuestion,
|
|
108
|
+
agentType: rec.agentType,
|
|
109
|
+
usagePct: rec.usagePct ?? null,
|
|
110
|
+
usageWindowMin: rec.usageWindowMin ?? null,
|
|
111
|
+
mtime: rec.mtime,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
/**
|
|
79
116
|
* Walk a set of JSONL tail lines and decide whether an AskUserQuestion is still
|
|
80
117
|
* OPEN — i.e. an assistant `tool_use` block named "AskUserQuestion" exists whose
|
|
@@ -365,8 +402,16 @@ export class SessionRegistry extends EventEmitter {
|
|
|
365
402
|
this._ctxMap = new Map();
|
|
366
403
|
/** @type {Map<string, boolean>} target -> actively-generating flag */
|
|
367
404
|
this._thinkingMap = new Map();
|
|
405
|
+
/** @type {Map<string, boolean>} target -> compacting-conversation flag */
|
|
406
|
+
this._compactingMap = new Map();
|
|
407
|
+
/** @type {Map<string, boolean>} target -> API-error/stall flag */
|
|
408
|
+
this._erroredMap = new Map();
|
|
409
|
+
/** @type {Map<string, boolean>} target -> has a sub-agent actively running */
|
|
410
|
+
this._subAgentActiveMap = new Map();
|
|
368
411
|
/** @type {Map<string, {pending:boolean, question:string|null}>} target -> pane-derived prompt */
|
|
369
412
|
this._panePromptMap = new Map();
|
|
413
|
+
/** @type {Map<string, {transcriptPath:string, sessionId?:string|null}>} target -> exact Codex rollout hint */
|
|
414
|
+
this._transcriptHintMap = new Map();
|
|
370
415
|
/** @type {Map<string, string>} target -> most-recent captured pane text (for fingerprint tiebreak) */
|
|
371
416
|
this._paneTextCache = new Map();
|
|
372
417
|
/** @type {number} monotonically-incrementing refresh() cycle counter */
|
|
@@ -419,15 +464,74 @@ export class SessionRegistry extends EventEmitter {
|
|
|
419
464
|
*/
|
|
420
465
|
setPending(id, pending) {
|
|
421
466
|
const session = this._sessions.find((s) => s.id === id);
|
|
467
|
+
this._pendingMap.set(id, !!pending);
|
|
422
468
|
if (!session) return;
|
|
469
|
+
const panePrompt = this._panePromptMap.get(id) ?? null;
|
|
423
470
|
const was = session.pending;
|
|
424
|
-
session.pending = !!pending;
|
|
425
|
-
this._pendingMap.set(id, !!pending);
|
|
471
|
+
session.pending = !!pending || !!panePrompt?.pending;
|
|
426
472
|
if (was !== session.pending) {
|
|
427
473
|
this._maybeEmit();
|
|
428
474
|
}
|
|
429
475
|
}
|
|
430
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Set a structured prompt surfaced by a non-transcript transport such as
|
|
479
|
+
* Codex app-server. Pane-scraped Claude prompts still flow through _pollThinking().
|
|
480
|
+
*
|
|
481
|
+
* @param {string} id
|
|
482
|
+
* @param {{question?: string|null}|null} prompt
|
|
483
|
+
*/
|
|
484
|
+
setPrompt(id, prompt) {
|
|
485
|
+
const rec = { pending: !!prompt, question: prompt?.question ?? null };
|
|
486
|
+
if (rec.pending) this._panePromptMap.set(id, rec);
|
|
487
|
+
else this._panePromptMap.delete(id);
|
|
488
|
+
|
|
489
|
+
const session = this._sessions.find((s) => s.id === id);
|
|
490
|
+
if (!session) return;
|
|
491
|
+
const wasPending = session.pending;
|
|
492
|
+
const wasQuestion = session.pendingQuestion ?? null;
|
|
493
|
+
session.pending = (this._pendingMap.get(id) ?? false) || rec.pending;
|
|
494
|
+
session.pendingQuestion = rec.question;
|
|
495
|
+
if (wasPending !== session.pending || wasQuestion !== session.pendingQuestion) {
|
|
496
|
+
this._maybeEmit();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Set active-generation state from a structured transport.
|
|
502
|
+
*
|
|
503
|
+
* @param {string} id
|
|
504
|
+
* @param {boolean} thinking
|
|
505
|
+
*/
|
|
506
|
+
setThinking(id, thinking) {
|
|
507
|
+
const session = this._sessions.find((s) => s.id === id);
|
|
508
|
+
const next = !!thinking;
|
|
509
|
+
this._thinkingMap.set(id, next);
|
|
510
|
+
if (!session) return;
|
|
511
|
+
const was = session.thinking;
|
|
512
|
+
session.thinking = next;
|
|
513
|
+
if (was !== next) {
|
|
514
|
+
this._maybeEmit();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Bind a pane target to an exact transcript path discovered from a structured
|
|
520
|
+
* transport (currently Codex app-server's thread.path). This is authoritative
|
|
521
|
+
* for that target and avoids cwd/time ambiguity.
|
|
522
|
+
*
|
|
523
|
+
* @param {string} id
|
|
524
|
+
* @param {{transcriptPath?: string|null, sessionId?: string|null}|null} hint
|
|
525
|
+
*/
|
|
526
|
+
setTranscriptHint(id, hint) {
|
|
527
|
+
if (!hint?.transcriptPath) this._transcriptHintMap.delete(id);
|
|
528
|
+
else this._transcriptHintMap.set(id, {
|
|
529
|
+
transcriptPath: hint.transcriptPath,
|
|
530
|
+
sessionId: hint.sessionId ?? null,
|
|
531
|
+
});
|
|
532
|
+
this.refresh().catch(() => {});
|
|
533
|
+
}
|
|
534
|
+
|
|
431
535
|
/**
|
|
432
536
|
* Rescan tmux windows and project directories. Returns the new session list.
|
|
433
537
|
*
|
|
@@ -464,10 +568,13 @@ export class SessionRegistry extends EventEmitter {
|
|
|
464
568
|
// only when ps is unavailable.
|
|
465
569
|
const paneProc = await this._buildPaneProc(panes);
|
|
466
570
|
const isClaudePane = (p) => {
|
|
571
|
+
if (p.ccAgent === 'claude') return true;
|
|
467
572
|
const info = paneProc.get(p.target);
|
|
468
573
|
return info ? info.isClaude : isClaudeCmd(p.cmd);
|
|
469
574
|
};
|
|
470
575
|
const paneKind = (p) => {
|
|
576
|
+
if (p.ccAgent === 'claude') return 'claude';
|
|
577
|
+
if (p.ccAgent === 'codex') return 'codex';
|
|
471
578
|
const info = paneProc.get(p.target);
|
|
472
579
|
if (info?.kind) return info.kind;
|
|
473
580
|
if (isClaudeCmd(p.cmd)) return 'claude';
|
|
@@ -503,8 +610,9 @@ export class SessionRegistry extends EventEmitter {
|
|
|
503
610
|
for (const p of claudePanes) {
|
|
504
611
|
if (pinnedByTarget.has(p.target)) continue;
|
|
505
612
|
const reg = p.paneId ? paneReg.get(p.paneId) : null;
|
|
506
|
-
|
|
507
|
-
|
|
613
|
+
const hint = this._transcriptHintMap.get(p.target) || reg;
|
|
614
|
+
if (!hint) continue;
|
|
615
|
+
const rec = await this._recordForPath(hint.transcriptPath);
|
|
508
616
|
if (rec) {
|
|
509
617
|
hookByTarget.set(p.target, rec);
|
|
510
618
|
pinnedPaths.add(rec.transcriptPath); // exclude from the auto-matcher pool
|
|
@@ -583,44 +691,113 @@ export class SessionRegistry extends EventEmitter {
|
|
|
583
691
|
// Discover Codex session transcripts and match them to Codex panes.
|
|
584
692
|
// The Claude assignment above is computed first and left untouched;
|
|
585
693
|
// codex results are merged in after.
|
|
694
|
+
//
|
|
695
|
+
// Binding strategy (authoritative → heuristic):
|
|
696
|
+
// 1. lsof: the live codex process holds its rollout file OPEN — lsof on
|
|
697
|
+
// its pid gives the exact path. Zero-ambiguity; self-heals on resume.
|
|
698
|
+
// 2. cwd + mtime heuristic (assignTranscripts): fallback for panes whose
|
|
699
|
+
// pid is unknown or whose lsof call failed.
|
|
586
700
|
const codexPanes = panes.filter((p) => paneKind(p) === 'codex');
|
|
587
701
|
if (codexPanes.length > 0) {
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
702
|
+
// --- Phase 1: exact binding via RPC hints, pane registry, then lsof -------
|
|
703
|
+
const exactByTarget = new Map();
|
|
704
|
+
const exactPaths = new Set();
|
|
705
|
+
const appServerTargets = new Set();
|
|
706
|
+
await Promise.all(
|
|
707
|
+
codexPanes.map(async (p) => {
|
|
708
|
+
try {
|
|
709
|
+
const procInfo = paneProc.get(p.target);
|
|
710
|
+
if (procInfo?.appServer) appServerTargets.add(p.target);
|
|
711
|
+
if (appServerTargets.has(p.target) && p.ccTransport !== 'rpc') return;
|
|
712
|
+
|
|
713
|
+
const runtimeHint = this._transcriptHintMap.get(p.target);
|
|
714
|
+
if (runtimeHint?.transcriptPath) {
|
|
715
|
+
const rec = await readCodexTranscriptRecord(runtimeHint.transcriptPath);
|
|
716
|
+
if (rec && isCwdConsistent(rec.cwd, p.cwd)) {
|
|
717
|
+
exactByTarget.set(p.target, codexRecordToCandidate({
|
|
718
|
+
...rec,
|
|
719
|
+
sessionId: rec.sessionId ?? runtimeHint.sessionId ?? null,
|
|
720
|
+
}));
|
|
721
|
+
exactPaths.add(rec.transcriptPath);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const reg = p.paneId ? paneReg.get(p.paneId) : null;
|
|
727
|
+
const codexPid = procInfo?.pid ?? null;
|
|
728
|
+
if (!codexPid) {
|
|
729
|
+
if (reg?.transcriptPath) {
|
|
730
|
+
const rec = await readCodexTranscriptRecord(reg.transcriptPath);
|
|
731
|
+
if (rec && isCwdConsistent(rec.cwd, p.cwd)) {
|
|
732
|
+
exactByTarget.set(p.target, codexRecordToCandidate({
|
|
733
|
+
...rec,
|
|
734
|
+
sessionId: rec.sessionId ?? reg.sessionId ?? null,
|
|
735
|
+
}));
|
|
736
|
+
exactPaths.add(rec.transcriptPath);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (appServerTargets.has(p.target)) {
|
|
742
|
+
// App-server processes may hold multiple rollout files for
|
|
743
|
+
// multiple RPC threads. Only runtime/pane-registry hints are
|
|
744
|
+
// authoritative enough to bind one back to this tmux pane.
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const rolloutPath = await findOpenRollout(codexPid);
|
|
748
|
+
if (!rolloutPath || exactPaths.has(rolloutPath)) {
|
|
749
|
+
if (reg?.transcriptPath) {
|
|
750
|
+
const rec = await readCodexTranscriptRecord(reg.transcriptPath);
|
|
751
|
+
if (rec && isCwdConsistent(rec.cwd, p.cwd)) {
|
|
752
|
+
exactByTarget.set(p.target, codexRecordToCandidate({
|
|
753
|
+
...rec,
|
|
754
|
+
sessionId: rec.sessionId ?? reg.sessionId ?? null,
|
|
755
|
+
}));
|
|
756
|
+
exactPaths.add(rec.transcriptPath);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const rec = await readCodexTranscriptRecord(rolloutPath);
|
|
762
|
+
if (!rec) return;
|
|
763
|
+
if (!isCwdConsistent(rec.cwd, p.cwd)) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
exactByTarget.set(p.target, codexRecordToCandidate(rec));
|
|
767
|
+
exactPaths.add(rec.transcriptPath);
|
|
768
|
+
} catch {
|
|
769
|
+
// exact lookup failed; heuristic fallback below can still bind it
|
|
770
|
+
}
|
|
771
|
+
}),
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
// --- Phase 2: heuristic fallback for unresolved panes ------------------
|
|
775
|
+
const unresolved = codexPanes.filter((p) =>
|
|
776
|
+
!exactByTarget.has(p.target) && !appServerTargets.has(p.target),
|
|
777
|
+
);
|
|
778
|
+
if (unresolved.length > 0) {
|
|
779
|
+
const codexIndex = await buildCodexIndex({ codexSessionsRoot: this._codexSessionsRoot });
|
|
780
|
+
const codexCandidates = [];
|
|
781
|
+
// Use every active rollout, not only the newest per cwd. Legacy TUI and
|
|
782
|
+
// RPC app-server panes often share one cwd; collapsing by cwd can leave
|
|
783
|
+
// the older still-live pane with no fallback candidate.
|
|
784
|
+
for (const rec of codexIndex.byPath.values()) {
|
|
785
|
+
if (!exactPaths.has(rec.transcriptPath)) {
|
|
786
|
+
codexCandidates.push(codexRecordToCandidate(rec));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const codexPaneInputs = unresolved.map((p) => ({
|
|
790
|
+
target: p.target,
|
|
791
|
+
windowName: p.windowName,
|
|
792
|
+
cwd: p.cwd,
|
|
793
|
+
projectDir: null,
|
|
794
|
+
procStartMs: paneProc.get(p.target)?.startMs ?? null,
|
|
795
|
+
capturedText: this._paneTextCache.get(p.target) ?? null,
|
|
796
|
+
}));
|
|
797
|
+
const codexAssignment = assignTranscripts(codexPaneInputs, codexCandidates);
|
|
798
|
+
for (const [t, rec] of codexAssignment) assignment.set(t, rec);
|
|
613
799
|
}
|
|
614
|
-
const
|
|
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);
|
|
800
|
+
for (const [t, rec] of exactByTarget) assignment.set(t, rec);
|
|
624
801
|
}
|
|
625
802
|
// ── End Codex matching ───────────────────────────────────────────────────
|
|
626
803
|
|
|
@@ -634,7 +811,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
634
811
|
// Pending = subscribed-tailer pending (live modal) OR transcript-derived
|
|
635
812
|
// pending OR pane-derived prompt (a numbered picker on screen — catches
|
|
636
813
|
// questions even when the transcript isn't matched). Works for ANY session.
|
|
637
|
-
const panePrompt =
|
|
814
|
+
const panePrompt = this._panePromptMap.get(id) ?? null;
|
|
638
815
|
const pending =
|
|
639
816
|
(this._pendingMap.get(id) ?? false) ||
|
|
640
817
|
!!transcript?.transcriptPending ||
|
|
@@ -670,11 +847,16 @@ export class SessionRegistry extends EventEmitter {
|
|
|
670
847
|
cmd: win.cmd,
|
|
671
848
|
isClaude,
|
|
672
849
|
kind,
|
|
850
|
+
transport: (kind === 'claude' || kind === 'codex') ? (win.ccTransport || 'tmux') : null,
|
|
851
|
+
endpoint: win.ccEndpoint || null,
|
|
673
852
|
ccShell: !!win.ccShell, // a composer >_ sister shell pane
|
|
674
853
|
|
|
675
854
|
model: ctx.model || prettyModel(transcript?.model) || null,
|
|
676
855
|
ctxPct: ctx.ctxPct ?? null,
|
|
677
856
|
thinking: (isClaude || kind === 'codex') ? this._thinkingMap.get(win.target) ?? false : false,
|
|
857
|
+
compacting: (isClaude || kind === 'codex') ? this._compactingMap.get(win.target) ?? false : false,
|
|
858
|
+
errored: (isClaude || kind === 'codex') ? this._erroredMap.get(win.target) ?? false : false,
|
|
859
|
+
subAgentActive: isClaude ? this._subAgentActiveMap.get(win.target) ?? false : false,
|
|
678
860
|
usagePct: transcript?.usagePct ?? null,
|
|
679
861
|
usageWindowMin: transcript?.usageWindowMin ?? null,
|
|
680
862
|
};
|
|
@@ -701,6 +883,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
701
883
|
sessions.map(async (s) => {
|
|
702
884
|
if (!this._tmux.isValidTarget(s.target)) return;
|
|
703
885
|
try {
|
|
886
|
+
if (s.transport === 'print') return;
|
|
704
887
|
const cap = await this._tmux.capturePane(s.target, 8);
|
|
705
888
|
// Codex panes use the codex header/footer parser (the Claude tui.js
|
|
706
889
|
// parser doesn't match codex's "model:"/footer formats). Codex has no
|
|
@@ -736,17 +919,35 @@ export class SessionRegistry extends EventEmitter {
|
|
|
736
919
|
sessions.map(async (s) => {
|
|
737
920
|
if (!this._tmux.isValidTarget(s.target)) return;
|
|
738
921
|
try {
|
|
739
|
-
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
|
|
744
|
-
|
|
922
|
+
if (s.transport === 'print') return;
|
|
923
|
+
// Capture the VISIBLE pane only (no scrollback). One capture feeds the
|
|
924
|
+
// working line ("esc to interrupt"), the TUI question picker
|
|
925
|
+
// (parsePanePrompt), and the codex prompt parse. Scrollback MUST be
|
|
926
|
+
// excluded: a `-S -N` window pulls in an already-answered picker frozen
|
|
927
|
+
// in history (still showing its ❯ cursor + "esc to cancel" footer),
|
|
928
|
+
// which re-fires the prompt after it was answered and lets stray
|
|
929
|
+
// numbered prose look like a live menu. The live picker is always on
|
|
930
|
+
// the visible screen, so visible-only is both sufficient and accurate.
|
|
931
|
+
const cap = await this._tmux.capturePane(s.target, 26, false, false, { visibleOnly: true });
|
|
932
|
+
const { thinking, compacting, errored } = parseTuiStatus(cap);
|
|
933
|
+
this._erroredMap.set(s.target, errored);
|
|
934
|
+
s.errored = errored;
|
|
745
935
|
this._thinkingMap.set(s.target, thinking);
|
|
936
|
+
this._compactingMap.set(s.target, compacting);
|
|
746
937
|
// Cache raw capture text for the content-fingerprint tiebreak in
|
|
747
938
|
// the next refresh() — cheap: already captured here.
|
|
748
939
|
this._paneTextCache.set(s.target, cap);
|
|
749
940
|
s.thinking = thinking;
|
|
941
|
+
s.compacting = compacting;
|
|
942
|
+
|
|
943
|
+
// Sub-agent activity (Claude only) — scan the session's subagents dir so
|
|
944
|
+
// the rail's "cloning" state lights for EVERY window, not just the one a
|
|
945
|
+
// client subscribed to (the SubAgentsWatcher is subscription-scoped).
|
|
946
|
+
if (s.kind === 'claude') {
|
|
947
|
+
const subActive = hasActiveSubAgents(s.transcriptPath);
|
|
948
|
+
this._subAgentActiveMap.set(s.target, subActive);
|
|
949
|
+
s.subAgentActive = subActive;
|
|
950
|
+
}
|
|
750
951
|
|
|
751
952
|
// Pane-derived question detection (Claude panes only): an on-screen
|
|
752
953
|
// numbered picker means a question is waiting — even if the transcript
|
|
@@ -759,6 +960,17 @@ export class SessionRegistry extends EventEmitter {
|
|
|
759
960
|
(this._pendingMap.get(s.target) ?? false) || rec.pending || s.pending;
|
|
760
961
|
s.pending = merged;
|
|
761
962
|
if (rec.pending && !s.pendingQuestion) s.pendingQuestion = rec.question;
|
|
963
|
+
} else if (s.kind === 'codex') {
|
|
964
|
+
// Pane-derived question detection for Codex panes: parseCodexPrompt
|
|
965
|
+
// detects approval modals and planning questions on screen. Feeds the
|
|
966
|
+
// same _panePromptMap so refresh() can surface the ASK sidebar icon.
|
|
967
|
+
const prompt = parseCodexPrompt(cap);
|
|
968
|
+
const rec = { pending: !!prompt, question: prompt?.question ?? null };
|
|
969
|
+
this._panePromptMap.set(s.target, rec);
|
|
970
|
+
const merged =
|
|
971
|
+
(this._pendingMap.get(s.target) ?? false) || rec.pending || s.pending;
|
|
972
|
+
s.pending = merged;
|
|
973
|
+
if (rec.pending && !s.pendingQuestion) s.pendingQuestion = rec.question;
|
|
762
974
|
}
|
|
763
975
|
} catch {
|
|
764
976
|
// pane gone / capture failed — leave previous value
|
|
@@ -882,18 +1094,18 @@ export class SessionRegistry extends EventEmitter {
|
|
|
882
1094
|
}
|
|
883
1095
|
|
|
884
1096
|
/**
|
|
885
|
-
* Classify each pane and resolve its
|
|
1097
|
+
* Classify each pane and resolve its agent-process start time in ONE `ps`
|
|
886
1098
|
* snapshot. A pane is a Claude session iff its process subtree (from the pane
|
|
887
1099
|
* shell pid) contains a `claude` descendant — far more reliable than the
|
|
888
1100
|
* `pane_current_command` version-regex, which flips to `node`/`git` while
|
|
889
|
-
* Claude runs a tool. The same walk yields the
|
|
1101
|
+
* Claude runs a tool. The same walk yields the agent start time (ms epoch)
|
|
890
1102
|
* for the start-time matching fallback.
|
|
891
1103
|
*
|
|
892
1104
|
* Best-effort: if `ps` is unavailable every pane maps to {isClaude:false,
|
|
893
1105
|
* startMs:null} and callers fall back to the cmd heuristic / other passes.
|
|
894
1106
|
*
|
|
895
1107
|
* @param {import('./tmux.js').Window[]} allPanes
|
|
896
|
-
* @returns {Promise<Map<string, {isClaude: boolean, startMs: number|null}>>} target -> info
|
|
1108
|
+
* @returns {Promise<Map<string, {isClaude: boolean, isCodex: boolean, kind: string|null, startMs: number|null, appServer?: boolean}>>} target -> info
|
|
897
1109
|
*/
|
|
898
1110
|
async _buildPaneProc(allPanes) {
|
|
899
1111
|
const out = new Map();
|
|
@@ -903,7 +1115,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
903
1115
|
try {
|
|
904
1116
|
const { stdout } = await execFile(
|
|
905
1117
|
'ps',
|
|
906
|
-
['-axo', 'pid=,ppid=,etime=,comm='],
|
|
1118
|
+
['-axo', 'pid=,ppid=,etime=,comm=,args='],
|
|
907
1119
|
{ timeout: 5000, maxBuffer: 8 * 1024 * 1024 },
|
|
908
1120
|
);
|
|
909
1121
|
rows = stdout.split('\n');
|
|
@@ -913,43 +1125,60 @@ export class SessionRegistry extends EventEmitter {
|
|
|
913
1125
|
|
|
914
1126
|
/** @type {Map<number, number[]>} ppid -> child pids */
|
|
915
1127
|
const children = new Map();
|
|
916
|
-
/** @type {Map<number, {etime:string, comm:string}>} */
|
|
1128
|
+
/** @type {Map<number, {etime:string, comm:string, args:string}>} */
|
|
917
1129
|
const info = new Map();
|
|
918
1130
|
for (const line of rows) {
|
|
919
|
-
const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
1131
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
|
|
920
1132
|
if (!m) continue;
|
|
921
1133
|
const pid = Number(m[1]);
|
|
922
1134
|
const ppid = Number(m[2]);
|
|
923
|
-
info.set(pid, { etime: m[3], comm: m[4] });
|
|
1135
|
+
info.set(pid, { etime: m[3], comm: m[4], args: m[5] || '' });
|
|
924
1136
|
if (!children.has(ppid)) children.set(ppid, []);
|
|
925
1137
|
children.get(ppid).push(pid);
|
|
926
1138
|
}
|
|
927
1139
|
|
|
928
1140
|
const now = Date.now();
|
|
929
|
-
// BFS from the pane shell pid for a `claude` or `codex` descendant; return its start.
|
|
1141
|
+
// BFS from the pane shell pid for a `claude` or `codex` descendant; return its start + pid.
|
|
930
1142
|
const findClaude = (rootPid) => {
|
|
931
1143
|
const queue = [rootPid];
|
|
932
1144
|
const seen = new Set();
|
|
1145
|
+
let codexFallback = null;
|
|
933
1146
|
while (queue.length) {
|
|
934
1147
|
const pid = queue.shift();
|
|
935
1148
|
if (seen.has(pid)) continue;
|
|
936
1149
|
seen.add(pid);
|
|
937
1150
|
const meta = info.get(pid);
|
|
938
|
-
if (meta && CLAUDE_COMM_RE.test(meta.comm)) {
|
|
1151
|
+
if (meta && (CLAUDE_COMM_RE.test(meta.comm) || CLAUDE_ARG_RE.test(meta.args))) {
|
|
939
1152
|
const sec = parseEtime(meta.etime);
|
|
940
|
-
return { isClaude: true, isCodex: false, kind: 'claude', startMs: sec == null ? null : now - sec * 1000 };
|
|
1153
|
+
return { isClaude: true, isCodex: false, kind: 'claude', startMs: sec == null ? null : now - sec * 1000, pid };
|
|
941
1154
|
}
|
|
942
|
-
|
|
1155
|
+
const codexKind = meta
|
|
1156
|
+
? (CODEX_COMM_RE.test(meta.comm) ? 'direct' : codexProcessMatchKind(meta.args))
|
|
1157
|
+
: null;
|
|
1158
|
+
if (codexKind) {
|
|
943
1159
|
const sec = parseEtime(meta.etime);
|
|
944
|
-
|
|
1160
|
+
const codexInfo = {
|
|
1161
|
+
isClaude: false,
|
|
1162
|
+
isCodex: true,
|
|
1163
|
+
kind: 'codex',
|
|
1164
|
+
startMs: sec == null ? null : now - sec * 1000,
|
|
1165
|
+
pid,
|
|
1166
|
+
appServer: isCodexAppServerArgs(meta.args),
|
|
1167
|
+
};
|
|
1168
|
+
// npm/nvm installs launch Codex as `node .../bin/codex`, which then
|
|
1169
|
+
// spawns the native Codex child. The native child holds the rollout
|
|
1170
|
+
// file open, so prefer it for lsof-based transcript binding while
|
|
1171
|
+
// keeping the wrapper as a fallback when no child is visible yet.
|
|
1172
|
+
if (codexKind === 'direct') return codexInfo;
|
|
1173
|
+
if (!codexFallback) codexFallback = codexInfo;
|
|
945
1174
|
}
|
|
946
1175
|
for (const c of children.get(pid) ?? []) queue.push(c);
|
|
947
1176
|
}
|
|
948
|
-
return { isClaude: false, isCodex: false, kind: null, startMs: null };
|
|
1177
|
+
return codexFallback || { isClaude: false, isCodex: false, kind: null, startMs: null, pid: null, appServer: false };
|
|
949
1178
|
};
|
|
950
1179
|
|
|
951
1180
|
for (const p of allPanes) {
|
|
952
|
-
out.set(p.target, p.panePid ? findClaude(p.panePid) : { isClaude: false, isCodex: false, kind: null, startMs: null });
|
|
1181
|
+
out.set(p.target, p.panePid ? findClaude(p.panePid) : { isClaude: false, isCodex: false, kind: null, startMs: null, pid: null, appServer: false });
|
|
953
1182
|
}
|
|
954
1183
|
return out;
|
|
955
1184
|
}
|
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
|