@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/bin/claude-print-bridge.mjs +247 -0
- package/lib/claude-print.js +352 -0
- package/lib/codex-rpc.js +719 -0
- package/lib/codex.js +639 -74
- package/lib/pane-registry.js +58 -10
- package/lib/prompt.js +60 -21
- package/lib/sessions.js +300 -63
- package/lib/subagents.js +113 -0
- package/lib/tmux.js +68 -11
- package/lib/transcribe.js +1 -1
- package/lib/tui.js +10 -3
- package/lib/version.js +44 -8
- package/package.json +1 -1
- package/server.js +561 -36
- package/web/dist/assets/{core-CpT6tRRG.js → core-BPDebW1g.js} +1 -1
- package/web/dist/assets/index-B3rIEzoc.css +1 -0
- package/web/dist/assets/index-DIwGyVZ7.js +104 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CjOcrKRX.css +0 -1
- package/web/dist/assets/index-CxhR0MPg.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,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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|