@idl3/claude-control 1.4.5 → 1.4.6
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 +121 -14
- package/package.json +1 -1
- package/server.js +8 -5
package/lib/sessions.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { EventEmitter } from 'node:events';
|
|
11
11
|
import fs from 'node:fs/promises';
|
|
12
|
+
import { watch as fsWatch } from 'node:fs';
|
|
12
13
|
import path from 'node:path';
|
|
13
14
|
import { execFile as _execFile } from 'node:child_process';
|
|
14
15
|
import { promisify } from 'node:util';
|
|
@@ -37,10 +38,16 @@ const CLAUDE_COMM_RE = /(^|\/)claude$/;
|
|
|
37
38
|
// Matches Codex CLI executable basename.
|
|
38
39
|
const CODEX_COMM_RE = /(^|\/)codex$/;
|
|
39
40
|
const CLAUDE_ARG_RE = /(^|[\s/])claude(?:\s|$)/;
|
|
41
|
+
const LOCAL_WS_RE = /\bws:\/\/(?:127\.0\.0\.1|localhost|\[::1\]|::1):\d+\b/;
|
|
40
42
|
|
|
41
43
|
function isCodexAppServerArgs(args) {
|
|
42
44
|
const s = String(args || '');
|
|
43
|
-
return /\bapp-server\b/.test(s) &&
|
|
45
|
+
return /\bapp-server\b/.test(s) && /(?:^|\s)--listen(?:[=\s]|$)/.test(s);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function codexAppServerEndpointFromArgs(args) {
|
|
49
|
+
const m = LOCAL_WS_RE.exec(String(args || ''));
|
|
50
|
+
return m ? m[0] : null;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
// A pane is a Claude Code session when its process title is the Claude version
|
|
@@ -87,6 +94,34 @@ export function isCwdConsistent(recCwd, winCwd) {
|
|
|
87
94
|
|
|
88
95
|
const PENDING_QUESTION_MAX = 140; // truncate the surfaced question text
|
|
89
96
|
|
|
97
|
+
// A pane is scraped (capture-pane) by the 2 s thinking poll only while it's
|
|
98
|
+
// "live"; idle backgrounded panes are skipped to cut needless tmux execs. A pane
|
|
99
|
+
// stays live for this long after its transcript last changed.
|
|
100
|
+
const ACTIVE_SCRAPE_WINDOW_MS = 20_000;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Should the 2 s thinking poll scrape this pane? True when:
|
|
104
|
+
* - it carries a live flag (thinking/compacting/pending/errored) — keep polling
|
|
105
|
+
* until it settles/clears, OR
|
|
106
|
+
* - it has no transcript to gate on (can't tell; scrape to be safe), OR
|
|
107
|
+
* - its transcript changed recently — via fs.watch (`activeUntilMs`) or the 4 s
|
|
108
|
+
* tail read (`lastActivityMs`) as a backstop.
|
|
109
|
+
* Otherwise it's idle → skip the capture.
|
|
110
|
+
*
|
|
111
|
+
* @param {{thinking?:boolean,compacting?:boolean,pending?:boolean,errored?:boolean,transcriptPath?:string|null,lastActivityMs?:number|null}} s
|
|
112
|
+
* @param {number} activeUntilMs fs.watch-fed "active until" timestamp for this transcript (0 if none)
|
|
113
|
+
* @param {number} now
|
|
114
|
+
* @param {number} windowMs
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
export function shouldScrapePane(s, activeUntilMs, now, windowMs = ACTIVE_SCRAPE_WINDOW_MS) {
|
|
118
|
+
if (s.thinking || s.compacting || s.pending || s.errored) return true;
|
|
119
|
+
if (!s.transcriptPath) return true;
|
|
120
|
+
if (activeUntilMs && now < activeUntilMs) return true;
|
|
121
|
+
if (s.lastActivityMs && now - s.lastActivityMs < windowMs) return true;
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
90
125
|
function codexRecordToCandidate(rec) {
|
|
91
126
|
return {
|
|
92
127
|
transcriptPath: rec.transcriptPath,
|
|
@@ -406,6 +441,10 @@ export class SessionRegistry extends EventEmitter {
|
|
|
406
441
|
this._compactingMap = new Map();
|
|
407
442
|
/** @type {Map<string, boolean>} target -> API-error/stall flag */
|
|
408
443
|
this._erroredMap = new Map();
|
|
444
|
+
/** @type {Map<string, number>} transcriptPath -> "scrape until" ts (fs.watch-fed) */
|
|
445
|
+
this._activeUntil = new Map();
|
|
446
|
+
/** @type {Map<string, import('node:fs').FSWatcher>} transcriptPath -> watcher */
|
|
447
|
+
this._transcriptWatchers = new Map();
|
|
409
448
|
/** @type {Map<string, boolean>} target -> has a sub-agent actively running */
|
|
410
449
|
this._subAgentActiveMap = new Map();
|
|
411
450
|
/** @type {Map<string, {pending:boolean, question:string|null}>} target -> pane-derived prompt */
|
|
@@ -707,8 +746,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
707
746
|
codexPanes.map(async (p) => {
|
|
708
747
|
try {
|
|
709
748
|
const procInfo = paneProc.get(p.target);
|
|
710
|
-
if (procInfo?.appServer) appServerTargets.add(p.target);
|
|
711
|
-
if (appServerTargets.has(p.target) && p.ccTransport !== 'rpc') return;
|
|
749
|
+
if (procInfo?.appServer || procInfo?.appServerEndpoint) appServerTargets.add(p.target);
|
|
712
750
|
|
|
713
751
|
const runtimeHint = this._transcriptHintMap.get(p.target);
|
|
714
752
|
if (runtimeHint?.transcriptPath) {
|
|
@@ -725,7 +763,10 @@ export class SessionRegistry extends EventEmitter {
|
|
|
725
763
|
|
|
726
764
|
const reg = p.paneId ? paneReg.get(p.paneId) : null;
|
|
727
765
|
const codexPid = procInfo?.pid ?? null;
|
|
728
|
-
if (
|
|
766
|
+
if (appServerTargets.has(p.target)) {
|
|
767
|
+
// App-server processes may hold multiple rollout files for
|
|
768
|
+
// multiple RPC threads. Only runtime/pane-registry hints are
|
|
769
|
+
// authoritative enough to bind one back to this tmux pane.
|
|
729
770
|
if (reg?.transcriptPath) {
|
|
730
771
|
const rec = await readCodexTranscriptRecord(reg.transcriptPath);
|
|
731
772
|
if (rec && isCwdConsistent(rec.cwd, p.cwd)) {
|
|
@@ -738,10 +779,17 @@ export class SessionRegistry extends EventEmitter {
|
|
|
738
779
|
}
|
|
739
780
|
return;
|
|
740
781
|
}
|
|
741
|
-
if (
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
782
|
+
if (!codexPid) {
|
|
783
|
+
if (reg?.transcriptPath) {
|
|
784
|
+
const rec = await readCodexTranscriptRecord(reg.transcriptPath);
|
|
785
|
+
if (rec && isCwdConsistent(rec.cwd, p.cwd)) {
|
|
786
|
+
exactByTarget.set(p.target, codexRecordToCandidate({
|
|
787
|
+
...rec,
|
|
788
|
+
sessionId: rec.sessionId ?? reg.sessionId ?? null,
|
|
789
|
+
}));
|
|
790
|
+
exactPaths.add(rec.transcriptPath);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
745
793
|
return;
|
|
746
794
|
}
|
|
747
795
|
const rolloutPath = await findOpenRollout(codexPid);
|
|
@@ -823,6 +871,17 @@ export class SessionRegistry extends EventEmitter {
|
|
|
823
871
|
// polled codex model and the rail would show no model for codex rows.
|
|
824
872
|
const ctx = isClaude || kind === 'codex' ? this._ctxMap.get(win.target) || {} : {};
|
|
825
873
|
|
|
874
|
+
const procInfo = paneProc.get(win.target);
|
|
875
|
+
const codexAppServer = kind === 'codex' && (!!procInfo?.appServer || !!procInfo?.appServerEndpoint);
|
|
876
|
+
const transport = kind === 'claude'
|
|
877
|
+
? (win.ccTransport || 'tmux')
|
|
878
|
+
: kind === 'codex'
|
|
879
|
+
? (codexAppServer ? 'rpc' : (win.ccTransport || 'tmux'))
|
|
880
|
+
: null;
|
|
881
|
+
const endpoint = kind === 'codex'
|
|
882
|
+
? (win.ccEndpoint || procInfo?.appServerEndpoint || null)
|
|
883
|
+
: (win.ccEndpoint || null);
|
|
884
|
+
|
|
826
885
|
return {
|
|
827
886
|
id,
|
|
828
887
|
sessionId: transcript?.sessionId ?? null,
|
|
@@ -847,8 +906,8 @@ export class SessionRegistry extends EventEmitter {
|
|
|
847
906
|
cmd: win.cmd,
|
|
848
907
|
isClaude,
|
|
849
908
|
kind,
|
|
850
|
-
transport
|
|
851
|
-
endpoint
|
|
909
|
+
transport,
|
|
910
|
+
endpoint,
|
|
852
911
|
ccShell: !!win.ccShell, // a composer >_ sister shell pane
|
|
853
912
|
|
|
854
913
|
model: ctx.model || prettyModel(transcript?.model) || null,
|
|
@@ -865,10 +924,46 @@ export class SessionRegistry extends EventEmitter {
|
|
|
865
924
|
// Surface EVERY pane: Claude sessions AND plain terminals (each pane is a row;
|
|
866
925
|
// terminals render a live interactive terminal instead of a transcript).
|
|
867
926
|
this._sessions = sessions;
|
|
927
|
+
this._syncTranscriptWatchers();
|
|
868
928
|
this._maybeEmit();
|
|
869
929
|
return this._sessions;
|
|
870
930
|
}
|
|
871
931
|
|
|
932
|
+
/**
|
|
933
|
+
* Keep one fs.watch per live transcript so a change instantly marks that pane
|
|
934
|
+
* "active" (scrape-worthy) for the next thinking poll — replacing blanket 2 s
|
|
935
|
+
* scraping of idle panes. Best-effort: a watch that fails to attach just means
|
|
936
|
+
* that pane falls back to the lastActivityMs backstop in shouldScrapePane.
|
|
937
|
+
*/
|
|
938
|
+
_syncTranscriptWatchers() {
|
|
939
|
+
const wanted = new Set();
|
|
940
|
+
for (const s of this._sessions) {
|
|
941
|
+
if (s.transcriptPath) wanted.add(s.transcriptPath);
|
|
942
|
+
}
|
|
943
|
+
// Add watchers for new transcripts; seed them active so a freshly-appeared
|
|
944
|
+
// session is scraped right away, then settles into the gated cadence.
|
|
945
|
+
for (const p of wanted) {
|
|
946
|
+
if (this._transcriptWatchers.has(p)) continue;
|
|
947
|
+
try {
|
|
948
|
+
const w = fsWatch(p, { persistent: false }, () => {
|
|
949
|
+
this._activeUntil.set(p, Date.now() + ACTIVE_SCRAPE_WINDOW_MS);
|
|
950
|
+
});
|
|
951
|
+
w.on('error', () => {}); // ignore — backstop covers it
|
|
952
|
+
this._transcriptWatchers.set(p, w);
|
|
953
|
+
this._activeUntil.set(p, Date.now() + ACTIVE_SCRAPE_WINDOW_MS);
|
|
954
|
+
} catch {
|
|
955
|
+
/* unwatchable (gone / FD limit) — lastActivityMs backstop handles it */
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
// Drop watchers for transcripts no longer present.
|
|
959
|
+
for (const [p, w] of this._transcriptWatchers) {
|
|
960
|
+
if (wanted.has(p)) continue;
|
|
961
|
+
try { w.close(); } catch { /* ignore */ }
|
|
962
|
+
this._transcriptWatchers.delete(p);
|
|
963
|
+
this._activeUntil.delete(p);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
872
967
|
/**
|
|
873
968
|
* Capture each Claude pane's TUI status line and parse model + context %.
|
|
874
969
|
* Throttled (separate from the 4 s refresh) and best-effort — capture-pane is
|
|
@@ -920,6 +1015,11 @@ export class SessionRegistry extends EventEmitter {
|
|
|
920
1015
|
if (!this._tmux.isValidTarget(s.target)) return;
|
|
921
1016
|
try {
|
|
922
1017
|
if (s.transport === 'print') return;
|
|
1018
|
+
// Skip idle backgrounded panes — only scrape while the pane is live
|
|
1019
|
+
// (flagged) or its transcript changed recently. Cuts capture-pane execs
|
|
1020
|
+
// for sleeping sessions; active/pending/errored panes keep updating.
|
|
1021
|
+
const activeUntil = s.transcriptPath ? this._activeUntil.get(s.transcriptPath) ?? 0 : 0;
|
|
1022
|
+
if (!shouldScrapePane(s, activeUntil, Date.now(), ACTIVE_SCRAPE_WINDOW_MS)) return;
|
|
923
1023
|
// Capture the VISIBLE pane only (no scrollback). One capture feeds the
|
|
924
1024
|
// working line ("esc to interrupt"), the TUI question picker
|
|
925
1025
|
// (parsePanePrompt), and the codex prompt parse. Scrollback MUST be
|
|
@@ -1019,6 +1119,11 @@ export class SessionRegistry extends EventEmitter {
|
|
|
1019
1119
|
clearInterval(this._thinkingInterval);
|
|
1020
1120
|
this._thinkingInterval = null;
|
|
1021
1121
|
}
|
|
1122
|
+
for (const w of this._transcriptWatchers.values()) {
|
|
1123
|
+
try { w.close(); } catch { /* ignore */ }
|
|
1124
|
+
}
|
|
1125
|
+
this._transcriptWatchers.clear();
|
|
1126
|
+
this._activeUntil.clear();
|
|
1022
1127
|
}
|
|
1023
1128
|
|
|
1024
1129
|
// -------------------------------------------------------------------------
|
|
@@ -1105,7 +1210,7 @@ export class SessionRegistry extends EventEmitter {
|
|
|
1105
1210
|
* startMs:null} and callers fall back to the cmd heuristic / other passes.
|
|
1106
1211
|
*
|
|
1107
1212
|
* @param {import('./tmux.js').Window[]} allPanes
|
|
1108
|
-
* @returns {Promise<Map<string, {isClaude: boolean, isCodex: boolean, kind: string|null, startMs: number|null, appServer?: boolean}>>} target -> info
|
|
1213
|
+
* @returns {Promise<Map<string, {isClaude: boolean, isCodex: boolean, kind: string|null, startMs: number|null, appServer?: boolean, appServerEndpoint?: string|null}>>} target -> info
|
|
1109
1214
|
*/
|
|
1110
1215
|
async _buildPaneProc(allPanes) {
|
|
1111
1216
|
const out = new Map();
|
|
@@ -1157,13 +1262,15 @@ export class SessionRegistry extends EventEmitter {
|
|
|
1157
1262
|
: null;
|
|
1158
1263
|
if (codexKind) {
|
|
1159
1264
|
const sec = parseEtime(meta.etime);
|
|
1265
|
+
const appServerEndpoint = codexAppServerEndpointFromArgs(meta.args);
|
|
1160
1266
|
const codexInfo = {
|
|
1161
1267
|
isClaude: false,
|
|
1162
1268
|
isCodex: true,
|
|
1163
1269
|
kind: 'codex',
|
|
1164
1270
|
startMs: sec == null ? null : now - sec * 1000,
|
|
1165
1271
|
pid,
|
|
1166
|
-
appServer: isCodexAppServerArgs(meta.args),
|
|
1272
|
+
appServer: isCodexAppServerArgs(meta.args) || !!appServerEndpoint,
|
|
1273
|
+
appServerEndpoint,
|
|
1167
1274
|
};
|
|
1168
1275
|
// npm/nvm installs launch Codex as `node .../bin/codex`, which then
|
|
1169
1276
|
// spawns the native Codex child. The native child holds the rollout
|
|
@@ -1174,11 +1281,11 @@ export class SessionRegistry extends EventEmitter {
|
|
|
1174
1281
|
}
|
|
1175
1282
|
for (const c of children.get(pid) ?? []) queue.push(c);
|
|
1176
1283
|
}
|
|
1177
|
-
return codexFallback || { isClaude: false, isCodex: false, kind: null, startMs: null, pid: null, appServer: false };
|
|
1284
|
+
return codexFallback || { isClaude: false, isCodex: false, kind: null, startMs: null, pid: null, appServer: false, appServerEndpoint: null };
|
|
1178
1285
|
};
|
|
1179
1286
|
|
|
1180
1287
|
for (const p of allPanes) {
|
|
1181
|
-
out.set(p.target, p.panePid ? findClaude(p.panePid) : { isClaude: false, isCodex: false, kind: null, startMs: null, pid: null, appServer: false });
|
|
1288
|
+
out.set(p.target, p.panePid ? findClaude(p.panePid) : { isClaude: false, isCodex: false, kind: null, startMs: null, pid: null, appServer: false, appServerEndpoint: null });
|
|
1182
1289
|
}
|
|
1183
1290
|
return out;
|
|
1184
1291
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idl3/claude-control",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local web UI to watch and drive your Claude Code sessions running in tmux — live transcripts, reply, answer AskUserQuestion, attach files, from a browser or phone.",
|
|
6
6
|
"keywords": [
|
package/server.js
CHANGED
|
@@ -1734,12 +1734,15 @@ async function ensureCodexRpcForSession(session) {
|
|
|
1734
1734
|
if (existing) return existing;
|
|
1735
1735
|
|
|
1736
1736
|
let capture = '';
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1737
|
+
let endpoint = session.endpoint || null;
|
|
1738
|
+
if (!endpoint) {
|
|
1739
|
+
try {
|
|
1740
|
+
capture = await tmux.capturePane(session.target, 200, false, true);
|
|
1741
|
+
} catch {
|
|
1742
|
+
return null;
|
|
1743
|
+
}
|
|
1744
|
+
endpoint = parseCodexAppServerEndpoint(capture);
|
|
1741
1745
|
}
|
|
1742
|
-
const endpoint = parseCodexAppServerEndpoint(capture);
|
|
1743
1746
|
if (!endpoint) {
|
|
1744
1747
|
if (isCodexAppServerCapture(capture)) {
|
|
1745
1748
|
throw new Error('Codex RPC app-server endpoint unavailable; refusing to type prompt into tmux pane');
|