@idl3/claude-control 0.4.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/auth.js +23 -2
- package/lib/codex.js +496 -0
- package/lib/config.js +38 -4
- package/lib/json-file.js +40 -0
- package/lib/match.js +78 -2
- package/lib/mlx.js +13 -0
- package/lib/pins.js +2 -3
- package/lib/push.js +3 -2
- package/lib/resources.js +112 -52
- package/lib/sessions.js +186 -13
- package/lib/shell.js +3 -1
- package/lib/subagents.js +7 -6
- package/lib/tmux.js +26 -7
- package/lib/transcribe.js +55 -24
- package/lib/transcript.js +5 -4
- package/lib/ws-heartbeat.js +32 -0
- package/package.json +1 -1
- package/server.js +312 -90
- package/web/dist/assets/{core-BA72pMy-.js → core-CpT6tRRG.js} +1 -1
- package/web/dist/assets/index-CjOcrKRX.css +1 -0
- package/web/dist/assets/index-CxhR0MPg.js +103 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CgHrw_VR.js +0 -103
- package/web/dist/assets/index-Dv9NwX8Q.css +0 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket ping/pong heartbeat helpers.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from server.js so tests can import pruneDeadClients without
|
|
5
|
+
* booting the HTTP/WS server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Prune dead WebSocket clients using the ping/pong aliveness flag.
|
|
10
|
+
*
|
|
11
|
+
* On every heartbeat tick the server calls this with `wss.clients`. Any
|
|
12
|
+
* client whose `isAlive` flag is still `false` from the previous sweep is
|
|
13
|
+
* terminated (firing its existing `close` handler → existing cleanup /
|
|
14
|
+
* `maybeTeardown`). Live clients have their flag reset to `false` and
|
|
15
|
+
* receive a ping; if they respond with a pong the `pong` handler in
|
|
16
|
+
* server.js sets `isAlive = true` before the next sweep.
|
|
17
|
+
*
|
|
18
|
+
* New connections set `isAlive = true` on creation, so they are never
|
|
19
|
+
* terminated on the very first sweep.
|
|
20
|
+
*
|
|
21
|
+
* @param {Iterable<{isAlive:boolean,terminate:()=>void,ping:()=>void}>} clients
|
|
22
|
+
*/
|
|
23
|
+
export function pruneDeadClients(clients) {
|
|
24
|
+
for (const ws of clients) {
|
|
25
|
+
if (ws.isAlive === false) {
|
|
26
|
+
ws.terminate();
|
|
27
|
+
} else {
|
|
28
|
+
ws.isAlive = false;
|
|
29
|
+
ws.ping();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idl3/claude-control",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local web UI to watch and drive your Claude Code sessions running in tmux — live transcripts, reply, answer AskUserQuestion, attach files, from a browser or phone.",
|
|
6
6
|
"keywords": [
|
package/server.js
CHANGED
|
@@ -10,7 +10,11 @@ import fs from 'node:fs';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import os from 'node:os';
|
|
13
|
-
import { spawn } from 'node:child_process';
|
|
13
|
+
import { spawn, execFile as _execFileRaw } from 'node:child_process';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
import fsp from 'node:fs/promises';
|
|
16
|
+
|
|
17
|
+
const _execFile = promisify(_execFileRaw);
|
|
14
18
|
import { WebSocketServer } from 'ws';
|
|
15
19
|
|
|
16
20
|
import * as tmux from './lib/tmux.js';
|
|
@@ -27,6 +31,7 @@ import { sweepUploads, resolveUploadPath } from './lib/uploads.js';
|
|
|
27
31
|
import { getVersionInfo, currentVersion } from './lib/version.js';
|
|
28
32
|
import * as push from './lib/push.js';
|
|
29
33
|
import { readConfig, writeConfig } from './lib/config.js';
|
|
34
|
+
import { parseCodexRecord, parseCodexPrompt, buildSpawnCommand } from './lib/codex.js';
|
|
30
35
|
import { optimizePrompt, rulesOptimize } from './lib/optimize.js';
|
|
31
36
|
import * as mlx from './lib/mlx.js';
|
|
32
37
|
import {
|
|
@@ -42,7 +47,8 @@ import { listSkills, readSkill } from './lib/skills.js';
|
|
|
42
47
|
// library auto-selects the FIRST offered one (the non-secret WS_PROTOCOL label)
|
|
43
48
|
// and echoes it, so we never reflect the raw token back and need no custom
|
|
44
49
|
// handleProtocols here. checkWsToken just verifies the token is among the offers.
|
|
45
|
-
import { checkToken as authCheckToken, checkWsToken } from './lib/auth.js';
|
|
50
|
+
import { checkToken as authCheckToken, checkWsToken, safeTokenEqual } from './lib/auth.js';
|
|
51
|
+
import { pruneDeadClients } from './lib/ws-heartbeat.js';
|
|
46
52
|
|
|
47
53
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
48
54
|
// Prefer the built assistant-ui app (web/dist) when present; otherwise fall back
|
|
@@ -75,6 +81,8 @@ const CONFIG = {
|
|
|
75
81
|
host: env('HOST') || '127.0.0.1',
|
|
76
82
|
projectsRoot:
|
|
77
83
|
env('PROJECTS') || path.join(os.homedir(), '.claude', 'projects'),
|
|
84
|
+
codexSessionsRoot:
|
|
85
|
+
env('CODEX_SESSIONS') || path.join(os.homedir(), '.codex', 'sessions'),
|
|
78
86
|
// 768MB: a long-running Node server (WS + transcript tailing + the bundled
|
|
79
87
|
// web app) baselines ~300-450MB of V8 heap + RSS, so the old 350MB budget
|
|
80
88
|
// tripped "over limit" permanently. Override with CLAUDE_CONTROL_RSS_LIMIT_MB.
|
|
@@ -122,7 +130,7 @@ const IMAGE_MIME = {
|
|
|
122
130
|
};
|
|
123
131
|
|
|
124
132
|
// --- shared state -----------------------------------------------------------
|
|
125
|
-
const registry = new SessionRegistry({ projectsRoot: CONFIG.projectsRoot, tmux });
|
|
133
|
+
const registry = new SessionRegistry({ projectsRoot: CONFIG.projectsRoot, codexSessionsRoot: CONFIG.codexSessionsRoot, tmux });
|
|
126
134
|
const resources = new ResourceMonitor({ rssLimitMB: CONFIG.rssLimitMB });
|
|
127
135
|
|
|
128
136
|
// Manual transcript pins (windowId.paneIndex -> transcript path). Loaded at boot,
|
|
@@ -152,7 +160,7 @@ function checkTerminalToken(reqUrl) {
|
|
|
152
160
|
if (!CONFIG.token) return true;
|
|
153
161
|
try {
|
|
154
162
|
const u = new URL(reqUrl, 'http://localhost');
|
|
155
|
-
return u.searchParams.get('token')
|
|
163
|
+
return safeTokenEqual(u.searchParams.get('token'), CONFIG.token);
|
|
156
164
|
} catch {
|
|
157
165
|
return false;
|
|
158
166
|
}
|
|
@@ -204,6 +212,7 @@ const _tls = loadTls();
|
|
|
204
212
|
const _scheme = _tls ? 'https' : 'http';
|
|
205
213
|
|
|
206
214
|
const _handler = (req, res) => {
|
|
215
|
+
try {
|
|
207
216
|
const u = new URL(req.url, 'http://localhost');
|
|
208
217
|
|
|
209
218
|
if (u.pathname === '/api/sessions') {
|
|
@@ -345,6 +354,33 @@ const _handler = (req, res) => {
|
|
|
345
354
|
if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
|
|
346
355
|
return handleTranscribe(req, res, u);
|
|
347
356
|
}
|
|
357
|
+
// GET /api/spawn-agents — agent-type availability (claude vs codex).
|
|
358
|
+
// Returns which agent binaries are resolvable on this machine so the UI can
|
|
359
|
+
// disable an unavailable agent picker option and show a reason.
|
|
360
|
+
// Token-gated + localhost, same as other GET endpoints.
|
|
361
|
+
if (u.pathname === '/api/spawn-agents') {
|
|
362
|
+
if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
|
|
363
|
+
const cfg = readConfig();
|
|
364
|
+
return Promise.all([
|
|
365
|
+
resolveBin(cfg.claudeBin || cfg.launchCommand),
|
|
366
|
+
resolveBin(cfg.codexBin || cfg.codexLaunchCommand),
|
|
367
|
+
]).then(([claudeResult, codexResult]) => {
|
|
368
|
+
return endJson(res, 200, {
|
|
369
|
+
agents: [
|
|
370
|
+
{
|
|
371
|
+
id: 'claude',
|
|
372
|
+
available: claudeResult.available,
|
|
373
|
+
...(claudeResult.available ? {} : { reason: claudeResult.reason }),
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
id: 'codex',
|
|
377
|
+
available: codexResult.available,
|
|
378
|
+
...(codexResult.available ? {} : { reason: codexResult.reason }),
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
}).catch((err) => endJson(res, 500, { error: String(err?.message || err) }));
|
|
383
|
+
}
|
|
348
384
|
if (u.pathname === '/api/session/new') {
|
|
349
385
|
if (req.method !== 'POST') return endJson(res, 405, { error: 'method not allowed' });
|
|
350
386
|
if (!checkToken(req)) return endJson(res, 401, { error: 'unauthorized' });
|
|
@@ -388,8 +424,16 @@ const _handler = (req, res) => {
|
|
|
388
424
|
return proxyTerminalHttp(req, res, u);
|
|
389
425
|
}
|
|
390
426
|
|
|
427
|
+
// Unknown /api/* path: return JSON 404 instead of falling through to the SPA.
|
|
428
|
+
if (u.pathname.startsWith('/api/')) return endJson(res, 404, { error: 'not found' });
|
|
429
|
+
|
|
391
430
|
// static
|
|
392
431
|
serveStatic(u.pathname, res);
|
|
432
|
+
} catch (e) {
|
|
433
|
+
// eslint-disable-next-line no-console
|
|
434
|
+
console.error('[handler] uncaught error:', e?.stack || e);
|
|
435
|
+
endJson(res, 500, { error: 'internal' });
|
|
436
|
+
}
|
|
393
437
|
};
|
|
394
438
|
|
|
395
439
|
const server = _tls
|
|
@@ -420,6 +464,7 @@ function handleUpdate(res) {
|
|
|
420
464
|
}
|
|
421
465
|
|
|
422
466
|
function endJson(res, code, obj) {
|
|
467
|
+
if (res.headersSent || res.writableEnded) return;
|
|
423
468
|
const body = JSON.stringify(obj);
|
|
424
469
|
res.writeHead(code, { 'content-type': MIME['.json'], 'content-length': Buffer.byteLength(body) });
|
|
425
470
|
res.end(body);
|
|
@@ -661,6 +706,40 @@ function handleTranscribe(req, res, u) {
|
|
|
661
706
|
});
|
|
662
707
|
}
|
|
663
708
|
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// resolveBin — async PATH lookup for a binary name or absolute path.
|
|
711
|
+
//
|
|
712
|
+
// If `bin` is an absolute path, checks it is executable directly.
|
|
713
|
+
// Otherwise runs `which <bin>` on PATH.
|
|
714
|
+
//
|
|
715
|
+
// Returns { available: true, path } on success, { available: false, reason }
|
|
716
|
+
// on failure. Never throws.
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
async function resolveBin(bin) {
|
|
719
|
+
if (!bin || typeof bin !== 'string' || !bin.trim()) {
|
|
720
|
+
return { available: false, reason: 'no binary configured' };
|
|
721
|
+
}
|
|
722
|
+
const b = bin.trim();
|
|
723
|
+
// Absolute path: check existence + execute permission directly.
|
|
724
|
+
if (b.startsWith('/')) {
|
|
725
|
+
try {
|
|
726
|
+
await fsp.access(b, fsp.constants?.X_OK ?? 1);
|
|
727
|
+
return { available: true, path: b };
|
|
728
|
+
} catch {
|
|
729
|
+
return { available: false, reason: `binary not found or not executable: ${b}` };
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// Relative / bare name: resolve via `which`.
|
|
733
|
+
try {
|
|
734
|
+
const { stdout } = await _execFile('which', [b], { timeout: 5000 });
|
|
735
|
+
const resolved = stdout.trim();
|
|
736
|
+
if (resolved) return { available: true, path: resolved };
|
|
737
|
+
return { available: false, reason: `${b} not found on PATH` };
|
|
738
|
+
} catch {
|
|
739
|
+
return { available: false, reason: `${b} not found on PATH` };
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
664
743
|
// POST /api/session/new — create a new tmux window in the configured (or
|
|
665
744
|
// body-overridden) cwd, then type the launch command into it via send-keys so
|
|
666
745
|
// the interactive shell resolves aliases. Security: the command is operator
|
|
@@ -676,23 +755,66 @@ async function handleSessionNew(req, res) {
|
|
|
676
755
|
const config = readConfig();
|
|
677
756
|
const cwd =
|
|
678
757
|
typeof body.cwd === 'string' && body.cwd.trim() ? body.cwd : config.defaultCwd;
|
|
758
|
+
|
|
759
|
+
// agent ∈ {'claude','codex'}, default 'claude'.
|
|
760
|
+
const agent = body.agent === 'codex' ? 'codex' : 'claude';
|
|
761
|
+
|
|
679
762
|
// Name is required-with-default: sanitize the requested name, falling back to
|
|
680
763
|
// `session-<short-ts>` so a session is ALWAYS named (the rail reads the tmux
|
|
681
764
|
// window name until a transcript title exists).
|
|
682
765
|
const name = tmux.sanitizeName(body.name) || tmux.defaultSessionName();
|
|
766
|
+
|
|
767
|
+
// --- Pre-validation: binary resolution + cwd check BEFORE creating any window ---
|
|
768
|
+
|
|
769
|
+
// (i) Resolve the agent binary and return 400 if unavailable.
|
|
770
|
+
const agentBin = agent === 'codex'
|
|
771
|
+
? (config.codexBin || config.codexLaunchCommand)
|
|
772
|
+
: (config.claudeBin || config.launchCommand);
|
|
773
|
+
const binCheck = await resolveBin(agentBin);
|
|
774
|
+
if (!binCheck.available) {
|
|
775
|
+
return endJson(res, 400, { error: `agent binary unavailable: ${binCheck.reason}` });
|
|
776
|
+
}
|
|
777
|
+
|
|
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') {
|
|
781
|
+
try {
|
|
782
|
+
const st = await fsp.stat(cwd);
|
|
783
|
+
if (!st.isDirectory()) {
|
|
784
|
+
return endJson(res, 400, { error: `cwd is not a directory: ${cwd}` });
|
|
785
|
+
}
|
|
786
|
+
} catch {
|
|
787
|
+
return endJson(res, 400, { error: `cwd does not exist: ${cwd}` });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
683
791
|
try {
|
|
684
792
|
// (1) Reliable named path: the tmux window name. createWindow sets it via
|
|
685
|
-
// `new-window -n
|
|
793
|
+
// `new-window -n` and the `-c cwd` flag — cwd flows through tmux's own
|
|
794
|
+
// working-directory flag, never a shell `cd`.
|
|
686
795
|
const target = await tmux.createWindow({ cwd, name });
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
796
|
+
|
|
797
|
+
let launch;
|
|
798
|
+
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)}`;
|
|
805
|
+
} else {
|
|
806
|
+
// Claude path: BYTE-IDENTICAL to the pre-Phase-D implementation.
|
|
807
|
+
// (2) Claude's own session title: `claude --help` exposes `-n/--name`
|
|
808
|
+
// (display name in the prompt box, /resume picker, terminal title), so
|
|
809
|
+
// we append it to the launch command rather than relying on a delayed
|
|
810
|
+
// `/rename`. The name is shell-quoted (sanitizeName already stripped
|
|
811
|
+
// control chars/newlines) since the command is typed into an interactive
|
|
812
|
+
// shell so aliases like `yolo` resolve. sendText appends Enter → runs it.
|
|
813
|
+
launch = `${config.launchCommand} --name ${tmux.shellQuoteName(name)}`;
|
|
814
|
+
}
|
|
815
|
+
|
|
694
816
|
await tmux.sendText(target, launch);
|
|
695
|
-
return endJson(res, 200, { ok: true, target, name });
|
|
817
|
+
return endJson(res, 200, { ok: true, target, name, agent });
|
|
696
818
|
} catch (err) {
|
|
697
819
|
return endJson(res, 500, { error: String(err?.message || err) });
|
|
698
820
|
}
|
|
@@ -953,6 +1075,47 @@ function serveIndexHtml(res) {
|
|
|
953
1075
|
});
|
|
954
1076
|
}
|
|
955
1077
|
|
|
1078
|
+
// --- Per-target WS op serialisation ----------------------------------------
|
|
1079
|
+
// Multiple browser/device clients on the same session can fire overlapping
|
|
1080
|
+
// send-keys ops concurrently. Two such ops dispatched to the same tmux pane
|
|
1081
|
+
// interleave keystrokes mid-sequence. We prevent this with a per-target FIFO
|
|
1082
|
+
// promise chain: each send-keys op appends to the tail of its target's chain
|
|
1083
|
+
// and runs only after its predecessor settles. Different targets run in
|
|
1084
|
+
// parallel. Read-only ops (subscribe, capture, shell-capture, …) are NOT
|
|
1085
|
+
// enqueued — they never touch the pane input buffer.
|
|
1086
|
+
const _opChains = new Map(); // target → current-tail Promise
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Enqueue `fn` behind any in-flight op on `target`.
|
|
1090
|
+
*
|
|
1091
|
+
* Contract:
|
|
1092
|
+
* - The returned promise settles exactly as fn() settles (value / throw).
|
|
1093
|
+
* - A rejected op does NOT poison the next op on the same target — the chain
|
|
1094
|
+
* continues regardless of whether prev settled fulfilled or rejected.
|
|
1095
|
+
* - The Map entry is deleted once the queued op is the sole tail and it has
|
|
1096
|
+
* settled, preventing unbounded growth on idle targets.
|
|
1097
|
+
*
|
|
1098
|
+
* @param {string} target tmux pane target (the serialisation key)
|
|
1099
|
+
* @param {() => Promise<any>} fn async work to serialise
|
|
1100
|
+
* @returns {Promise<any>}
|
|
1101
|
+
*/
|
|
1102
|
+
function runSerial(target, fn) {
|
|
1103
|
+
const prev = _opChains.get(target) ?? Promise.resolve();
|
|
1104
|
+
// chain: run fn after prev regardless of prev's outcome
|
|
1105
|
+
const tail = prev.then(fn, fn);
|
|
1106
|
+
// Store the tail so the NEXT enqueue can chain behind it.
|
|
1107
|
+
// Suppress any rejection on the stored promise so Node's
|
|
1108
|
+
// unhandledRejection handler never fires on the chain itself —
|
|
1109
|
+
// the caller's `tail` reference will surface the error to the caller.
|
|
1110
|
+
const stored = tail.then(() => {}, () => {});
|
|
1111
|
+
_opChains.set(target, stored);
|
|
1112
|
+
// Clean up once this op is the last in the chain and it has settled.
|
|
1113
|
+
stored.finally(() => {
|
|
1114
|
+
if (_opChains.get(target) === stored) _opChains.delete(target);
|
|
1115
|
+
});
|
|
1116
|
+
return tail;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
956
1119
|
// --- WebSocket --------------------------------------------------------------
|
|
957
1120
|
// 1 MB cap: control messages are tiny; this prevents a single huge frame from
|
|
958
1121
|
// forcing a multi-hundred-MB string allocation in the cockpit process.
|
|
@@ -1083,7 +1246,7 @@ function ensureSubscription(id) {
|
|
|
1083
1246
|
return sub;
|
|
1084
1247
|
}
|
|
1085
1248
|
|
|
1086
|
-
const tailer = new TranscriptTailer(session.transcriptPath, { maxBuffer: CONFIG.maxBuffer });
|
|
1249
|
+
const tailer = new TranscriptTailer(session.transcriptPath, { maxBuffer: CONFIG.maxBuffer, parser: session.kind === 'codex' ? parseCodexRecord : undefined });
|
|
1087
1250
|
// Watch this session's sub-agent transcripts (Task/Agent). Discovery is polled
|
|
1088
1251
|
// when the parent transcript grows (when sub-agents spawn) + once at subscribe.
|
|
1089
1252
|
const subagents = new SubAgentsWatcher(session.transcriptPath);
|
|
@@ -1139,20 +1302,27 @@ function ensureSubscription(id) {
|
|
|
1139
1302
|
function startPromptPoller(id, sub) {
|
|
1140
1303
|
if (sub.promptTimer) return;
|
|
1141
1304
|
sub._lastPrompt = undefined;
|
|
1305
|
+
sub._promptTicking = false;
|
|
1142
1306
|
const tick = async () => {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
let prompt = null;
|
|
1307
|
+
if (sub._promptTicking) return;
|
|
1308
|
+
sub._promptTicking = true;
|
|
1146
1309
|
try {
|
|
1147
|
-
const
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1310
|
+
const session = sessionById(id);
|
|
1311
|
+
if (!session || !tmux.isValidTarget(session.target)) return;
|
|
1312
|
+
let prompt = null;
|
|
1313
|
+
try {
|
|
1314
|
+
const cap = await tmux.capturePane(session.target, 40);
|
|
1315
|
+
prompt = session.kind === 'codex' ? parseCodexPrompt(cap) : parsePanePrompt(cap);
|
|
1316
|
+
} catch {
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
const json = prompt ? JSON.stringify(prompt) : null;
|
|
1320
|
+
if (json !== sub._lastPrompt) {
|
|
1321
|
+
sub._lastPrompt = json;
|
|
1322
|
+
broadcastTo(id, { type: 'prompt', id, prompt });
|
|
1323
|
+
}
|
|
1324
|
+
} finally {
|
|
1325
|
+
sub._promptTicking = false;
|
|
1156
1326
|
}
|
|
1157
1327
|
};
|
|
1158
1328
|
sub.promptTimer = setInterval(() => tick().catch(() => {}), 2000);
|
|
@@ -1171,6 +1341,9 @@ function maybeTeardown(id) {
|
|
|
1171
1341
|
}
|
|
1172
1342
|
|
|
1173
1343
|
wss.on('connection', (ws) => {
|
|
1344
|
+
ws.isAlive = true;
|
|
1345
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
1346
|
+
|
|
1174
1347
|
send(ws, { type: 'sessions', sessions: registry.getSessions() });
|
|
1175
1348
|
send(ws, { type: 'resources', snapshot: resources.snapshot() });
|
|
1176
1349
|
ws._subs = new Set();
|
|
@@ -1193,6 +1366,9 @@ wss.on('connection', (ws) => {
|
|
|
1193
1366
|
});
|
|
1194
1367
|
});
|
|
1195
1368
|
|
|
1369
|
+
const heartbeatInterval = setInterval(() => pruneDeadClients(wss.clients), 30000);
|
|
1370
|
+
heartbeatInterval.unref();
|
|
1371
|
+
|
|
1196
1372
|
async function handleClientMessage(ws, msg) {
|
|
1197
1373
|
switch (msg.type) {
|
|
1198
1374
|
case 'subscribe': {
|
|
@@ -1228,8 +1404,11 @@ async function handleClientMessage(ws, msg) {
|
|
|
1228
1404
|
const session = sessionById(msg.id);
|
|
1229
1405
|
if (!session) throw new Error('unknown session');
|
|
1230
1406
|
if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
|
|
1231
|
-
|
|
1232
|
-
return
|
|
1407
|
+
const replyText = String(msg.text ?? '');
|
|
1408
|
+
return runSerial(session.target, async () => {
|
|
1409
|
+
await tmux.sendText(session.target, replyText);
|
|
1410
|
+
send(ws, { type: 'ack', op: 'reply', ok: true });
|
|
1411
|
+
});
|
|
1233
1412
|
}
|
|
1234
1413
|
case 'answer': {
|
|
1235
1414
|
const session = sessionById(msg.id);
|
|
@@ -1244,6 +1423,7 @@ async function handleClientMessage(ws, msg) {
|
|
|
1244
1423
|
throw new Error('stale question (already answered or changed)');
|
|
1245
1424
|
}
|
|
1246
1425
|
|
|
1426
|
+
return runSerial(session.target, async () => {
|
|
1247
1427
|
// ── Capture-driven path ──────────────────────────────────────────────
|
|
1248
1428
|
// Attempt to navigate by parsing the live picker render. Falls back to
|
|
1249
1429
|
// the static buildAnswerProgram on ANY parse failure, unknown label, or
|
|
@@ -1449,7 +1629,8 @@ async function handleClientMessage(ws, msg) {
|
|
|
1449
1629
|
console.log(`[answer] sent toolUseId=${msg.toolUseId} via dynamic path`);
|
|
1450
1630
|
}
|
|
1451
1631
|
|
|
1452
|
-
|
|
1632
|
+
send(ws, { type: 'ack', op: 'answer', ok: true });
|
|
1633
|
+
}); // end runSerial
|
|
1453
1634
|
}
|
|
1454
1635
|
case 'capture': {
|
|
1455
1636
|
const session = sessionById(msg.id);
|
|
@@ -1467,16 +1648,22 @@ async function handleClientMessage(ws, msg) {
|
|
|
1467
1648
|
const session = sessionById(msg.id);
|
|
1468
1649
|
if (!session) throw new Error('unknown session');
|
|
1469
1650
|
if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
|
|
1470
|
-
|
|
1471
|
-
return
|
|
1651
|
+
const paneText = String(msg.text ?? '');
|
|
1652
|
+
return runSerial(session.target, async () => {
|
|
1653
|
+
await tmux.sendLiteral(session.target, paneText);
|
|
1654
|
+
send(ws, { type: 'ack', op: 'pane-text', ok: true });
|
|
1655
|
+
});
|
|
1472
1656
|
}
|
|
1473
1657
|
case 'pane-key': {
|
|
1474
1658
|
const session = sessionById(msg.id);
|
|
1475
1659
|
if (!session) throw new Error('unknown session');
|
|
1476
1660
|
if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
|
|
1477
1661
|
if (!shell.SHELL_KEYS.has(String(msg.key ?? ''))) throw new Error('key not allowed');
|
|
1478
|
-
|
|
1479
|
-
return
|
|
1662
|
+
const paneKey = String(msg.key);
|
|
1663
|
+
return runSerial(session.target, async () => {
|
|
1664
|
+
await tmux.sendRawKeys(session.target, [paneKey]);
|
|
1665
|
+
send(ws, { type: 'ack', op: 'pane-key', ok: true });
|
|
1666
|
+
});
|
|
1480
1667
|
}
|
|
1481
1668
|
case 'promptkey': {
|
|
1482
1669
|
// Respond to a live TUI selection prompt (permission/menu). Whitelisted
|
|
@@ -1486,11 +1673,14 @@ async function handleClientMessage(ws, msg) {
|
|
|
1486
1673
|
if (!tmux.isValidTarget(session.target)) throw new Error('invalid tmux target');
|
|
1487
1674
|
const ALLOWED = new Set(['1', '2', '3', '4', '5', '6', '7', '8', '9', 'Enter', 'Escape', 'Up', 'Down']);
|
|
1488
1675
|
if (!ALLOWED.has(msg.key)) throw new Error('key not allowed');
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1676
|
+
const promptKey = msg.key;
|
|
1677
|
+
return runSerial(session.target, async () => {
|
|
1678
|
+
await tmux.sendRawKeys(session.target, [promptKey]);
|
|
1679
|
+
// Force the next poll tick to broadcast (the prompt should now change/clear).
|
|
1680
|
+
const sub = subscriptions.get(msg.id);
|
|
1681
|
+
if (sub) sub._lastPrompt = '__force__';
|
|
1682
|
+
send(ws, { type: 'ack', op: 'promptkey', ok: true });
|
|
1683
|
+
});
|
|
1494
1684
|
}
|
|
1495
1685
|
case 'promptselect': {
|
|
1496
1686
|
// Respond to a live TUI multi-select checkbox prompt (surfaced via pane-scrape
|
|
@@ -1503,57 +1693,61 @@ async function handleClientMessage(ws, msg) {
|
|
|
1503
1693
|
const labels = Array.isArray(msg.labels) ? msg.labels.map(String) : [];
|
|
1504
1694
|
if (labels.length === 0) throw new Error('no labels provided');
|
|
1505
1695
|
|
|
1506
|
-
|
|
1696
|
+
return runSerial(session.target, async () => {
|
|
1697
|
+
const SETTLE_MS = 300;
|
|
1507
1698
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1699
|
+
// 1. Capture current picker state.
|
|
1700
|
+
let capture;
|
|
1701
|
+
try {
|
|
1702
|
+
capture = await tmux.capturePane(session.target);
|
|
1703
|
+
} catch (captureErr) {
|
|
1704
|
+
throw new Error(`promptselect: capture failed: ${captureErr?.message}`);
|
|
1705
|
+
}
|
|
1515
1706
|
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1707
|
+
// 2. Parse into a structured picker model.
|
|
1708
|
+
const parsed = parsePicker(capture);
|
|
1709
|
+
if (parsed.confidence !== 'ok') {
|
|
1710
|
+
send(ws, {
|
|
1711
|
+
type: 'ack',
|
|
1712
|
+
op: 'promptselect',
|
|
1713
|
+
ok: false,
|
|
1714
|
+
error: 'promptselect: picker not found or low confidence — please retry',
|
|
1715
|
+
});
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1526
1718
|
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
// 4. Plan keystrokes via the tested planStep function.
|
|
1537
|
-
const keys = planStep(parsed, syntheticQuestion, labels);
|
|
1538
|
-
if (!keys) {
|
|
1539
|
-
console.log(`[promptselect] planStep returned null for labels=${JSON.stringify(labels)} — low confidence`);
|
|
1540
|
-
return send(ws, {
|
|
1541
|
-
type: 'ack',
|
|
1542
|
-
op: 'promptselect',
|
|
1543
|
-
ok: false,
|
|
1544
|
-
error: 'promptselect: could not map labels to picker rows — please retry',
|
|
1545
|
-
});
|
|
1546
|
-
}
|
|
1719
|
+
// 3. Build a synthetic single-question descriptor (multiSelect=true) so
|
|
1720
|
+
// planStep can calculate Space-toggle + action-row Enter keys.
|
|
1721
|
+
const syntheticQuestion = {
|
|
1722
|
+
multiSelect: true,
|
|
1723
|
+
options: parsed.rows
|
|
1724
|
+
.filter((r) => r.kind === 'option')
|
|
1725
|
+
.map((r) => ({ label: r.label })),
|
|
1726
|
+
};
|
|
1547
1727
|
|
|
1548
|
-
|
|
1728
|
+
// 4. Plan keystrokes via the tested planStep function.
|
|
1729
|
+
const keys = planStep(parsed, syntheticQuestion, labels);
|
|
1730
|
+
if (!keys) {
|
|
1731
|
+
console.log(`[promptselect] planStep returned null for labels=${JSON.stringify(labels)} — low confidence`);
|
|
1732
|
+
send(ws, {
|
|
1733
|
+
type: 'ack',
|
|
1734
|
+
op: 'promptselect',
|
|
1735
|
+
ok: false,
|
|
1736
|
+
error: 'promptselect: could not map labels to picker rows — please retry',
|
|
1737
|
+
});
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1549
1740
|
|
|
1550
|
-
|
|
1551
|
-
await tmux.sendRawKeysSequenced(session.target, keys, SETTLE_MS);
|
|
1741
|
+
console.log(`[promptselect] id=${msg.id} labels=${JSON.stringify(labels)} keys=${JSON.stringify(keys)}`);
|
|
1552
1742
|
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1743
|
+
// 5. Send keys sequenced with settle delay (same as case 'answer' dynamic path).
|
|
1744
|
+
await tmux.sendRawKeysSequenced(session.target, keys, SETTLE_MS);
|
|
1745
|
+
|
|
1746
|
+
// Force the next poll tick to broadcast (the prompt should now change/clear).
|
|
1747
|
+
const promptSub = subscriptions.get(msg.id);
|
|
1748
|
+
if (promptSub) promptSub._lastPrompt = '__force__';
|
|
1749
|
+
send(ws, { type: 'ack', op: 'promptselect', ok: true });
|
|
1750
|
+
});
|
|
1557
1751
|
}
|
|
1558
1752
|
// Composer terminal mode (>_): each Claude session has its OWN sister shell
|
|
1559
1753
|
// pane in its window. Resolve the session by id → its target + cwd, then act
|
|
@@ -1561,20 +1755,29 @@ async function handleClientMessage(ws, msg) {
|
|
|
1561
1755
|
case 'shell-input': {
|
|
1562
1756
|
const s = sessionById(msg.id);
|
|
1563
1757
|
if (!s) throw new Error('unknown session');
|
|
1564
|
-
|
|
1565
|
-
return
|
|
1758
|
+
const shellInputLine = String(msg.line ?? '');
|
|
1759
|
+
return runSerial(s.target + ':shell', async () => {
|
|
1760
|
+
await shell.shellInput(s.target, s.cwd, shellInputLine);
|
|
1761
|
+
send(ws, { type: 'ack', op: 'shell-input', ok: true });
|
|
1762
|
+
});
|
|
1566
1763
|
}
|
|
1567
1764
|
case 'shell-text': {
|
|
1568
1765
|
const s = sessionById(msg.id);
|
|
1569
1766
|
if (!s) throw new Error('unknown session');
|
|
1570
|
-
|
|
1571
|
-
return
|
|
1767
|
+
const shellTextVal = String(msg.text ?? '');
|
|
1768
|
+
return runSerial(s.target + ':shell', async () => {
|
|
1769
|
+
await shell.shellText(s.target, s.cwd, shellTextVal);
|
|
1770
|
+
send(ws, { type: 'ack', op: 'shell-text', ok: true });
|
|
1771
|
+
});
|
|
1572
1772
|
}
|
|
1573
1773
|
case 'shell-key': {
|
|
1574
1774
|
const s = sessionById(msg.id);
|
|
1575
1775
|
if (!s) throw new Error('unknown session');
|
|
1576
|
-
|
|
1577
|
-
return
|
|
1776
|
+
const shellKeyVal = String(msg.key ?? '');
|
|
1777
|
+
return runSerial(s.target + ':shell', async () => {
|
|
1778
|
+
await shell.shellKey(s.target, s.cwd, shellKeyVal);
|
|
1779
|
+
send(ws, { type: 'ack', op: 'shell-key', ok: true });
|
|
1780
|
+
});
|
|
1578
1781
|
}
|
|
1579
1782
|
case 'shell-capture': {
|
|
1580
1783
|
const s = sessionById(msg.id);
|
|
@@ -1691,8 +1894,10 @@ async function main() {
|
|
|
1691
1894
|
}
|
|
1692
1895
|
|
|
1693
1896
|
function shutdown() {
|
|
1897
|
+
clearInterval(heartbeatInterval);
|
|
1694
1898
|
for (const [, sub] of subscriptions) sub.tailer?.stop();
|
|
1695
1899
|
terminal.shutdownAll();
|
|
1900
|
+
mlx.shutdown();
|
|
1696
1901
|
registry.stop();
|
|
1697
1902
|
resources.stop();
|
|
1698
1903
|
if (uploadSweepTimer) clearInterval(uploadSweepTimer);
|
|
@@ -1702,4 +1907,21 @@ function shutdown() {
|
|
|
1702
1907
|
process.on('SIGINT', shutdown);
|
|
1703
1908
|
process.on('SIGTERM', shutdown);
|
|
1704
1909
|
|
|
1705
|
-
|
|
1910
|
+
// Safety nets: log unhandled async rejections; exit on truly uncaught sync
|
|
1911
|
+
// exceptions so Node doesn't continue with a corrupted process state.
|
|
1912
|
+
process.on('unhandledRejection', (e) => {
|
|
1913
|
+
// eslint-disable-next-line no-console
|
|
1914
|
+
console.error('[unhandledRejection]', e?.stack || e);
|
|
1915
|
+
});
|
|
1916
|
+
process.on('uncaughtException', (e) => {
|
|
1917
|
+
// eslint-disable-next-line no-console
|
|
1918
|
+
console.error('[uncaughtException]', e?.stack || e);
|
|
1919
|
+
process.exit(1);
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
// Guard: only run the server when executed directly, not when imported for testing.
|
|
1923
|
+
const _isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
1924
|
+
if (_isMain) main();
|
|
1925
|
+
|
|
1926
|
+
// Exported for unit testing only — not part of the public API.
|
|
1927
|
+
export { endJson, _handler, runSerial };
|