@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.
@@ -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": "0.4.1",
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') === CONFIG.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`, so the rail shows the name immediately.
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
- // (2) Claude's own session title: `claude --help` exposes `-n/--name`
688
- // (display name in the prompt box, /resume picker, terminal title), so
689
- // we append it to the launch command rather than relying on a delayed
690
- // `/rename`. The name is shell-quoted (sanitizeName already stripped
691
- // control chars/newlines) since the command is typed into an interactive
692
- // shell so aliases like `yolo` resolve. sendText appends Enter runs it.
693
- const launch = `${config.launchCommand} --name ${tmux.shellQuoteName(name)}`;
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
- const session = sessionById(id);
1144
- if (!session || !tmux.isValidTarget(session.target)) return;
1145
- let prompt = null;
1307
+ if (sub._promptTicking) return;
1308
+ sub._promptTicking = true;
1146
1309
  try {
1147
- const cap = await tmux.capturePane(session.target, 40);
1148
- prompt = parsePanePrompt(cap);
1149
- } catch {
1150
- return;
1151
- }
1152
- const json = prompt ? JSON.stringify(prompt) : null;
1153
- if (json !== sub._lastPrompt) {
1154
- sub._lastPrompt = json;
1155
- broadcastTo(id, { type: 'prompt', id, prompt });
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
- await tmux.sendText(session.target, String(msg.text ?? ''));
1232
- return send(ws, { type: 'ack', op: 'reply', ok: true });
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
- return send(ws, { type: 'ack', op: 'answer', ok: true });
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
- await tmux.sendLiteral(session.target, String(msg.text ?? ''));
1471
- return send(ws, { type: 'ack', op: 'pane-text', ok: true });
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
- await tmux.sendRawKeys(session.target, [String(msg.key)]);
1479
- return send(ws, { type: 'ack', op: 'pane-key', ok: true });
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
- await tmux.sendRawKeys(session.target, [msg.key]);
1490
- // Force the next poll tick to broadcast (the prompt should now change/clear).
1491
- const sub = subscriptions.get(msg.id);
1492
- if (sub) sub._lastPrompt = '__force__';
1493
- return send(ws, { type: 'ack', op: 'promptkey', ok: true });
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
- const SETTLE_MS = 300;
1696
+ return runSerial(session.target, async () => {
1697
+ const SETTLE_MS = 300;
1507
1698
 
1508
- // 1. Capture current picker state.
1509
- let capture;
1510
- try {
1511
- capture = await tmux.capturePane(session.target);
1512
- } catch (captureErr) {
1513
- throw new Error(`promptselect: capture failed: ${captureErr?.message}`);
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
- // 2. Parse into a structured picker model.
1517
- const parsed = parsePicker(capture);
1518
- if (parsed.confidence !== 'ok') {
1519
- return send(ws, {
1520
- type: 'ack',
1521
- op: 'promptselect',
1522
- ok: false,
1523
- error: 'promptselect: picker not found or low confidence — please retry',
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
- // 3. Build a synthetic single-question descriptor (multiSelect=true) so
1528
- // planStep can calculate Space-toggle + action-row Enter keys.
1529
- const syntheticQuestion = {
1530
- multiSelect: true,
1531
- options: parsed.rows
1532
- .filter((r) => r.kind === 'option')
1533
- .map((r) => ({ label: r.label })),
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
- console.log(`[promptselect] id=${msg.id} labels=${JSON.stringify(labels)} keys=${JSON.stringify(keys)}`);
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
- // 5. Send keys sequenced with settle delay (same as case 'answer' dynamic path).
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
- // Force the next poll tick to broadcast (the prompt should now change/clear).
1554
- const promptSub = subscriptions.get(msg.id);
1555
- if (promptSub) promptSub._lastPrompt = '__force__';
1556
- return send(ws, { type: 'ack', op: 'promptselect', ok: true });
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
- await shell.shellInput(s.target, s.cwd, String(msg.line ?? ''));
1565
- return send(ws, { type: 'ack', op: 'shell-input', ok: true });
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
- await shell.shellText(s.target, s.cwd, String(msg.text ?? ''));
1571
- return send(ws, { type: 'ack', op: 'shell-text', ok: true });
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
- await shell.shellKey(s.target, s.cwd, String(msg.key ?? ''));
1577
- return send(ws, { type: 'ack', op: 'shell-key', ok: true });
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
- main();
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 };