@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 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) && /\b--listen\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 (!codexPid) {
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 (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.
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: (kind === 'claude' || kind === 'codex') ? (win.ccTransport || 'tmux') : null,
851
- endpoint: win.ccEndpoint || null,
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.5",
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
- try {
1738
- capture = await tmux.capturePane(session.target, 200, false, true);
1739
- } catch {
1740
- return null;
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');