@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/server.js CHANGED
@@ -21,19 +21,23 @@ import * as tmux from './lib/tmux.js';
21
21
  import * as terminal from './lib/terminal.js';
22
22
  import * as shell from './lib/shell.js';
23
23
  import { TranscriptTailer } from './lib/transcript.js';
24
- import { SubAgentsWatcher, listAgents } from './lib/subagents.js';
24
+ import { SubAgentsWatcher, CodexSubAgentsWatcher, listAgents } from './lib/subagents.js';
25
25
  import { parsePanePrompt } from './lib/prompt.js';
26
26
  import { SessionRegistry, listRecentTranscripts } from './lib/sessions.js';
27
27
  import { loadPins, savePins, validateTranscriptPath, pinKey } from './lib/pins.js';
28
+ import { writePaneRegistryRecord } from './lib/pane-registry.js';
28
29
  import { ResourceMonitor, listProcesses, killProcess } from './lib/resources.js';
29
30
  import { buildAnswerProgram, parsePicker, planStep } from './lib/answer.js';
30
31
  import { sweepUploads, resolveUploadPath } from './lib/uploads.js';
31
32
  import { getVersionInfo, currentVersion } from './lib/version.js';
32
33
  import * as push from './lib/push.js';
33
34
  import { readConfig, writeConfig } from './lib/config.js';
34
- import { parseCodexRecord, parseCodexPrompt, buildSpawnCommand } from './lib/codex.js';
35
+ import { parseCodexRecord, parseCodexPrompt, parseCodexSubagentNotificationRecord, buildSpawnCommand, buildAppServerCommand } from './lib/codex.js';
36
+ import { CodexRpcManager, isCodexActiveStatus, isCodexAppServerCapture, parseCodexAppServerEndpoint } from './lib/codex-rpc.js';
37
+ import { ClaudePrintManager, buildBridgeCommand } from './lib/claude-print.js';
35
38
  import { optimizePrompt, rulesOptimize } from './lib/optimize.js';
36
39
  import * as mlx from './lib/mlx.js';
40
+ import { resolveClaudeBin } from './lib/claude-cli.js';
37
41
  import {
38
42
  MLX_MODELS,
39
43
  CLAUDE_MODELS,
@@ -83,6 +87,13 @@ const CONFIG = {
83
87
  env('PROJECTS') || path.join(os.homedir(), '.claude', 'projects'),
84
88
  codexSessionsRoot:
85
89
  env('CODEX_SESSIONS') || path.join(os.homedir(), '.codex', 'sessions'),
90
+ // Experimental Codex app-server transport. New Codex sessions use a tmux
91
+ // pane as a visible process pin, but replies/approvals move over JSON-RPC.
92
+ // Set CLAUDE_CONTROL_CODEX_TRANSPORT=tmux to force the legacy TUI-key path.
93
+ codexTransport: String(env('CODEX_TRANSPORT') || '').toLowerCase() === 'tmux' ? 'tmux' : 'rpc',
94
+ // Experimental Claude print-mode transport. New Claude sessions default to the
95
+ // interactive TUI unless CLAUDE_CONTROL_CLAUDE_TRANSPORT=print is set.
96
+ claudeTransport: String(env('CLAUDE_TRANSPORT') || '').toLowerCase() === 'print' ? 'print' : 'tmux',
86
97
  // 768MB: a long-running Node server (WS + transcript tailing + the bundled
87
98
  // web app) baselines ~300-450MB of V8 heap + RSS, so the old 350MB budget
88
99
  // tripped "over limit" permanently. Override with CLAUDE_CONTROL_RSS_LIMIT_MB.
@@ -95,6 +106,8 @@ const CONFIG = {
95
106
  maxUploadMB: Number(env('MAX_UPLOAD_MB')) || 25,
96
107
  uploadsDir:
97
108
  env('UPLOADS') || path.join(os.homedir(), '.claude-control', 'uploads'),
109
+ presentDir:
110
+ env('PRESENT') || path.join(os.homedir(), '.claude-control', 'present'),
98
111
  uploadTtlHours: Number(env('UPLOAD_TTL_HOURS')) || 24,
99
112
  pinsFile:
100
113
  env('PINS') || path.join(os.homedir(), '.claude-control', 'pins.json'),
@@ -113,6 +126,12 @@ const MIME = {
113
126
  '.ico': 'image/x-icon',
114
127
  '.json': 'application/json; charset=utf-8',
115
128
  '.png': 'image/png',
129
+ '.jpg': 'image/jpeg',
130
+ '.jpeg': 'image/jpeg',
131
+ '.webm': 'video/webm',
132
+ '.mp4': 'video/mp4',
133
+ '.txt': 'text/plain; charset=utf-8',
134
+ '.md': 'text/markdown; charset=utf-8',
116
135
  '.webmanifest': 'application/manifest+json; charset=utf-8',
117
136
  };
118
137
 
@@ -132,6 +151,8 @@ const IMAGE_MIME = {
132
151
  // --- shared state -----------------------------------------------------------
133
152
  const registry = new SessionRegistry({ projectsRoot: CONFIG.projectsRoot, codexSessionsRoot: CONFIG.codexSessionsRoot, tmux });
134
153
  const resources = new ResourceMonitor({ rssLimitMB: CONFIG.rssLimitMB });
154
+ const codexRpc = new CodexRpcManager();
155
+ const claudePrint = new ClaudePrintManager();
135
156
 
136
157
  // Manual transcript pins (windowId.paneIndex -> transcript path). Loaded at boot,
137
158
  // applied to the registry, and editable via /api/pins.
@@ -139,6 +160,8 @@ let pins = loadPins(CONFIG.pinsFile);
139
160
 
140
161
  /** id -> { tailer, clients:Set<ws>, pending } */
141
162
  const subscriptions = new Map();
163
+ const RAW_EVENT_LIMIT = 200;
164
+ const rawEventsById = new Map();
142
165
 
143
166
  function sessionById(id) {
144
167
  return registry.getSessions().find((s) => s.id === id) || null;
@@ -370,11 +393,15 @@ const _handler = (req, res) => {
370
393
  {
371
394
  id: 'claude',
372
395
  available: claudeResult.available,
396
+ defaultTransport: CONFIG.claudeTransport,
397
+ transports: ['tmux', 'print'],
373
398
  ...(claudeResult.available ? {} : { reason: claudeResult.reason }),
374
399
  },
375
400
  {
376
401
  id: 'codex',
377
402
  available: codexResult.available,
403
+ defaultTransport: CONFIG.codexTransport,
404
+ transports: ['rpc', 'tmux'],
378
405
  ...(codexResult.available ? {} : { reason: codexResult.reason }),
379
406
  },
380
407
  ],
@@ -424,6 +451,14 @@ const _handler = (req, res) => {
424
451
  return proxyTerminalHttp(req, res, u);
425
452
  }
426
453
 
454
+ // Public presentation artifacts (screenshots, videos, one-off demos) live
455
+ // under ~/.claude-control/present and are intentionally iframe-friendly.
456
+ // This is a confined static surface: no directory listing, no writes, and no
457
+ // filesystem paths outside presentDir.
458
+ if (u.pathname === '/present' || u.pathname.startsWith('/present/')) {
459
+ return servePresent(u.pathname, res);
460
+ }
461
+
427
462
  // Unknown /api/* path: return JSON 404 instead of falling through to the SPA.
428
463
  if (u.pathname.startsWith('/api/')) return endJson(res, 404, { error: 'not found' });
429
464
 
@@ -707,19 +742,27 @@ function handleTranscribe(req, res, u) {
707
742
  }
708
743
 
709
744
  // ---------------------------------------------------------------------------
710
- // resolveBin — async PATH lookup for a binary name or absolute path.
745
+ function commandHead(command) {
746
+ const text = String(command || '').trim();
747
+ const m = /^(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(text);
748
+ return m ? (m[1] || m[2] || m[3] || '') : '';
749
+ }
750
+
751
+ // resolveBin — async lookup for a configured launch command.
711
752
  //
712
- // If `bin` is an absolute path, checks it is executable directly.
713
- // Otherwise runs `which <bin>` on PATH.
753
+ // If the first command word is an absolute path, checks it is executable
754
+ // directly. Otherwise resolves it via PATH and then the user's login shell so
755
+ // aliases/functions such as `yodex` are treated the same way as the tmux pane
756
+ // that will receive the typed command.
714
757
  //
715
758
  // Returns { available: true, path } on success, { available: false, reason }
716
759
  // on failure. Never throws.
717
760
  // ---------------------------------------------------------------------------
718
761
  async function resolveBin(bin) {
719
- if (!bin || typeof bin !== 'string' || !bin.trim()) {
762
+ const b = commandHead(bin);
763
+ if (!b) {
720
764
  return { available: false, reason: 'no binary configured' };
721
765
  }
722
- const b = bin.trim();
723
766
  // Absolute path: check existence + execute permission directly.
724
767
  if (b.startsWith('/')) {
725
768
  try {
@@ -736,7 +779,33 @@ async function resolveBin(bin) {
736
779
  if (resolved) return { available: true, path: resolved };
737
780
  return { available: false, reason: `${b} not found on PATH` };
738
781
  } catch {
739
- return { available: false, reason: `${b} not found on PATH` };
782
+ // Fall through to the user's login shell; aliases/functions are only visible
783
+ // there, but the lookup script itself is fixed and receives `b` as argv.
784
+ }
785
+ try {
786
+ const shell = process.env.SHELL || '/bin/zsh';
787
+ const { stdout } = await _execFile(
788
+ shell,
789
+ ['-lic', 'command -v -- "$1"', 'claude-control-resolve', b],
790
+ { timeout: 5000 },
791
+ );
792
+ const resolved = stdout.trim();
793
+ if (resolved) return { available: true, path: resolved };
794
+ return { available: false, reason: `${b} not found in login shell` };
795
+ } catch {
796
+ return { available: false, reason: `${b} not found in login shell` };
797
+ }
798
+ }
799
+
800
+ async function resolvePaneTarget(target) {
801
+ if (String(target || '').includes('.')) return target;
802
+ try {
803
+ const panes = await tmux.listPanes();
804
+ const pane = panes.find((p) => p.target === target) ||
805
+ panes.find((p) => p.target.startsWith(`${target}.`));
806
+ return pane?.target || target;
807
+ } catch {
808
+ return target;
740
809
  }
741
810
  }
742
811
 
@@ -758,6 +827,14 @@ async function handleSessionNew(req, res) {
758
827
 
759
828
  // agent ∈ {'claude','codex'}, default 'claude'.
760
829
  const agent = body.agent === 'codex' ? 'codex' : 'claude';
830
+ const claudeTransport =
831
+ body.claudeTransport === 'print' || body.claudeTransport === 'tmux'
832
+ ? body.claudeTransport
833
+ : CONFIG.claudeTransport;
834
+ const codexTransport =
835
+ body.codexTransport === 'tmux' || body.codexTransport === 'rpc'
836
+ ? body.codexTransport
837
+ : CONFIG.codexTransport;
761
838
 
762
839
  // Name is required-with-default: sanitize the requested name, falling back to
763
840
  // `session-<short-ts>` so a session is ALWAYS named (the rail reads the tmux
@@ -769,15 +846,17 @@ async function handleSessionNew(req, res) {
769
846
  // (i) Resolve the agent binary and return 400 if unavailable.
770
847
  const agentBin = agent === 'codex'
771
848
  ? (config.codexBin || config.codexLaunchCommand)
772
- : (config.claudeBin || config.launchCommand);
849
+ : (agent === 'claude' && claudeTransport === 'print'
850
+ ? (resolveClaudeBin() || 'claude')
851
+ : (config.claudeBin || config.launchCommand));
773
852
  const binCheck = await resolveBin(agentBin);
774
853
  if (!binCheck.available) {
775
854
  return endJson(res, 400, { error: `agent binary unavailable: ${binCheck.reason}` });
776
855
  }
777
856
 
778
- // (ii) For codex: pre-validate cwd exists and is a directory BEFORE createWindow,
779
- // so a bad request creates NO window (400 not 500, window-leak prevention).
780
- if (agent === 'codex') {
857
+ // (ii) For structured transports: pre-validate cwd exists and is a directory
858
+ // BEFORE createWindow, so a bad request creates NO window.
859
+ if (agent === 'codex' || (agent === 'claude' && claudeTransport === 'print')) {
781
860
  try {
782
861
  const st = await fsp.stat(cwd);
783
862
  if (!st.isDirectory()) {
@@ -795,16 +874,42 @@ async function handleSessionNew(req, res) {
795
874
  const target = await tmux.createWindow({ cwd, name });
796
875
 
797
876
  let launch;
877
+ let codexRpcEndpoint = null;
878
+ let printPaneTarget = target;
879
+ let printClient = null;
798
880
  if (agent === 'codex') {
799
- // Codex path: uses -C <cwd> (its own cwd flag). No --name flag — Codex
800
- // has none. The tmux window is still named (above) so the rail shows it.
801
- // buildSpawnCommand is the single source of truth for Codex's launch
802
- // shape; the cwd arg is shell-quoted since the command is typed into an
803
- // interactive shell via sendText. The executed command is
804
- // config.codexLaunchCommand (may be a shell alias), validated above via
805
- // codexBin||codexLaunchCommand same pattern as the Claude branch.
806
- const { bin, args } = buildSpawnCommand({ cwd, bin: config.codexLaunchCommand });
807
- launch = `${bin} ${args.map((a) => (a === cwd ? tmux.shellQuoteName(cwd) : a)).join(' ')}`;
881
+ const codexCommand = config.codexBin || config.codexLaunchCommand;
882
+ if (codexTransport === 'rpc') {
883
+ codexRpcEndpoint = await codexRpc.prepareEndpoint(target);
884
+ const { bin, args } = buildAppServerCommand({ endpoint: codexRpcEndpoint, bin: codexCommand });
885
+ launch = `${bin} ${args.map((a) => (a === codexRpcEndpoint ? tmux.shellQuoteName(a) : a)).join(' ')}`;
886
+ } else {
887
+ // Legacy Codex path: uses -C <cwd> (its own cwd flag). No --name flag —
888
+ // Codex has none. The tmux window is still named (above) so the rail
889
+ // shows it. buildSpawnCommand is the single source of truth for Codex's
890
+ // launch shape; the cwd arg is shell-quoted since the command is typed
891
+ // into an interactive shell via sendText.
892
+ const { bin, args } = buildSpawnCommand({ cwd, bin: codexCommand });
893
+ launch = `${bin} ${args.map((a) => (a === cwd ? tmux.shellQuoteName(cwd) : a)).join(' ')}`;
894
+ }
895
+ } else if (claudeTransport === 'print') {
896
+ printPaneTarget = await resolvePaneTarget(target);
897
+ const socketPath = claudePrint.endpointFor(printPaneTarget);
898
+ printClient = await claudePrint.attach({ target: printPaneTarget, socketPath, cwd });
899
+ await tmux.setPaneOption(printPaneTarget, '@cc_agent', 'claude');
900
+ await tmux.setPaneOption(printPaneTarget, '@cc_transport', 'print');
901
+ await tmux.setPaneOption(printPaneTarget, '@cc_endpoint', socketPath);
902
+ const bridgePath = path.join(__dirname, 'bin', 'claude-print-bridge.mjs');
903
+ const claudeBin = binCheck.path || resolveClaudeBin() || commandHead(agentBin);
904
+ launch = buildBridgeCommand({
905
+ bridgePath,
906
+ socketPath,
907
+ cwd,
908
+ claudeBin,
909
+ name,
910
+ permissionMode: 'acceptEdits',
911
+ quote: tmux.shellQuoteName,
912
+ });
808
913
  } else {
809
914
  // Claude path: BYTE-IDENTICAL to the pre-Phase-D implementation.
810
915
  // (2) Claude's own session title: `claude --help` exposes `-n/--name`
@@ -816,8 +921,26 @@ async function handleSessionNew(req, res) {
816
921
  launch = `${config.launchCommand} --name ${tmux.shellQuoteName(name)}`;
817
922
  }
818
923
 
924
+ if (agent === 'codex') {
925
+ await tmux.setPaneOption(target, '@cc_agent', 'codex');
926
+ await tmux.setPaneOption(target, '@cc_transport', codexTransport);
927
+ if (codexRpcEndpoint) await tmux.setPaneOption(target, '@cc_endpoint', codexRpcEndpoint);
928
+ }
929
+
819
930
  await tmux.sendText(target, launch);
820
- return endJson(res, 200, { ok: true, target, name, agent });
931
+ if (printClient) {
932
+ await printClient.waitForBridge();
933
+ }
934
+ if (agent === 'codex' && codexRpcEndpoint) {
935
+ await codexRpc.attach({ target, endpoint: codexRpcEndpoint, cwd });
936
+ }
937
+ return endJson(res, 200, {
938
+ ok: true,
939
+ target: printPaneTarget,
940
+ name,
941
+ agent,
942
+ transport: agent === 'codex' ? codexTransport : claudeTransport,
943
+ });
821
944
  } catch (err) {
822
945
  return endJson(res, 500, { error: String(err?.message || err) });
823
946
  }
@@ -1016,6 +1139,21 @@ async function handleSetPin(req, res) {
1016
1139
  } catch (err) {
1017
1140
  return endJson(res, 400, { error: String(err?.message || err) });
1018
1141
  }
1142
+ // Global "re-match all windows": drop every manual pin so every pane falls
1143
+ // back to the SessionStart-hook binding (the accurate, current transcript).
1144
+ // Used by the "Re-match all" command to recover from stale pins in bulk.
1145
+ if (body?.all === true) {
1146
+ pins = {};
1147
+ try {
1148
+ savePins(CONFIG.pinsFile, pins);
1149
+ } catch (err) {
1150
+ return endJson(res, 500, { error: String(err?.message || err) });
1151
+ }
1152
+ registry.setPins(pins);
1153
+ await registry.refresh().catch(() => {});
1154
+ return endJson(res, 200, { ok: true, pins });
1155
+ }
1156
+
1019
1157
  const id = typeof body?.id === 'string' ? body.id : '';
1020
1158
  const session = sessionById(id);
1021
1159
  if (!session) return endJson(res, 404, { error: 'unknown session' });
@@ -1035,6 +1173,9 @@ async function handleSetPin(req, res) {
1035
1173
  return endJson(res, 500, { error: String(err?.message || err) });
1036
1174
  }
1037
1175
  registry.setPins(pins);
1176
+ // Re-run the matcher NOW so clearing/setting a pin re-binds immediately
1177
+ // (otherwise the change only lands on the next 4 s refresh tick).
1178
+ await registry.refresh().catch(() => {});
1038
1179
  return endJson(res, 200, { ok: true, pins });
1039
1180
  }
1040
1181
 
@@ -1066,6 +1207,34 @@ function serveStatic(pathname, res) {
1066
1207
  });
1067
1208
  }
1068
1209
 
1210
+ function servePresent(pathname, res) {
1211
+ let rel;
1212
+ try {
1213
+ rel = decodeURIComponent(pathname.replace(/^\/present\/?/, ''));
1214
+ } catch {
1215
+ res.writeHead(400); return res.end('bad request');
1216
+ }
1217
+ if (!rel || rel.endsWith('/')) rel = path.join(rel, 'index.html');
1218
+ const root = path.resolve(CONFIG.presentDir);
1219
+ const full = path.resolve(root, rel);
1220
+ if (full !== root && !full.startsWith(root + path.sep)) {
1221
+ res.writeHead(403); return res.end('forbidden');
1222
+ }
1223
+ fs.readFile(full, (err, data) => {
1224
+ if (err) {
1225
+ res.writeHead(404);
1226
+ return res.end('not found');
1227
+ }
1228
+ const ext = path.extname(full).toLowerCase();
1229
+ res.writeHead(200, {
1230
+ 'content-type': MIME[ext] || 'application/octet-stream',
1231
+ 'cache-control': 'no-store, must-revalidate',
1232
+ 'x-content-type-options': 'nosniff',
1233
+ });
1234
+ res.end(data);
1235
+ });
1236
+ }
1237
+
1069
1238
  // Serve the SPA shell (index.html) for client-side routes.
1070
1239
  function serveIndexHtml(res) {
1071
1240
  fs.readFile(path.join(PUBLIC_DIR, 'index.html'), (err, data) => {
@@ -1219,6 +1388,181 @@ function broadcastTo(id, obj) {
1219
1388
  for (const ws of sub.clients) if (ws.readyState === ws.OPEN) ws.send(msg);
1220
1389
  }
1221
1390
 
1391
+ function rawSummary(value, max = 240) {
1392
+ const text = typeof value === 'string'
1393
+ ? value
1394
+ : (() => {
1395
+ try { return JSON.stringify(value); } catch { return String(value); }
1396
+ })();
1397
+ const compact = text.replace(/\s+/g, ' ').trim();
1398
+ return compact.length > max ? compact.slice(0, max - 1) + '…' : compact;
1399
+ }
1400
+
1401
+ function emitRawEvent(id, event) {
1402
+ if (!id) return;
1403
+ const entry = {
1404
+ ts: Date.now(),
1405
+ source: event.source || 'server',
1406
+ kind: event.kind || 'event',
1407
+ summary: rawSummary(event.summary ?? ''),
1408
+ detail: event.detail ?? null,
1409
+ };
1410
+ const prev = rawEventsById.get(id) ?? [];
1411
+ const next = [...prev, entry];
1412
+ rawEventsById.set(id, next.length > RAW_EVENT_LIMIT ? next.slice(next.length - RAW_EVENT_LIMIT) : next);
1413
+ broadcastTo(id, { type: 'raw-event', id, event: entry });
1414
+ }
1415
+
1416
+ codexRpc.on('thread', (id, opened) => {
1417
+ const thread = opened?.thread || {};
1418
+ const transcriptPath = thread.path ?? null;
1419
+ emitRawEvent(id, {
1420
+ source: 'codex-rpc',
1421
+ kind: 'thread',
1422
+ summary: `thread ${thread.id || '(unknown)'} ${transcriptPath || ''}`,
1423
+ detail: { threadId: thread.id ?? null, transcriptPath },
1424
+ });
1425
+ registry.setTranscriptHint(id, {
1426
+ transcriptPath,
1427
+ sessionId: thread.id ?? null,
1428
+ });
1429
+ if (transcriptPath) {
1430
+ (async () => {
1431
+ const panes = await tmux.listPanes();
1432
+ const pane = panes.find((p) => p.target === id) ||
1433
+ panes.find((p) => p.target.startsWith(`${id}.`));
1434
+ if (!pane?.paneId) return;
1435
+ await writePaneRegistryRecord({
1436
+ paneId: pane.paneId,
1437
+ sessionId: thread.id ?? null,
1438
+ transcriptPath,
1439
+ cwd: pane.cwd ?? null,
1440
+ });
1441
+ })().catch(() => {});
1442
+ }
1443
+ });
1444
+ codexRpc.on('messages', (id, messages) => {
1445
+ const sub = subscriptions.get(id);
1446
+ if (sub?.tailer) return;
1447
+ emitRawEvent(id, {
1448
+ source: 'codex-rpc',
1449
+ kind: 'messages',
1450
+ summary: `${messages?.length ?? 0} message(s)`,
1451
+ detail: { messages },
1452
+ });
1453
+ broadcastTo(id, { type: 'append', id, messages });
1454
+ });
1455
+ codexRpc.on('prompt', (id, prompt) => {
1456
+ registry.setPrompt(id, prompt);
1457
+ emitRawEvent(id, {
1458
+ source: 'codex-rpc',
1459
+ kind: 'prompt',
1460
+ summary: prompt ? prompt.question : 'cleared',
1461
+ detail: prompt,
1462
+ });
1463
+ broadcastTo(id, { type: 'prompt', id, prompt });
1464
+ });
1465
+ codexRpc.on('pending', (id, pending) => {
1466
+ registry.setPending(id, !!pending);
1467
+ emitRawEvent(id, {
1468
+ source: 'codex-rpc',
1469
+ kind: 'pending',
1470
+ summary: pending ? 'pending true' : 'pending false',
1471
+ });
1472
+ });
1473
+ codexRpc.on('status', (id, status) => {
1474
+ registry.setThinking(id, isCodexActiveStatus(status));
1475
+ emitRawEvent(id, {
1476
+ source: 'codex-rpc',
1477
+ kind: 'status',
1478
+ summary: status ?? 'null',
1479
+ detail: status,
1480
+ });
1481
+ });
1482
+ codexRpc.on('subagent', (id, update) => {
1483
+ const sub = subscriptions.get(id);
1484
+ sub?.subagents?.ingest?.(update);
1485
+ emitRawEvent(id, {
1486
+ source: 'codex-rpc',
1487
+ kind: 'subagent',
1488
+ summary: `${update.agentId} ${update.state}`,
1489
+ detail: update,
1490
+ });
1491
+ });
1492
+ codexRpc.on('raw', (id, event) => {
1493
+ emitRawEvent(id, {
1494
+ source: event.source || 'codex-rpc',
1495
+ kind: event.kind || 'rpc',
1496
+ summary: event.summary || event.method || '',
1497
+ detail: event,
1498
+ });
1499
+ });
1500
+ codexRpc.on('error', (id, err) => {
1501
+ emitRawEvent(id, {
1502
+ source: 'codex-rpc',
1503
+ kind: 'error',
1504
+ summary: String(err?.message || err),
1505
+ });
1506
+ broadcastTo(id, {
1507
+ type: 'ack',
1508
+ op: 'codex-rpc',
1509
+ ok: false,
1510
+ error: String(err?.message || err),
1511
+ });
1512
+ });
1513
+ codexRpc.on('close', (id) => {
1514
+ registry.setPending(id, false);
1515
+ registry.setPrompt(id, null);
1516
+ registry.setThinking(id, false);
1517
+ emitRawEvent(id, {
1518
+ source: 'codex-rpc',
1519
+ kind: 'close',
1520
+ summary: 'closed',
1521
+ });
1522
+ broadcastTo(id, { type: 'prompt', id, prompt: null });
1523
+ });
1524
+
1525
+ claudePrint.on('thread', (id, thread) => {
1526
+ const transcriptPath = thread?.transcriptPath ?? null;
1527
+ registry.setTranscriptHint(id, {
1528
+ transcriptPath,
1529
+ sessionId: thread?.sessionId ?? null,
1530
+ });
1531
+ if (transcriptPath) {
1532
+ (async () => {
1533
+ const panes = await tmux.listPanes();
1534
+ const pane = panes.find((p) => p.target === id) ||
1535
+ panes.find((p) => p.target.startsWith(`${id}.`));
1536
+ if (!pane?.paneId) return;
1537
+ await writePaneRegistryRecord({
1538
+ paneId: pane.paneId,
1539
+ sessionId: thread?.sessionId ?? null,
1540
+ transcriptPath,
1541
+ cwd: pane.cwd ?? null,
1542
+ });
1543
+ })().catch(() => {});
1544
+ }
1545
+ });
1546
+ claudePrint.on('messages', (id, messages) => {
1547
+ const sub = subscriptions.get(id);
1548
+ if (sub?.tailer) return;
1549
+ broadcastTo(id, { type: 'append', id, messages });
1550
+ });
1551
+ claudePrint.on('status', (id, status) => {
1552
+ registry.setThinking(id, status === 'active');
1553
+ });
1554
+ claudePrint.on('error', (id, err) => {
1555
+ broadcastTo(id, {
1556
+ type: 'ack',
1557
+ op: 'claude-print',
1558
+ ok: false,
1559
+ error: String(err?.message || err),
1560
+ });
1561
+ });
1562
+ claudePrint.on('close', (id) => {
1563
+ registry.setThinking(id, false);
1564
+ });
1565
+
1222
1566
  function ensureSubscription(id) {
1223
1567
  let sub = subscriptions.get(id);
1224
1568
  if (sub) {
@@ -1243,16 +1587,39 @@ function ensureSubscription(id) {
1243
1587
  // `capture` and accepts `reply`, and a later refresh that matches the
1244
1588
  // transcript upgrades the subscription (see the tailer-null branch above).
1245
1589
  if (!session.transcriptPath) {
1246
- sub = { tailer: null, clients: new Set(), pending: null, ready: Promise.resolve() };
1590
+ const subagents = session.kind === 'codex' ? new CodexSubAgentsWatcher() : null;
1591
+ sub = { tailer: null, subagents, clients: new Set(), pending: null, ready: Promise.resolve() };
1247
1592
  subscriptions.set(id, sub);
1248
- startPromptPoller(id, sub);
1593
+ if (subagents) {
1594
+ subagents.on('change', (entry) =>
1595
+ broadcastTo(id, { type: 'subagent', id, subagent: entry }),
1596
+ );
1597
+ }
1598
+ if (!codexRpc.has(id) && session.transport !== 'print') startPromptPoller(id, sub);
1249
1599
  return sub;
1250
1600
  }
1251
1601
 
1252
- const tailer = new TranscriptTailer(session.transcriptPath, { maxBuffer: CONFIG.maxBuffer, parser: session.kind === 'codex' ? parseCodexRecord : undefined });
1253
1602
  // Watch this session's sub-agent transcripts (Task/Agent). Discovery is polled
1254
1603
  // when the parent transcript grows (when sub-agents spawn) + once at subscribe.
1255
- const subagents = new SubAgentsWatcher(session.transcriptPath);
1604
+ const subagents = session.kind === 'codex'
1605
+ ? new CodexSubAgentsWatcher()
1606
+ : new SubAgentsWatcher(session.transcriptPath);
1607
+ const parser = session.kind === 'codex'
1608
+ ? (line) => {
1609
+ const update = parseCodexSubagentNotificationRecord(line);
1610
+ if (update) {
1611
+ subagents.ingest(update);
1612
+ emitRawEvent(id, {
1613
+ source: 'codex-transcript',
1614
+ kind: 'subagent',
1615
+ summary: `${update.agentId} ${update.state}`,
1616
+ detail: update,
1617
+ });
1618
+ }
1619
+ return parseCodexRecord(line);
1620
+ }
1621
+ : undefined;
1622
+ const tailer = new TranscriptTailer(session.transcriptPath, { maxBuffer: CONFIG.maxBuffer, parser });
1256
1623
  sub = { tailer, subagents, clients: new Set(), pending: null };
1257
1624
  subscriptions.set(id, sub);
1258
1625
 
@@ -1261,6 +1628,12 @@ function ensureSubscription(id) {
1261
1628
  );
1262
1629
 
1263
1630
  tailer.on('append', (msgs) => {
1631
+ emitRawEvent(id, {
1632
+ source: session.kind === 'codex' ? 'codex-transcript' : 'transcript',
1633
+ kind: 'append',
1634
+ summary: `${msgs.length} message(s)`,
1635
+ detail: { messages: msgs },
1636
+ });
1264
1637
  broadcastTo(id, { type: 'append', id, messages: msgs });
1265
1638
  // A sub-agent may have just spawned (poll for its files) or finished (its
1266
1639
  // Task tool-call produced a tool_result → mark it done).
@@ -1274,6 +1647,12 @@ function ensureSubscription(id) {
1274
1647
  tailer.on('pending', (pending) => {
1275
1648
  sub.pending = pending;
1276
1649
  registry.setPending(id, !!pending);
1650
+ emitRawEvent(id, {
1651
+ source: 'transcript',
1652
+ kind: 'pending',
1653
+ summary: pending ? pending.questions?.[0]?.question || 'pending true' : 'pending false',
1654
+ detail: pending,
1655
+ });
1277
1656
  broadcastTo(id, { type: 'pending', id, pending });
1278
1657
  });
1279
1658
  tailer.on('error', (err) => broadcastTo(id, { type: 'ack', op: 'tail', ok: false, error: String(err?.message || err) }));
@@ -1295,10 +1674,102 @@ function ensureSubscription(id) {
1295
1674
  if (doneIds.size) subagents.markDone(doneIds);
1296
1675
  });
1297
1676
  sub.ready.catch(() => {}); // errors surface via the per-subscribe await below
1298
- startPromptPoller(id, sub);
1677
+ if (!codexRpc.has(id) && session.transport !== 'print') startPromptPoller(id, sub);
1299
1678
  return sub;
1300
1679
  }
1301
1680
 
1681
+ function sendSubscriptionSnapshot(ws, id, sub) {
1682
+ const liveMessages = claudePrint.has(id)
1683
+ ? claudePrint.messages(id)
1684
+ : codexRpc.messages(id);
1685
+ send(ws, {
1686
+ type: 'messages',
1687
+ id,
1688
+ // Tailer-less RPC-backed Codex subscriptions keep an in-memory message
1689
+ // buffer fed by app-server notifications. Claude print mode does the same
1690
+ // through its bridge until a real transcript tailer is available.
1691
+ messages: sub.tailer ? sub.tailer.getMessages() : liveMessages,
1692
+ pending: sub.tailer ? sub.tailer.getPending() : null,
1693
+ });
1694
+ const rpcPrompt = codexRpc.prompt(id);
1695
+ if (rpcPrompt) send(ws, { type: 'prompt', id, prompt: rpcPrompt });
1696
+ // Snapshot any already-running sub-agents for this session.
1697
+ const subs = sub.subagents ? sub.subagents.snapshot() : [];
1698
+ if (subs.length) send(ws, { type: 'subagents', id, subagents: subs });
1699
+ const rawEvents = rawEventsById.get(id) ?? [];
1700
+ if (rawEvents.length) send(ws, { type: 'raw-events', id, events: rawEvents });
1701
+ }
1702
+
1703
+ function upgradeSubscriptionIfTranscriptReady(id) {
1704
+ const old = subscriptions.get(id);
1705
+ if (!old || old.tailer) return;
1706
+ const session = sessionById(id);
1707
+ if (!session?.transcriptPath) return;
1708
+
1709
+ const clients = new Set(old.clients);
1710
+ if (old.promptTimer) clearInterval(old.promptTimer);
1711
+ subscriptions.delete(id);
1712
+
1713
+ const next = ensureSubscription(id);
1714
+ if (!next) return;
1715
+ for (const ws of clients) next.clients.add(ws);
1716
+
1717
+ next.ready.then(() => {
1718
+ for (const ws of clients) {
1719
+ if (ws.readyState === ws.OPEN && next.clients.has(ws)) {
1720
+ sendSubscriptionSnapshot(ws, id, next);
1721
+ }
1722
+ }
1723
+ }).catch((err) => {
1724
+ for (const ws of clients) {
1725
+ send(ws, { type: 'ack', op: 'subscribe', ok: false, error: String(err?.message || err) });
1726
+ }
1727
+ });
1728
+ }
1729
+
1730
+ async function ensureCodexRpcForSession(session) {
1731
+ if (session.kind !== 'codex') return null;
1732
+ if (session.transport !== 'rpc') return null;
1733
+ const existing = codexRpc.get(session.target);
1734
+ if (existing) return existing;
1735
+
1736
+ let capture = '';
1737
+ try {
1738
+ capture = await tmux.capturePane(session.target, 200, false, true);
1739
+ } catch {
1740
+ return null;
1741
+ }
1742
+ const endpoint = parseCodexAppServerEndpoint(capture);
1743
+ if (!endpoint) {
1744
+ if (isCodexAppServerCapture(capture)) {
1745
+ throw new Error('Codex RPC app-server endpoint unavailable; refusing to type prompt into tmux pane');
1746
+ }
1747
+ return null;
1748
+ }
1749
+
1750
+ return codexRpc.ensureAttached({
1751
+ target: session.target,
1752
+ endpoint,
1753
+ cwd: session.cwd,
1754
+ resumeThreadId: session.sessionId,
1755
+ transcriptPath: session.transcriptPath,
1756
+ });
1757
+ }
1758
+
1759
+ async function ensureClaudePrintForSession(session) {
1760
+ if (session.kind !== 'claude' || session.transport !== 'print') return null;
1761
+ const existing = claudePrint.get(session.target);
1762
+ if (existing) return existing;
1763
+ const socketPath = session.endpoint || claudePrint.endpointFor(session.target);
1764
+ const client = await claudePrint.attach({
1765
+ target: session.target,
1766
+ socketPath,
1767
+ cwd: session.cwd,
1768
+ });
1769
+ await client.waitForBridge();
1770
+ return client;
1771
+ }
1772
+
1302
1773
  // Poll the live pane for a TUI selection prompt (permission/trust/numbered menu).
1303
1774
  // These never reach the transcript, so without this the cockpit shows a pending
1304
1775
  // tool-call and looks stuck. Broadcasts a `prompt` frame only when it changes.
@@ -1314,7 +1785,9 @@ function startPromptPoller(id, sub) {
1314
1785
  if (!session || !tmux.isValidTarget(session.target)) return;
1315
1786
  let prompt = null;
1316
1787
  try {
1317
- const cap = await tmux.capturePane(session.target, 40);
1788
+ // 80 lines so a tall AskUserQuestion (question + 5 multi-line options +
1789
+ // footer) fits the parser window (parsePanePrompt BOTTOM_REGION).
1790
+ const cap = await tmux.capturePane(session.target, 80);
1318
1791
  prompt = session.kind === 'codex' ? parseCodexPrompt(cap) : parsePanePrompt(cap);
1319
1792
  } catch {
1320
1793
  return;
@@ -1322,6 +1795,12 @@ function startPromptPoller(id, sub) {
1322
1795
  const json = prompt ? JSON.stringify(prompt) : null;
1323
1796
  if (json !== sub._lastPrompt) {
1324
1797
  sub._lastPrompt = json;
1798
+ emitRawEvent(id, {
1799
+ source: session.kind === 'codex' ? 'codex-tui' : 'tui',
1800
+ kind: 'prompt',
1801
+ summary: prompt ? prompt.question : 'cleared',
1802
+ detail: prompt,
1803
+ });
1325
1804
  broadcastTo(id, { type: 'prompt', id, prompt });
1326
1805
  }
1327
1806
  } finally {
@@ -1386,16 +1865,7 @@ async function handleClientMessage(ws, msg) {
1386
1865
  }
1387
1866
  // Client may have unsubscribed/closed while we awaited.
1388
1867
  if (!sub.clients.has(ws)) return;
1389
- send(ws, {
1390
- type: 'messages',
1391
- id: msg.id,
1392
- // Tailer-less subscription (no matched transcript): no history to send.
1393
- messages: sub.tailer ? sub.tailer.getMessages() : [],
1394
- pending: sub.tailer ? sub.tailer.getPending() : null,
1395
- });
1396
- // Snapshot any already-running sub-agents for this session.
1397
- const subs = sub.subagents ? sub.subagents.snapshot() : [];
1398
- if (subs.length) send(ws, { type: 'subagents', id: msg.id, subagents: subs });
1868
+ sendSubscriptionSnapshot(ws, msg.id, sub);
1399
1869
  return;
1400
1870
  }
1401
1871
  case 'unsubscribe': {
@@ -1409,6 +1879,35 @@ async function handleClientMessage(ws, msg) {
1409
1879
  if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
1410
1880
  const replyText = String(msg.text ?? '');
1411
1881
  return runSerial(session.target, async () => {
1882
+ if (session.kind === 'claude' && session.transport === 'print') {
1883
+ await ensureClaudePrintForSession(session);
1884
+ registry.setThinking(session.target, true);
1885
+ try {
1886
+ claudePrint.submit(session.target, replyText);
1887
+ } catch (err) {
1888
+ registry.setThinking(session.target, false);
1889
+ throw err;
1890
+ }
1891
+ send(ws, { type: 'ack', op: 'reply', ok: true, transport: 'claude-print' });
1892
+ return;
1893
+ }
1894
+ if (session.kind === 'codex') {
1895
+ const codexClient = await ensureCodexRpcForSession(session);
1896
+ if (codexClient) {
1897
+ registry.setThinking(session.target, true);
1898
+ try {
1899
+ await codexRpc.submit(session.target, replyText, { cwd: session.cwd });
1900
+ } catch (err) {
1901
+ registry.setThinking(session.target, false);
1902
+ throw err;
1903
+ }
1904
+ send(ws, { type: 'ack', op: 'reply', ok: true, transport: 'codex-rpc' });
1905
+ return;
1906
+ }
1907
+ // Codex TUI compatibility: only non-app-server Codex panes may use
1908
+ // tmux keystrokes. RPC app-server panes must never receive prompt text
1909
+ // in their terminal buffer.
1910
+ }
1412
1911
  await tmux.sendText(session.target, replyText);
1413
1912
  send(ws, { type: 'ack', op: 'reply', ok: true });
1414
1913
  });
@@ -1678,7 +2177,34 @@ async function handleClientMessage(ws, msg) {
1678
2177
  if (!ALLOWED.has(msg.key)) throw new Error('key not allowed');
1679
2178
  const promptKey = msg.key;
1680
2179
  return runSerial(session.target, async () => {
1681
- await tmux.sendRawKeys(session.target, [promptKey]);
2180
+ if (session.kind === 'claude' && session.transport === 'print') {
2181
+ if (promptKey !== 'Escape') throw new Error('key not allowed for Claude print mode');
2182
+ await ensureClaudePrintForSession(session);
2183
+ claudePrint.cancel(session.target);
2184
+ registry.setThinking(session.target, false);
2185
+ send(ws, { type: 'ack', op: 'promptkey', ok: true, transport: 'claude-print' });
2186
+ return;
2187
+ }
2188
+ if (session.kind === 'codex') {
2189
+ const codexClient = await ensureCodexRpcForSession(session);
2190
+ if (codexClient && codexRpc.prompt(session.target)) {
2191
+ codexRpc.answerPrompt(session.target, promptKey);
2192
+ const sub = subscriptions.get(msg.id);
2193
+ if (sub) sub._lastPrompt = '__force__';
2194
+ send(ws, { type: 'ack', op: 'promptkey', ok: true, transport: 'codex-rpc' });
2195
+ return;
2196
+ }
2197
+ // Codex TUI compatibility only; app-server panes are rejected inside
2198
+ // ensureCodexRpcForSession when an RPC endpoint cannot be attached.
2199
+ }
2200
+ // Codex TUI confirms a numbered choice with <digit> THEN Enter ("Press
2201
+ // enter to confirm"); the digit alone only moves the highlight.
2202
+ const isDigit = /^[1-9]$/.test(promptKey);
2203
+ if (session.kind === 'codex' && isDigit) {
2204
+ await tmux.sendRawKeysSequenced(session.target, [promptKey, 'Enter'], 120);
2205
+ } else {
2206
+ await tmux.sendRawKeys(session.target, [promptKey]);
2207
+ }
1682
2208
  // Force the next poll tick to broadcast (the prompt should now change/clear).
1683
2209
  const sub = subscriptions.get(msg.id);
1684
2210
  if (sub) sub._lastPrompt = '__force__';
@@ -1834,6 +2360,9 @@ function firePushForChange(sessions) {
1834
2360
  }
1835
2361
 
1836
2362
  registry.on('change', (sessions) => {
2363
+ codexRpc.sweep(sessions.map((s) => s.id));
2364
+ claudePrint.sweep(sessions.map((s) => s.id));
2365
+ for (const s of sessions) upgradeSubscriptionIfTranscriptReady(s.id);
1837
2366
  firePushForChange(sessions);
1838
2367
  broadcast({ type: 'sessions', sessions });
1839
2368
  });