@idl3/claude-control 1.3.0 → 1.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/claude-print-bridge.mjs +247 -0
- package/lib/claude-print.js +352 -0
- package/lib/codex-rpc.js +755 -0
- package/lib/codex.js +553 -89
- package/lib/pane-registry.js +38 -3
- package/lib/prompt.js +60 -21
- package/lib/sessions.js +289 -60
- package/lib/subagents.js +113 -0
- package/lib/tmux.js +68 -11
- package/lib/transcribe.js +1 -1
- package/lib/tui.js +21 -3
- package/lib/version.js +44 -8
- package/package.json +1 -1
- package/server.js +568 -39
- package/web/dist/assets/{core-C29-1O9j.js → core-CXYe4Mpr.js} +1 -1
- package/web/dist/assets/index-B15X7siX.js +104 -0
- package/web/dist/assets/index-BT4vDWJt.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CT-y6LU4.css +0 -1
- package/web/dist/assets/index-DzIDTXLS.js +0 -103
package/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
|
-
|
|
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
|
|
713
|
-
// Otherwise
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
: (
|
|
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
|
|
779
|
-
// so a bad request creates NO window
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|