@idl3/claude-control 1.1.0 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,13 +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
- // cwd is shell-quoted (same quoting as names) since the command is typed
802
- // into an interactive shell via sendText.
803
- void buildSpawnCommand({ cwd, bin: config.codexLaunchCommand }); // validate shape
804
- launch = `${config.codexLaunchCommand} -C ${tmux.shellQuoteName(cwd)}`;
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
+ });
805
913
  } else {
806
914
  // Claude path: BYTE-IDENTICAL to the pre-Phase-D implementation.
807
915
  // (2) Claude's own session title: `claude --help` exposes `-n/--name`
@@ -814,7 +922,19 @@ async function handleSessionNew(req, res) {
814
922
  }
815
923
 
816
924
  await tmux.sendText(target, launch);
817
- return endJson(res, 200, { ok: true, target, name, agent });
925
+ if (printClient) {
926
+ await printClient.waitForBridge();
927
+ }
928
+ if (agent === 'codex' && codexRpcEndpoint) {
929
+ await codexRpc.attach({ target, endpoint: codexRpcEndpoint, cwd });
930
+ }
931
+ return endJson(res, 200, {
932
+ ok: true,
933
+ target: printPaneTarget,
934
+ name,
935
+ agent,
936
+ transport: agent === 'codex' ? codexTransport : claudeTransport,
937
+ });
818
938
  } catch (err) {
819
939
  return endJson(res, 500, { error: String(err?.message || err) });
820
940
  }
@@ -1013,6 +1133,21 @@ async function handleSetPin(req, res) {
1013
1133
  } catch (err) {
1014
1134
  return endJson(res, 400, { error: String(err?.message || err) });
1015
1135
  }
1136
+ // Global "re-match all windows": drop every manual pin so every pane falls
1137
+ // back to the SessionStart-hook binding (the accurate, current transcript).
1138
+ // Used by the "Re-match all" command to recover from stale pins in bulk.
1139
+ if (body?.all === true) {
1140
+ pins = {};
1141
+ try {
1142
+ savePins(CONFIG.pinsFile, pins);
1143
+ } catch (err) {
1144
+ return endJson(res, 500, { error: String(err?.message || err) });
1145
+ }
1146
+ registry.setPins(pins);
1147
+ await registry.refresh().catch(() => {});
1148
+ return endJson(res, 200, { ok: true, pins });
1149
+ }
1150
+
1016
1151
  const id = typeof body?.id === 'string' ? body.id : '';
1017
1152
  const session = sessionById(id);
1018
1153
  if (!session) return endJson(res, 404, { error: 'unknown session' });
@@ -1032,6 +1167,9 @@ async function handleSetPin(req, res) {
1032
1167
  return endJson(res, 500, { error: String(err?.message || err) });
1033
1168
  }
1034
1169
  registry.setPins(pins);
1170
+ // Re-run the matcher NOW so clearing/setting a pin re-binds immediately
1171
+ // (otherwise the change only lands on the next 4 s refresh tick).
1172
+ await registry.refresh().catch(() => {});
1035
1173
  return endJson(res, 200, { ok: true, pins });
1036
1174
  }
1037
1175
 
@@ -1063,6 +1201,34 @@ function serveStatic(pathname, res) {
1063
1201
  });
1064
1202
  }
1065
1203
 
1204
+ function servePresent(pathname, res) {
1205
+ let rel;
1206
+ try {
1207
+ rel = decodeURIComponent(pathname.replace(/^\/present\/?/, ''));
1208
+ } catch {
1209
+ res.writeHead(400); return res.end('bad request');
1210
+ }
1211
+ if (!rel || rel.endsWith('/')) rel = path.join(rel, 'index.html');
1212
+ const root = path.resolve(CONFIG.presentDir);
1213
+ const full = path.resolve(root, rel);
1214
+ if (full !== root && !full.startsWith(root + path.sep)) {
1215
+ res.writeHead(403); return res.end('forbidden');
1216
+ }
1217
+ fs.readFile(full, (err, data) => {
1218
+ if (err) {
1219
+ res.writeHead(404);
1220
+ return res.end('not found');
1221
+ }
1222
+ const ext = path.extname(full).toLowerCase();
1223
+ res.writeHead(200, {
1224
+ 'content-type': MIME[ext] || 'application/octet-stream',
1225
+ 'cache-control': 'no-store, must-revalidate',
1226
+ 'x-content-type-options': 'nosniff',
1227
+ });
1228
+ res.end(data);
1229
+ });
1230
+ }
1231
+
1066
1232
  // Serve the SPA shell (index.html) for client-side routes.
1067
1233
  function serveIndexHtml(res) {
1068
1234
  fs.readFile(path.join(PUBLIC_DIR, 'index.html'), (err, data) => {
@@ -1216,6 +1382,181 @@ function broadcastTo(id, obj) {
1216
1382
  for (const ws of sub.clients) if (ws.readyState === ws.OPEN) ws.send(msg);
1217
1383
  }
1218
1384
 
1385
+ function rawSummary(value, max = 240) {
1386
+ const text = typeof value === 'string'
1387
+ ? value
1388
+ : (() => {
1389
+ try { return JSON.stringify(value); } catch { return String(value); }
1390
+ })();
1391
+ const compact = text.replace(/\s+/g, ' ').trim();
1392
+ return compact.length > max ? compact.slice(0, max - 1) + '…' : compact;
1393
+ }
1394
+
1395
+ function emitRawEvent(id, event) {
1396
+ if (!id) return;
1397
+ const entry = {
1398
+ ts: Date.now(),
1399
+ source: event.source || 'server',
1400
+ kind: event.kind || 'event',
1401
+ summary: rawSummary(event.summary ?? ''),
1402
+ detail: event.detail ?? null,
1403
+ };
1404
+ const prev = rawEventsById.get(id) ?? [];
1405
+ const next = [...prev, entry];
1406
+ rawEventsById.set(id, next.length > RAW_EVENT_LIMIT ? next.slice(next.length - RAW_EVENT_LIMIT) : next);
1407
+ broadcastTo(id, { type: 'raw-event', id, event: entry });
1408
+ }
1409
+
1410
+ codexRpc.on('thread', (id, opened) => {
1411
+ const thread = opened?.thread || {};
1412
+ const transcriptPath = thread.path ?? null;
1413
+ emitRawEvent(id, {
1414
+ source: 'codex-rpc',
1415
+ kind: 'thread',
1416
+ summary: `thread ${thread.id || '(unknown)'} ${transcriptPath || ''}`,
1417
+ detail: { threadId: thread.id ?? null, transcriptPath },
1418
+ });
1419
+ registry.setTranscriptHint(id, {
1420
+ transcriptPath,
1421
+ sessionId: thread.id ?? null,
1422
+ });
1423
+ if (transcriptPath) {
1424
+ (async () => {
1425
+ const panes = await tmux.listPanes();
1426
+ const pane = panes.find((p) => p.target === id) ||
1427
+ panes.find((p) => p.target.startsWith(`${id}.`));
1428
+ if (!pane?.paneId) return;
1429
+ await writePaneRegistryRecord({
1430
+ paneId: pane.paneId,
1431
+ sessionId: thread.id ?? null,
1432
+ transcriptPath,
1433
+ cwd: pane.cwd ?? null,
1434
+ });
1435
+ })().catch(() => {});
1436
+ }
1437
+ });
1438
+ codexRpc.on('messages', (id, messages) => {
1439
+ const sub = subscriptions.get(id);
1440
+ if (sub?.tailer) return;
1441
+ emitRawEvent(id, {
1442
+ source: 'codex-rpc',
1443
+ kind: 'messages',
1444
+ summary: `${messages?.length ?? 0} message(s)`,
1445
+ detail: { messages },
1446
+ });
1447
+ broadcastTo(id, { type: 'append', id, messages });
1448
+ });
1449
+ codexRpc.on('prompt', (id, prompt) => {
1450
+ registry.setPrompt(id, prompt);
1451
+ emitRawEvent(id, {
1452
+ source: 'codex-rpc',
1453
+ kind: 'prompt',
1454
+ summary: prompt ? prompt.question : 'cleared',
1455
+ detail: prompt,
1456
+ });
1457
+ broadcastTo(id, { type: 'prompt', id, prompt });
1458
+ });
1459
+ codexRpc.on('pending', (id, pending) => {
1460
+ registry.setPending(id, !!pending);
1461
+ emitRawEvent(id, {
1462
+ source: 'codex-rpc',
1463
+ kind: 'pending',
1464
+ summary: pending ? 'pending true' : 'pending false',
1465
+ });
1466
+ });
1467
+ codexRpc.on('status', (id, status) => {
1468
+ registry.setThinking(id, isCodexActiveStatus(status));
1469
+ emitRawEvent(id, {
1470
+ source: 'codex-rpc',
1471
+ kind: 'status',
1472
+ summary: status ?? 'null',
1473
+ detail: status,
1474
+ });
1475
+ });
1476
+ codexRpc.on('subagent', (id, update) => {
1477
+ const sub = subscriptions.get(id);
1478
+ sub?.subagents?.ingest?.(update);
1479
+ emitRawEvent(id, {
1480
+ source: 'codex-rpc',
1481
+ kind: 'subagent',
1482
+ summary: `${update.agentId} ${update.state}`,
1483
+ detail: update,
1484
+ });
1485
+ });
1486
+ codexRpc.on('raw', (id, event) => {
1487
+ emitRawEvent(id, {
1488
+ source: event.source || 'codex-rpc',
1489
+ kind: event.kind || 'rpc',
1490
+ summary: event.summary || event.method || '',
1491
+ detail: event,
1492
+ });
1493
+ });
1494
+ codexRpc.on('error', (id, err) => {
1495
+ emitRawEvent(id, {
1496
+ source: 'codex-rpc',
1497
+ kind: 'error',
1498
+ summary: String(err?.message || err),
1499
+ });
1500
+ broadcastTo(id, {
1501
+ type: 'ack',
1502
+ op: 'codex-rpc',
1503
+ ok: false,
1504
+ error: String(err?.message || err),
1505
+ });
1506
+ });
1507
+ codexRpc.on('close', (id) => {
1508
+ registry.setPending(id, false);
1509
+ registry.setPrompt(id, null);
1510
+ registry.setThinking(id, false);
1511
+ emitRawEvent(id, {
1512
+ source: 'codex-rpc',
1513
+ kind: 'close',
1514
+ summary: 'closed',
1515
+ });
1516
+ broadcastTo(id, { type: 'prompt', id, prompt: null });
1517
+ });
1518
+
1519
+ claudePrint.on('thread', (id, thread) => {
1520
+ const transcriptPath = thread?.transcriptPath ?? null;
1521
+ registry.setTranscriptHint(id, {
1522
+ transcriptPath,
1523
+ sessionId: thread?.sessionId ?? null,
1524
+ });
1525
+ if (transcriptPath) {
1526
+ (async () => {
1527
+ const panes = await tmux.listPanes();
1528
+ const pane = panes.find((p) => p.target === id) ||
1529
+ panes.find((p) => p.target.startsWith(`${id}.`));
1530
+ if (!pane?.paneId) return;
1531
+ await writePaneRegistryRecord({
1532
+ paneId: pane.paneId,
1533
+ sessionId: thread?.sessionId ?? null,
1534
+ transcriptPath,
1535
+ cwd: pane.cwd ?? null,
1536
+ });
1537
+ })().catch(() => {});
1538
+ }
1539
+ });
1540
+ claudePrint.on('messages', (id, messages) => {
1541
+ const sub = subscriptions.get(id);
1542
+ if (sub?.tailer) return;
1543
+ broadcastTo(id, { type: 'append', id, messages });
1544
+ });
1545
+ claudePrint.on('status', (id, status) => {
1546
+ registry.setThinking(id, status === 'active');
1547
+ });
1548
+ claudePrint.on('error', (id, err) => {
1549
+ broadcastTo(id, {
1550
+ type: 'ack',
1551
+ op: 'claude-print',
1552
+ ok: false,
1553
+ error: String(err?.message || err),
1554
+ });
1555
+ });
1556
+ claudePrint.on('close', (id) => {
1557
+ registry.setThinking(id, false);
1558
+ });
1559
+
1219
1560
  function ensureSubscription(id) {
1220
1561
  let sub = subscriptions.get(id);
1221
1562
  if (sub) {
@@ -1240,16 +1581,39 @@ function ensureSubscription(id) {
1240
1581
  // `capture` and accepts `reply`, and a later refresh that matches the
1241
1582
  // transcript upgrades the subscription (see the tailer-null branch above).
1242
1583
  if (!session.transcriptPath) {
1243
- sub = { tailer: null, clients: new Set(), pending: null, ready: Promise.resolve() };
1584
+ const subagents = session.kind === 'codex' ? new CodexSubAgentsWatcher() : null;
1585
+ sub = { tailer: null, subagents, clients: new Set(), pending: null, ready: Promise.resolve() };
1244
1586
  subscriptions.set(id, sub);
1245
- startPromptPoller(id, sub);
1587
+ if (subagents) {
1588
+ subagents.on('change', (entry) =>
1589
+ broadcastTo(id, { type: 'subagent', id, subagent: entry }),
1590
+ );
1591
+ }
1592
+ if (!codexRpc.has(id) && session.transport !== 'print') startPromptPoller(id, sub);
1246
1593
  return sub;
1247
1594
  }
1248
1595
 
1249
- const tailer = new TranscriptTailer(session.transcriptPath, { maxBuffer: CONFIG.maxBuffer, parser: session.kind === 'codex' ? parseCodexRecord : undefined });
1250
1596
  // Watch this session's sub-agent transcripts (Task/Agent). Discovery is polled
1251
1597
  // when the parent transcript grows (when sub-agents spawn) + once at subscribe.
1252
- const subagents = new SubAgentsWatcher(session.transcriptPath);
1598
+ const subagents = session.kind === 'codex'
1599
+ ? new CodexSubAgentsWatcher()
1600
+ : new SubAgentsWatcher(session.transcriptPath);
1601
+ const parser = session.kind === 'codex'
1602
+ ? (line) => {
1603
+ const update = parseCodexSubagentNotificationRecord(line);
1604
+ if (update) {
1605
+ subagents.ingest(update);
1606
+ emitRawEvent(id, {
1607
+ source: 'codex-transcript',
1608
+ kind: 'subagent',
1609
+ summary: `${update.agentId} ${update.state}`,
1610
+ detail: update,
1611
+ });
1612
+ }
1613
+ return parseCodexRecord(line);
1614
+ }
1615
+ : undefined;
1616
+ const tailer = new TranscriptTailer(session.transcriptPath, { maxBuffer: CONFIG.maxBuffer, parser });
1253
1617
  sub = { tailer, subagents, clients: new Set(), pending: null };
1254
1618
  subscriptions.set(id, sub);
1255
1619
 
@@ -1258,6 +1622,12 @@ function ensureSubscription(id) {
1258
1622
  );
1259
1623
 
1260
1624
  tailer.on('append', (msgs) => {
1625
+ emitRawEvent(id, {
1626
+ source: session.kind === 'codex' ? 'codex-transcript' : 'transcript',
1627
+ kind: 'append',
1628
+ summary: `${msgs.length} message(s)`,
1629
+ detail: { messages: msgs },
1630
+ });
1261
1631
  broadcastTo(id, { type: 'append', id, messages: msgs });
1262
1632
  // A sub-agent may have just spawned (poll for its files) or finished (its
1263
1633
  // Task tool-call produced a tool_result → mark it done).
@@ -1271,6 +1641,12 @@ function ensureSubscription(id) {
1271
1641
  tailer.on('pending', (pending) => {
1272
1642
  sub.pending = pending;
1273
1643
  registry.setPending(id, !!pending);
1644
+ emitRawEvent(id, {
1645
+ source: 'transcript',
1646
+ kind: 'pending',
1647
+ summary: pending ? pending.questions?.[0]?.question || 'pending true' : 'pending false',
1648
+ detail: pending,
1649
+ });
1274
1650
  broadcastTo(id, { type: 'pending', id, pending });
1275
1651
  });
1276
1652
  tailer.on('error', (err) => broadcastTo(id, { type: 'ack', op: 'tail', ok: false, error: String(err?.message || err) }));
@@ -1292,10 +1668,101 @@ function ensureSubscription(id) {
1292
1668
  if (doneIds.size) subagents.markDone(doneIds);
1293
1669
  });
1294
1670
  sub.ready.catch(() => {}); // errors surface via the per-subscribe await below
1295
- startPromptPoller(id, sub);
1671
+ if (!codexRpc.has(id) && session.transport !== 'print') startPromptPoller(id, sub);
1296
1672
  return sub;
1297
1673
  }
1298
1674
 
1675
+ function sendSubscriptionSnapshot(ws, id, sub) {
1676
+ const liveMessages = claudePrint.has(id)
1677
+ ? claudePrint.messages(id)
1678
+ : codexRpc.messages(id);
1679
+ send(ws, {
1680
+ type: 'messages',
1681
+ id,
1682
+ // Tailer-less RPC-backed Codex subscriptions keep an in-memory message
1683
+ // buffer fed by app-server notifications. Claude print mode does the same
1684
+ // through its bridge until a real transcript tailer is available.
1685
+ messages: sub.tailer ? sub.tailer.getMessages() : liveMessages,
1686
+ pending: sub.tailer ? sub.tailer.getPending() : null,
1687
+ });
1688
+ const rpcPrompt = codexRpc.prompt(id);
1689
+ if (rpcPrompt) send(ws, { type: 'prompt', id, prompt: rpcPrompt });
1690
+ // Snapshot any already-running sub-agents for this session.
1691
+ const subs = sub.subagents ? sub.subagents.snapshot() : [];
1692
+ if (subs.length) send(ws, { type: 'subagents', id, subagents: subs });
1693
+ const rawEvents = rawEventsById.get(id) ?? [];
1694
+ if (rawEvents.length) send(ws, { type: 'raw-events', id, events: rawEvents });
1695
+ }
1696
+
1697
+ function upgradeSubscriptionIfTranscriptReady(id) {
1698
+ const old = subscriptions.get(id);
1699
+ if (!old || old.tailer) return;
1700
+ const session = sessionById(id);
1701
+ if (!session?.transcriptPath) return;
1702
+
1703
+ const clients = new Set(old.clients);
1704
+ if (old.promptTimer) clearInterval(old.promptTimer);
1705
+ subscriptions.delete(id);
1706
+
1707
+ const next = ensureSubscription(id);
1708
+ if (!next) return;
1709
+ for (const ws of clients) next.clients.add(ws);
1710
+
1711
+ next.ready.then(() => {
1712
+ for (const ws of clients) {
1713
+ if (ws.readyState === ws.OPEN && next.clients.has(ws)) {
1714
+ sendSubscriptionSnapshot(ws, id, next);
1715
+ }
1716
+ }
1717
+ }).catch((err) => {
1718
+ for (const ws of clients) {
1719
+ send(ws, { type: 'ack', op: 'subscribe', ok: false, error: String(err?.message || err) });
1720
+ }
1721
+ });
1722
+ }
1723
+
1724
+ async function ensureCodexRpcForSession(session) {
1725
+ if (session.kind !== 'codex') return null;
1726
+ const existing = codexRpc.get(session.target);
1727
+ if (existing) return existing;
1728
+
1729
+ let capture = '';
1730
+ try {
1731
+ capture = await tmux.capturePane(session.target, 200, false, true);
1732
+ } catch {
1733
+ return null;
1734
+ }
1735
+ const endpoint = parseCodexAppServerEndpoint(capture);
1736
+ if (!endpoint) {
1737
+ if (isCodexAppServerCapture(capture)) {
1738
+ throw new Error('Codex RPC app-server endpoint unavailable; refusing to type prompt into tmux pane');
1739
+ }
1740
+ return null;
1741
+ }
1742
+
1743
+ return codexRpc.ensureAttached({
1744
+ target: session.target,
1745
+ endpoint,
1746
+ cwd: session.cwd,
1747
+ resumeThreadId: session.sessionId,
1748
+ transcriptPath: session.transcriptPath,
1749
+ });
1750
+ }
1751
+
1752
+ async function ensureClaudePrintForSession(session) {
1753
+ if (session.kind !== 'claude' || session.transport !== 'print') return null;
1754
+ const existing = claudePrint.get(session.target);
1755
+ if (existing) return existing;
1756
+ const socketPath = session.endpoint || claudePrint.endpointFor(session.target);
1757
+ const client = await claudePrint.attach({
1758
+ target: session.target,
1759
+ socketPath,
1760
+ cwd: session.cwd,
1761
+ });
1762
+ await client.waitForBridge();
1763
+ return client;
1764
+ }
1765
+
1299
1766
  // Poll the live pane for a TUI selection prompt (permission/trust/numbered menu).
1300
1767
  // These never reach the transcript, so without this the cockpit shows a pending
1301
1768
  // tool-call and looks stuck. Broadcasts a `prompt` frame only when it changes.
@@ -1311,7 +1778,9 @@ function startPromptPoller(id, sub) {
1311
1778
  if (!session || !tmux.isValidTarget(session.target)) return;
1312
1779
  let prompt = null;
1313
1780
  try {
1314
- const cap = await tmux.capturePane(session.target, 40);
1781
+ // 80 lines so a tall AskUserQuestion (question + 5 multi-line options +
1782
+ // footer) fits the parser window (parsePanePrompt BOTTOM_REGION).
1783
+ const cap = await tmux.capturePane(session.target, 80);
1315
1784
  prompt = session.kind === 'codex' ? parseCodexPrompt(cap) : parsePanePrompt(cap);
1316
1785
  } catch {
1317
1786
  return;
@@ -1319,6 +1788,12 @@ function startPromptPoller(id, sub) {
1319
1788
  const json = prompt ? JSON.stringify(prompt) : null;
1320
1789
  if (json !== sub._lastPrompt) {
1321
1790
  sub._lastPrompt = json;
1791
+ emitRawEvent(id, {
1792
+ source: session.kind === 'codex' ? 'codex-tui' : 'tui',
1793
+ kind: 'prompt',
1794
+ summary: prompt ? prompt.question : 'cleared',
1795
+ detail: prompt,
1796
+ });
1322
1797
  broadcastTo(id, { type: 'prompt', id, prompt });
1323
1798
  }
1324
1799
  } finally {
@@ -1383,16 +1858,7 @@ async function handleClientMessage(ws, msg) {
1383
1858
  }
1384
1859
  // Client may have unsubscribed/closed while we awaited.
1385
1860
  if (!sub.clients.has(ws)) return;
1386
- send(ws, {
1387
- type: 'messages',
1388
- id: msg.id,
1389
- // Tailer-less subscription (no matched transcript): no history to send.
1390
- messages: sub.tailer ? sub.tailer.getMessages() : [],
1391
- pending: sub.tailer ? sub.tailer.getPending() : null,
1392
- });
1393
- // Snapshot any already-running sub-agents for this session.
1394
- const subs = sub.subagents ? sub.subagents.snapshot() : [];
1395
- if (subs.length) send(ws, { type: 'subagents', id: msg.id, subagents: subs });
1861
+ sendSubscriptionSnapshot(ws, msg.id, sub);
1396
1862
  return;
1397
1863
  }
1398
1864
  case 'unsubscribe': {
@@ -1406,6 +1872,35 @@ async function handleClientMessage(ws, msg) {
1406
1872
  if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
1407
1873
  const replyText = String(msg.text ?? '');
1408
1874
  return runSerial(session.target, async () => {
1875
+ if (session.kind === 'claude' && session.transport === 'print') {
1876
+ await ensureClaudePrintForSession(session);
1877
+ registry.setThinking(session.target, true);
1878
+ try {
1879
+ claudePrint.submit(session.target, replyText);
1880
+ } catch (err) {
1881
+ registry.setThinking(session.target, false);
1882
+ throw err;
1883
+ }
1884
+ send(ws, { type: 'ack', op: 'reply', ok: true, transport: 'claude-print' });
1885
+ return;
1886
+ }
1887
+ if (session.kind === 'codex') {
1888
+ const codexClient = await ensureCodexRpcForSession(session);
1889
+ if (codexClient) {
1890
+ registry.setThinking(session.target, true);
1891
+ try {
1892
+ await codexRpc.submit(session.target, replyText, { cwd: session.cwd });
1893
+ } catch (err) {
1894
+ registry.setThinking(session.target, false);
1895
+ throw err;
1896
+ }
1897
+ send(ws, { type: 'ack', op: 'reply', ok: true, transport: 'codex-rpc' });
1898
+ return;
1899
+ }
1900
+ // Codex TUI compatibility: only non-app-server Codex panes may use
1901
+ // tmux keystrokes. RPC app-server panes must never receive prompt text
1902
+ // in their terminal buffer.
1903
+ }
1409
1904
  await tmux.sendText(session.target, replyText);
1410
1905
  send(ws, { type: 'ack', op: 'reply', ok: true });
1411
1906
  });
@@ -1675,7 +2170,34 @@ async function handleClientMessage(ws, msg) {
1675
2170
  if (!ALLOWED.has(msg.key)) throw new Error('key not allowed');
1676
2171
  const promptKey = msg.key;
1677
2172
  return runSerial(session.target, async () => {
1678
- await tmux.sendRawKeys(session.target, [promptKey]);
2173
+ if (session.kind === 'claude' && session.transport === 'print') {
2174
+ if (promptKey !== 'Escape') throw new Error('key not allowed for Claude print mode');
2175
+ await ensureClaudePrintForSession(session);
2176
+ claudePrint.cancel(session.target);
2177
+ registry.setThinking(session.target, false);
2178
+ send(ws, { type: 'ack', op: 'promptkey', ok: true, transport: 'claude-print' });
2179
+ return;
2180
+ }
2181
+ if (session.kind === 'codex') {
2182
+ const codexClient = await ensureCodexRpcForSession(session);
2183
+ if (codexClient && codexRpc.prompt(session.target)) {
2184
+ codexRpc.answerPrompt(session.target, promptKey);
2185
+ const sub = subscriptions.get(msg.id);
2186
+ if (sub) sub._lastPrompt = '__force__';
2187
+ send(ws, { type: 'ack', op: 'promptkey', ok: true, transport: 'codex-rpc' });
2188
+ return;
2189
+ }
2190
+ // Codex TUI compatibility only; app-server panes are rejected inside
2191
+ // ensureCodexRpcForSession when an RPC endpoint cannot be attached.
2192
+ }
2193
+ // Codex TUI confirms a numbered choice with <digit> THEN Enter ("Press
2194
+ // enter to confirm"); the digit alone only moves the highlight.
2195
+ const isDigit = /^[1-9]$/.test(promptKey);
2196
+ if (session.kind === 'codex' && isDigit) {
2197
+ await tmux.sendRawKeysSequenced(session.target, [promptKey, 'Enter'], 120);
2198
+ } else {
2199
+ await tmux.sendRawKeys(session.target, [promptKey]);
2200
+ }
1679
2201
  // Force the next poll tick to broadcast (the prompt should now change/clear).
1680
2202
  const sub = subscriptions.get(msg.id);
1681
2203
  if (sub) sub._lastPrompt = '__force__';
@@ -1831,6 +2353,9 @@ function firePushForChange(sessions) {
1831
2353
  }
1832
2354
 
1833
2355
  registry.on('change', (sessions) => {
2356
+ codexRpc.sweep(sessions.map((s) => s.id));
2357
+ claudePrint.sweep(sessions.map((s) => s.id));
2358
+ for (const s of sessions) upgradeSubscriptionIfTranscriptReady(s.id);
1834
2359
  firePushForChange(sessions);
1835
2360
  broadcast({ type: 'sessions', sessions });
1836
2361
  });