@shumkov/orchestra 0.2.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,251 @@
1
+ // provenance: polygram@0.17.11 lib/tmux/startup-gate.js (git 746bca6) — verbatim*: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * runStartupGate — generic helper for "spawn a tmux'd TUI and wait until
4
+ * it's accepting input, sending Enter for known transient dialogs along
5
+ * the way".
6
+ *
7
+ * Extracted from CliProcess._handleStartupDialogs (M1 follow-on
8
+ * refactor). Made caller-agnostic so future TmuxProcess flows that need
9
+ * to navigate trust / dev-channels / approval-mode prompts can reuse
10
+ * it without duplicating the poll loop.
11
+ *
12
+ * Loop semantics:
13
+ * - capture-pane every `pollMs` (default 300ms)
14
+ * - if any trigger regex matches AND its `name` hasn't been seen, send
15
+ * the trigger's `key` (typically 'Enter') via runner.sendControl
16
+ * - after each send, wait `settleMs` (default 500ms) for the TUI to
17
+ * transition out of the dialog before the next poll
18
+ * - if `readySignal` regex matches the captured pane content, resolve
19
+ * - if `Date.now()` exceeds the deadline, throw with `err.code = timeoutCode`
20
+ *
21
+ * Progress-aware (stall) deadline — `stallMs`:
22
+ * The blind wall-clock `deadlineMs` can't tell "claude is mid-download
23
+ * (24% progress bar, genuinely working)" from "claude is wedged". The
24
+ * shumorobot General incident (2026-05-30) killed a cold-spawn that was
25
+ * actively downloading the runtime. When `stallMs` is set, the gate
26
+ * tracks pane ACTIVITY: any change in captured pane content — or a
27
+ * trigger key being sent — resets a stall clock. The gate fails early
28
+ * (with `timeoutCode`) only after `stallMs` elapses with NO activity,
29
+ * i.e. the pane is frozen. `deadlineMs` remains an absolute backstop so
30
+ * a pane that animates forever but never reaches `readySignal` still
31
+ * terminates. When `stallMs` is omitted (default), behavior is the pure
32
+ * `deadlineMs` wall-clock exactly as before.
33
+ *
34
+ * Each trigger is one-shot per gate run (tracked by `name` in a Set).
35
+ *
36
+ * Caller supplies:
37
+ * - runner: object with `captureWide(tmuxName)` and `sendControl(tmuxName, key)`
38
+ * - triggers: [{name, regex, key}] — order matters; first match wins
39
+ * - readySignal: RegExp matching the "TUI is ready, no more dialogs" pane text
40
+ * - deadlineMs, pollMs, settleMs — timeouts
41
+ * - timeoutCode: err.code on deadline expiry (default 'TUI_STARTUP_TIMEOUT')
42
+ * - logger, label — for diagnostic prose
43
+ */
44
+
45
+ 'use strict';
46
+
47
+ const DEFAULT_DEADLINE_MS = 30_000;
48
+ const DEFAULT_POLL_MS = 300;
49
+ const DEFAULT_SETTLE_MS = 500;
50
+
51
+ /**
52
+ * @param {object} opts
53
+ * @param {object} opts.runner — tmux runner with captureWide + sendControl
54
+ * @param {string} opts.tmuxName — tmux session name to poll
55
+ * @param {Array<{name:string, regex:RegExp, key:string}>} opts.triggers
56
+ * @param {RegExp} opts.readySignal — match → resolve
57
+ * @param {number} [opts.deadlineMs=30000] — absolute backstop
58
+ * @param {number} [opts.stallMs] — if set, fail after this much
59
+ * wall-clock with NO pane activity (progress-aware). Omit for pure
60
+ * wall-clock behavior.
61
+ * @param {number} [opts.pollMs=300]
62
+ * @param {number} [opts.settleMs=500]
63
+ * @param {string} [opts.timeoutCode='TUI_STARTUP_TIMEOUT']
64
+ * @param {Function} [opts.onTrigger] — (name) => void, called AT FIRE
65
+ * TIME (not gate resolution). Telemetry hung off the success-path return
66
+ * misses the matched-then-died sequence (2026-06-10 prod: gate matched
67
+ * session-age, then TMUX_SESSION_GONE). Errors are swallowed.
68
+ * @param {object} [opts.logger=console]
69
+ * @param {string} [opts.label='startup-gate']
70
+ * @returns {Promise<{matchedTriggers: string[], elapsedMs: number}>}
71
+ */
72
+ async function runStartupGate({
73
+ runner,
74
+ tmuxName,
75
+ triggers = [],
76
+ readySignal,
77
+ deadlineMs = DEFAULT_DEADLINE_MS,
78
+ stallMs,
79
+ pollMs = DEFAULT_POLL_MS,
80
+ settleMs = DEFAULT_SETTLE_MS,
81
+ timeoutCode = 'TUI_STARTUP_TIMEOUT',
82
+ onTrigger = null,
83
+ logger = console,
84
+ label = 'startup-gate',
85
+ } = {}) {
86
+ if (!runner || typeof runner.captureWide !== 'function' || typeof runner.sendControl !== 'function') {
87
+ throw new TypeError('runStartupGate: runner must have captureWide + sendControl');
88
+ }
89
+ if (!tmuxName) throw new TypeError('runStartupGate: tmuxName required');
90
+ if (!(readySignal instanceof RegExp)) {
91
+ throw new TypeError('runStartupGate: readySignal must be a RegExp');
92
+ }
93
+
94
+ const startedAt = Date.now();
95
+ const deadline = startedAt + deadlineMs;
96
+ const stallEnabled = Number.isFinite(stallMs) && stallMs > 0;
97
+ const seen = new Set();
98
+ const matchedTriggers = [];
99
+ // rc.4: remember the most recent successful pane snapshot. If the gate
100
+ // fails (deadline OR session-gone), include the snapshot in the error
101
+ // so we can see what the TUI last printed before claude exited. Without
102
+ // this, "claude exits code 0 after dev-channels Enter" surfaces as a
103
+ // 30-second `can't find pane` spam with no diagnostic about WHY.
104
+ let lastPane = null;
105
+ // Progress-aware gate: timestamp of the last observed pane CHANGE (or
106
+ // trigger send). Only consulted when stallEnabled.
107
+ let lastActivityAt = startedAt;
108
+ // Music incident (2026-06-01): the stall timer must NOT arm while the pane
109
+ // is still BLANK. A blank-and-unchanging pane means claude hasn't started
110
+ // rendering yet (slow cold-start), NOT that it wedged — the TUI for some
111
+ // topics takes 30-45s to first-render. Arming the stall timer on a blank
112
+ // pane killed a legitimate slow spawn at stallMs with a false "wedged".
113
+ // So the stall clock only runs once the pane has shown non-whitespace
114
+ // content; before that, only the absolute `deadlineMs` governs.
115
+ let sawContent = false;
116
+
117
+ while (Date.now() < deadline) {
118
+ // Stall check (progress-aware): the pane RENDERED something and has then
119
+ // been static for stallMs → genuinely wedged. Gated on sawContent so a
120
+ // blank cold-start isn't mistaken for a wedge. Fires early so a truly
121
+ // hung TUI fails fast, while an actively-progressing one (download bar,
122
+ // dialog navigation) keeps resetting lastActivityAt below.
123
+ if (stallEnabled && sawContent && Date.now() - lastActivityAt >= stallMs) {
124
+ const err = new Error(
125
+ `[${label}] startup gate: pane rendered then went static for ${stallMs}ms for ${tmuxName} ` +
126
+ `(matched: ${matchedTriggers.length ? matchedTriggers.join(', ') : 'none'}). ` +
127
+ `Appears wedged. Last pane content:\n` +
128
+ _formatPaneTail(lastPane),
129
+ );
130
+ err.code = timeoutCode;
131
+ err.lastPane = lastPane;
132
+ err.matchedTriggers = matchedTriggers;
133
+ err.reason = 'stall';
134
+ throw err;
135
+ }
136
+ let pane;
137
+ try {
138
+ pane = await runner.captureWide(tmuxName);
139
+ } catch (err) {
140
+ // rc.4: detect "can't find pane" / "no server" — tmux session died
141
+ // (claude exited, killed the bash that hosted it, tmux tore down the
142
+ // pane). Fast-fail with a distinct code instead of spinning for the
143
+ // full deadline. Pattern matches the actual tmux capture-pane errors:
144
+ // - "can't find pane: <name>" (session/pane gone after spawn)
145
+ // - "no server running" (entire tmux server gone)
146
+ const msg = err?.message || '';
147
+ if (/can't find (pane|session)|no server running|session not found/i.test(msg)) {
148
+ const goneErr = new Error(
149
+ `[${label}] tmux session disappeared for ${tmuxName} after ${Date.now() - startedAt}ms ` +
150
+ `(matched: ${matchedTriggers.length ? matchedTriggers.join(', ') : 'none'}). ` +
151
+ `claude likely exited; last pane content:\n` +
152
+ _formatPaneTail(lastPane),
153
+ );
154
+ goneErr.code = 'TMUX_SESSION_GONE';
155
+ goneErr.lastPane = lastPane;
156
+ goneErr.matchedTriggers = matchedTriggers;
157
+ throw goneErr;
158
+ }
159
+ logger.warn?.(`[${label}] captureWide failed: ${msg}`);
160
+ await new Promise(r => setTimeout(r, settleMs));
161
+ continue;
162
+ }
163
+ // First non-whitespace content = the TUI has started rendering. Only
164
+ // from here does the stall timer become meaningful (before this, a blank
165
+ // pane is cold-start, governed by the absolute deadline). Seed
166
+ // lastActivityAt at the moment content first appears so the stall window
167
+ // is measured from "rendered", not from spawn.
168
+ if (!sawContent && pane && pane.trim().length > 0) {
169
+ sawContent = true;
170
+ lastActivityAt = Date.now();
171
+ }
172
+ // Progress signal: any change in pane content is activity → reset the
173
+ // stall clock. A captureWide that returns the SAME bytes is NOT
174
+ // activity (a frozen download bar at 24% reads identically each poll).
175
+ if (pane !== lastPane) lastActivityAt = Date.now();
176
+ lastPane = pane;
177
+
178
+ // Walk triggers in declaration order — first match (and not yet seen) wins
179
+ let matched = false;
180
+ for (const trigger of triggers) {
181
+ if (seen.has(trigger.name)) continue;
182
+ if (!trigger.regex.test(pane)) continue;
183
+ // `keys: [...]` sends a sequence (dialog navigation — e.g. Down,Enter
184
+ // to pick a non-default option); `key:` remains the single-key form.
185
+ // Sequence keys go as separate send-keys calls with a short delay —
186
+ // Ink dialogs can swallow the second key of a same-batch sequence.
187
+ const keySeq = Array.isArray(trigger.keys) ? trigger.keys : [trigger.key];
188
+ for (let ki = 0; ki < keySeq.length; ki++) {
189
+ if (ki > 0) await new Promise(r => setTimeout(r, Math.min(settleMs, 120)));
190
+ try {
191
+ await runner.sendControl(tmuxName, keySeq[ki]);
192
+ } catch (err) {
193
+ logger.warn?.(`[${label}] sendControl(${keySeq[ki]}) failed for trigger=${trigger.name}: ${err.message}`);
194
+ }
195
+ }
196
+ seen.add(trigger.name);
197
+ matchedTriggers.push(trigger.name);
198
+ try { onTrigger?.(trigger.name); } catch { /* telemetry must not break the gate */ }
199
+ matched = true;
200
+ // Sending a key is activity — navigating the TUI counts as progress
201
+ // even if the pre-transition pane text was static (e.g. a dialog we
202
+ // just answered). Reset the stall clock so we don't fail mid-nav.
203
+ lastActivityAt = Date.now();
204
+ // Settle window so the TUI transitions out of the dialog before next poll
205
+ await new Promise(r => setTimeout(r, settleMs));
206
+ break;
207
+ }
208
+ if (matched) continue;
209
+
210
+ if (readySignal.test(pane)) {
211
+ return { matchedTriggers, elapsedMs: Date.now() - startedAt };
212
+ }
213
+
214
+ await new Promise(r => setTimeout(r, pollMs));
215
+ }
216
+
217
+ const err = new Error(
218
+ `[${label}] startup gate did not resolve within ${deadlineMs}ms for ${tmuxName} ` +
219
+ `(matched: ${matchedTriggers.length ? matchedTriggers.join(', ') : 'none'}). ` +
220
+ `Last pane content:\n` +
221
+ _formatPaneTail(lastPane),
222
+ );
223
+ err.code = timeoutCode;
224
+ err.lastPane = lastPane;
225
+ err.matchedTriggers = matchedTriggers;
226
+ throw err;
227
+ }
228
+
229
+ /**
230
+ * Render the last ~800 chars of pane content for inclusion in error messages.
231
+ * Truncate at line boundaries when possible so the diagnostic isn't visually
232
+ * mangled. Returns "(no pane content ever captured)" for null/undefined.
233
+ */
234
+ function _formatPaneTail(pane) {
235
+ if (!pane) return ' (no pane content ever captured — claude exited before first captureWide)';
236
+ const MAX = 800;
237
+ const text = String(pane);
238
+ if (text.length <= MAX) return text.split('\n').map(l => ' ' + l).join('\n');
239
+ // Take last MAX chars, then trim to a line boundary if one exists nearby
240
+ let tail = text.slice(-MAX);
241
+ const nl = tail.indexOf('\n');
242
+ if (nl > 0 && nl < 80) tail = tail.slice(nl + 1);
243
+ return ' …(truncated)…\n' + tail.split('\n').map(l => ' ' + l).join('\n');
244
+ }
245
+
246
+ module.exports = {
247
+ runStartupGate,
248
+ DEFAULT_DEADLINE_MS,
249
+ DEFAULT_POLL_MS,
250
+ DEFAULT_SETTLE_MS,
251
+ };
@@ -0,0 +1,415 @@
1
+ // provenance: polygram@0.17.11 lib/tmux/tmux-runner.js (git 746bca6) — verbatim*: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * Low-level tmux wrapper used by TmuxProcess (`lib/process/tmux-process.js`).
4
+ *
5
+ * Pure mechanics — spawn / send / capture / kill / list. No semantics
6
+ * about claude or polygram. TmuxProcess composes these into the
7
+ * higher-level send-prompt / observe-turn / interrupt flow.
8
+ *
9
+ * Conventions:
10
+ * - Session names are bot-prefixed to avoid cross-bot collision
11
+ * on the same host: `water-<bot>-<chat>-<thread>`.
12
+ * - Prompt bodies go through pasteText() (sanitize + multiline
13
+ * separator + set-buffer/paste-buffer).
14
+ * - Control keys (Enter, Escape, C-c) go through sendControl()
15
+ * using `tmux send-keys` (no -l flag).
16
+ *
17
+ * Phase 0 spike findings encoded here:
18
+ * F-spike-1 --permission-mode acceptEdits handled by callers
19
+ * F-spike-3 `\n` in paste-buffer SPLITS into separate Enter
20
+ * presses → encode as MULTILINE_SEPARATOR before paste
21
+ * F-spike-4 bypassPermissions needs --dangerously-skip-permissions
22
+ * companion (callers add this flag pair when needed)
23
+ * G5b sanitize() strips C0/DEL control bytes so a Telegram
24
+ * user can't inject Ctrl-C / Ctrl-D into the pty
25
+ *
26
+ * @see docs/0.10.0-phase0-spike-findings.md F-spike-1..4
27
+ * @see docs/0.10.0-process-manager-abstraction-plan.md §12.4
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const childProcess = require('child_process');
33
+ const crypto = require('crypto');
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const { createAsyncLock } = require('../async-lock');
37
+
38
+ // ─── Constants ───────────────────────────────────────────────────────
39
+
40
+ // Phase 0 F-spike-3: `\n` in paste-buffer triggers separate Enter
41
+ // presses in claude TUI; only the LAST line stays. Encode as a visible
42
+ // separator before paste so the full multi-line prompt arrives.
43
+ const MULTILINE_SEPARATOR = ' / ';
44
+
45
+ // G5b: strip C0/DEL bytes (0x00-0x08, 0x0b-0x1f, 0x7f) from prompt
46
+ // before send. Allows \t (0x09) and \n (0x0a) through; we handle \n
47
+ // via MULTILINE_SEPARATOR.
48
+ const CONTROL_CHAR_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
49
+
50
+ // 2026-05-18 incident — submit confirmation now lives in TmuxProcess.
51
+ // A ~1-2KB polygram prompt is collapsed by the claude TUI into a
52
+ // "[Pasted text #N]" placeholder; the single post-paste Enter can be
53
+ // absorbed mid-ingest, leaving the prompt unsubmitted.
54
+ //
55
+ // B5 confirmed the submit HERE by capture-pane (does the input box
56
+ // still hold the paste?). That FALSE-POSITIVED: the TUI renders the
57
+ // collapsed-paste placeholder asynchronously, so a capture-pane poll
58
+ // catches a transient frame where the placeholder is not yet visible
59
+ // and B5 wrongly concluded "submitted ✓", leaving the prompt stuck
60
+ // (3rd recurrence: shumorobot msg 803, 2026-05-19). B7 REMOVED the
61
+ // capture-pane confirm — capture-pane is an unreliable signal for a
62
+ // collapsed paste. Submission is now confirmed in `TmuxProcess` by the
63
+ // paste's correlation token surfacing in a JSONL `user-message` (the
64
+ // only reliable "the prompt reached claude" signal). The runner just
65
+ // pastes + Enter; it no longer judges whether the submit landed.
66
+
67
+ // ─── execFile wrapper ────────────────────────────────────────────────
68
+
69
+ // Every tmux invocation polygram makes — capture-pane, send-keys,
70
+ // set-buffer, paste-buffer, has-session, list-sessions, kill-session,
71
+ // and even `new-session -d` (which detaches immediately) — is a
72
+ // sub-second operation. A tmux call that runs longer than this is
73
+ // WEDGED (tmux server hung, host pathologically loaded).
74
+ //
75
+ // Without a bound, a wedged subprocess hangs the `await` forever:
76
+ // `_awaitTurnComplete`'s poll loop re-checks its deadline only
77
+ // BETWEEN `captureWide` calls, so a single hung `capture-pane` stalls
78
+ // the turn with no timeout (leftover R7). The 2026-05-18 submit-
79
+ // confirm loop has the same exposure — it capture-panes too.
80
+ //
81
+ // A per-exec timeout bounds ALL of it: a timed-out tmux call rejects
82
+ // → the caller throws → the turn fails LOUD with an error instead of
83
+ // hanging. killSignal is SIGKILL because a wedged process may ignore
84
+ // SIGTERM. 10s is generous headroom over the sub-second norm.
85
+ const TMUX_RUN_TIMEOUT_MS = 10_000;
86
+
87
+ /**
88
+ * Promise-wrapped childProcess.execFile. Returns { stdout, stderr }.
89
+ * Rejects on non-zero exit (or timeout) with err.stdout + err.stderr
90
+ * attached. A default timeout + SIGKILL bound every tmux call so a
91
+ * wedged subprocess cannot hang a turn (leftover R7); an explicit
92
+ * `opts.timeout` still overrides.
93
+ */
94
+ function run(cmd, args, opts = {}) {
95
+ return new Promise((resolve, reject) => {
96
+ childProcess.execFile(
97
+ cmd,
98
+ args,
99
+ { timeout: TMUX_RUN_TIMEOUT_MS, killSignal: 'SIGKILL', ...opts, encoding: 'utf8' },
100
+ (err, stdout, stderr) => {
101
+ if (err) {
102
+ err.stdout = stdout;
103
+ err.stderr = stderr;
104
+ return reject(err);
105
+ }
106
+ resolve({ stdout, stderr });
107
+ },
108
+ );
109
+ });
110
+ }
111
+
112
+ // ─── Helpers ─────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Strip C0/DEL control characters. Allow \t and \n (\n is then
116
+ * encoded to MULTILINE_SEPARATOR by pasteText below).
117
+ */
118
+ function sanitize(text) {
119
+ return String(text).replace(CONTROL_CHAR_RE, '');
120
+ }
121
+
122
+ /**
123
+ * Bot-prefixed tmux session name. Replaces unsafe chars with _ so
124
+ * the name is always a valid tmux session identifier.
125
+ *
126
+ * @param {string} botName
127
+ * @param {string|number} chatId
128
+ * @param {string|number|null} threadId
129
+ */
130
+ function sessionName(botName, chatId, threadId, prefix = 'orchestra') {
131
+ const tail = threadId ? `${chatId}-${threadId}` : `${chatId}-main`;
132
+ return `${prefix}-${botName}-${tail}`.replace(/[^\w-]/g, '_');
133
+ }
134
+
135
+ /**
136
+ * Per-session debug-log path. Same sanitization as session name so
137
+ * an admin typo in a topic key can't path-traverse.
138
+ *
139
+ * Per R2-F5 the path lives under polygram's own data dir, not /tmp.
140
+ *
141
+ * @param {string} botName
142
+ * @param {string|number} chatId
143
+ * @param {string|number|null} threadId
144
+ * @param {string} [logsDir] base dir; default ~/.water/<bot>/logs
145
+ */
146
+ function debugLogPath(botName, chatId, threadId, logsDir) {
147
+ const safeBot = String(botName).replace(/[^\w-]/g, '_');
148
+ const tail = threadId ? `${chatId}-${threadId}` : `${chatId}-main`;
149
+ const safeTail = String(tail).replace(/[^\w-]/g, '_');
150
+ // SECURITY (audit M4): refuse to fall back to /tmp when HOME is
151
+ // unset. /tmp is world-writable; a co-tenant could pre-create the
152
+ // path as a symlink pointing at an arbitrary file, and claude's
153
+ // --debug-file flag would follow it on open-for-append. Operators
154
+ // running polygram must have HOME set; if not, this is a misconfig
155
+ // that should fail loud.
156
+ if (!logsDir && !process.env.HOME) {
157
+ throw Object.assign(
158
+ new Error('HOME env var unset; refusing /tmp fallback for debugLogPath'),
159
+ { code: 'HOME_UNSET' },
160
+ );
161
+ }
162
+ const base = logsDir || path.join(process.env.HOME, '.orchestra', safeBot, 'logs');
163
+ return path.join(base, `tmux-claude-${safeTail}.log`);
164
+ }
165
+
166
+ /**
167
+ * Ensure the directory exists for a debug log path. Idempotent.
168
+ */
169
+ function ensureLogDir(logPath) {
170
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
171
+ }
172
+
173
+ // ─── TmuxRunner ──────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Construct a tmux runner. Returns an object of methods. Stateless —
177
+ * each call is an independent tmux invocation. The shared `logger` is
178
+ * the only injected dependency.
179
+ *
180
+ * Test seam: tests can stub `runner._run` to mock execFile.
181
+ *
182
+ * @param {object} [opts]
183
+ * @param {object} [opts.logger=console]
184
+ * @param {Function} [opts.runFn] — override the underlying execFile
185
+ * wrapper (for tests). Same signature: (cmd, args, opts?) → Promise.
186
+ */
187
+ function createTmuxRunner({
188
+ sessionPrefix = 'orchestra',
189
+ logger = console,
190
+ runFn = run,
191
+ } = {}) {
192
+ const runnerPrefix = sessionPrefix;
193
+ async function spawn({
194
+ name,
195
+ cwd,
196
+ command,
197
+ args = [],
198
+ envExtras = {},
199
+ paneWidth = 200,
200
+ }) {
201
+ // SECURITY (audit M5): paneWidth ends up as a CLI arg to `tmux
202
+ // set-option`. We use execFile (no shell), so this is not a
203
+ // shell-injection vector — but a hostile value like '-Force' or
204
+ // a number-string with embedded options could be mis-parsed by
205
+ // tmux. Validate it's a small positive integer.
206
+ if (!Number.isInteger(paneWidth) || paneWidth < 20 || paneWidth > 10_000) {
207
+ throw new TypeError(`paneWidth must be an integer in [20, 10000], got ${paneWidth}`);
208
+ }
209
+ const sessArgs = ['new-session', '-d', '-s', name];
210
+ if (cwd) sessArgs.push('-c', cwd);
211
+ for (const [k, v] of Object.entries(envExtras)) {
212
+ sessArgs.push('-e', `${k}=${v}`);
213
+ }
214
+ sessArgs.push(command, ...args);
215
+ try {
216
+ await runFn('tmux', sessArgs);
217
+ } catch (err) {
218
+ throw Object.assign(new Error(`tmux spawn failed: ${err.message}`), {
219
+ code: 'TMUX_SPAWN_FAILED',
220
+ name,
221
+ cause: err,
222
+ stderr: err.stderr,
223
+ });
224
+ }
225
+ // Try to widen the detached pane so claude TUI has room to render
226
+ // long lines. `resize-window` is the supported way; older
227
+ // attempts used a non-existent `pane-width` option that always
228
+ // errored (tmux 3.x: pane-width is a format variable, not a
229
+ // settable option). capture-pane -J in captureWide() handles
230
+ // any remaining wrap artifacts.
231
+ try {
232
+ await runFn('tmux', ['resize-window', '-t', name, '-x', String(paneWidth)]);
233
+ } catch (err) {
234
+ logger.debug?.(`[tmux-runner] resize-window failed for ${name}: ${err.message} (capture-pane -J handles wrap)`);
235
+ }
236
+ return name;
237
+ }
238
+
239
+ /**
240
+ * Send raw key sequence (Enter, Escape, C-c, etc.). NOT for prompt
241
+ * body — use pasteText for that. send-keys without -l interprets
242
+ * key names like "Enter" and "C-c".
243
+ */
244
+ async function sendControl(name, key) {
245
+ await runFn('tmux', ['send-keys', '-t', name, key]);
246
+ }
247
+
248
+ // rc.13.1: paste+Enter must be ATOMIC per session. Pre-rc.13.1 two
249
+ // concurrent pasteText+sendControl pairs could interleave in the
250
+ // TUI's bracketed-paste buffer — Ivan caught this on shumorobot
251
+ // 2026-05-15 (the 2233-char user JSONL entry contained one
252
+ // truncated polygram channel + a full nested polygram prompt for
253
+ // a different msg_id). Symptom: msg 696's paste was at byte
254
+ // `chat_id="-1003` when msg 698's autosteer paste cut in,
255
+ // concatenating two pastes into one TUI user message → the agent
256
+ // saw a malformed input → the reply attribution went sideways
257
+ // (msg 697 got msg 698's answer, msg 696 got served last).
258
+ //
259
+ // The async-lock is keyed by tmux session name, so different
260
+ // sessions don't block each other. Within one session, pasteText
261
+ // + sendControl(Enter) hold the lock atomically.
262
+ const inputLock = createAsyncLock();
263
+ /**
264
+ * Paste a prompt body + press Enter, atomically per session.
265
+ *
266
+ * Pure mechanics: paste, Enter, a small post-Enter drain. The runner
267
+ * does NOT judge whether the submit landed — B7 moved submit
268
+ * confirmation to `TmuxProcess`, which confirms it via the paste's
269
+ * correlation token surfacing in a JSONL `user-message` (the only
270
+ * reliable signal; capture-pane false-positives on a collapsed
271
+ * `[Pasted text #N]` placeholder).
272
+ *
273
+ * @param {string} name tmux session
274
+ * @param {string} text prompt body
275
+ */
276
+ async function pasteAndEnter(name, text) {
277
+ const release = await inputLock.acquire(name);
278
+ try {
279
+ const res = await pasteText(name, text);
280
+ await sendControl(name, 'Enter');
281
+ // L3 fix: small post-Enter drain so back-to-back
282
+ // pasteAndEnter calls don't race in the claude TUI's input
283
+ // handler. Pre-fix, when two injectUserMessage calls fired in
284
+ // quick succession (spike multi-2-rapid, ~2/5 failure rate),
285
+ // the TUI sometimes only enqueued ONE of the two pastes —
286
+ // the second's bracketed-paste-start collided with the first
287
+ // Enter's processing. 50ms is enough on the TUI we tested
288
+ // against (claude v2.1.142); see AGENTS.md pinned version.
289
+ await new Promise((r) => setTimeout(r, 50));
290
+ return res;
291
+ } finally {
292
+ release();
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Push a multi-line text prompt into the pane.
298
+ *
299
+ * 1. sanitize() strips C0/DEL bytes (G5b)
300
+ * 2. \n → MULTILINE_SEPARATOR (F-spike-3)
301
+ * 3. set-buffer + paste-buffer (atomic; bracketed-paste-aware
302
+ * in modern claude TUI versions)
303
+ * 4. brief drain delay so a subsequent send-keys (e.g. Enter) is
304
+ * processed as a key event by the TUI, NOT consumed as part of
305
+ * the paste's bracketed-paste content.
306
+ *
307
+ * INCIDENT (0.10.0-rc.2): without the drain delay, send-keys Enter
308
+ * fired immediately after paste-buffer was being swallowed by
309
+ * claude TUI's bracketed-paste handler — the paste sat in the input
310
+ * area unsubmitted. Manual `tmux send-keys ... Enter` unstuck it.
311
+ * 80ms is enough on macOS tmux 3.6a for the close-bracket ESC[201~
312
+ * to land before any subsequent key arrives.
313
+ *
314
+ * NO Enter is sent here. Caller follows up with
315
+ * `sendControl(name, 'Enter')` when they want to submit.
316
+ */
317
+ async function pasteText(name, text) {
318
+ const sanitized = sanitize(text);
319
+ const oneLine = sanitized.replace(/\r?\n/g, MULTILINE_SEPARATOR);
320
+ const bufName = `${runnerPrefix}-buf-${crypto.randomBytes(3).toString('hex')}`;
321
+ await runFn('tmux', ['set-buffer', '-b', bufName, oneLine]);
322
+ try {
323
+ // -d (delete after) so the buffer doesn't accumulate.
324
+ await runFn('tmux', ['paste-buffer', '-t', name, '-b', bufName, '-d']);
325
+ } catch (err) {
326
+ // Best-effort buffer cleanup if paste fails.
327
+ await runFn('tmux', ['delete-buffer', '-b', bufName]).catch(() => {});
328
+ throw err;
329
+ }
330
+ // Drain delay — see incident note above.
331
+ await new Promise((r) => setTimeout(r, 80));
332
+ return { sanitized, oneLine, stripped: text.length - sanitized.length };
333
+ }
334
+
335
+ /**
336
+ * Capture pane content. By default returns the last 1000 lines with
337
+ * line-wrapped lines joined (`-J`) — handles wrapping artifacts
338
+ * regardless of pane-width setting.
339
+ *
340
+ * For frequent polling (ready/streaming/approval-prompt detection),
341
+ * pass a smaller `lines` value — the indicators all live in the
342
+ * bottom ~50 lines of the pane. Polling 1000 lines each poll spawns
343
+ * a heavier tmux capture subprocess unnecessarily.
344
+ */
345
+ async function capturePane(name, { lines = 1000, joinWrapped = true } = {}) {
346
+ const args = ['capture-pane', '-t', name, '-p'];
347
+ if (joinWrapped) args.push('-J');
348
+ args.push('-S', `-${lines}`);
349
+ const { stdout } = await runFn('tmux', args);
350
+ return stdout;
351
+ }
352
+
353
+ /**
354
+ * Wide capture — alias for capturePane with -J always on. Use when
355
+ * regex parsing is sensitive to line wrapping.
356
+ */
357
+ async function captureWide(name, opts = {}) {
358
+ return capturePane(name, { ...opts, joinWrapped: true });
359
+ }
360
+
361
+ async function sessionExists(name) {
362
+ try {
363
+ await runFn('tmux', ['has-session', '-t', name]);
364
+ return true;
365
+ } catch {
366
+ return false;
367
+ }
368
+ }
369
+
370
+ async function killSession(name) {
371
+ await runFn('tmux', ['kill-session', '-t', name]).catch(() => {});
372
+ }
373
+
374
+ /**
375
+ * List polygram-managed tmux sessions on the host. Optional `botName`
376
+ * narrows the prefix; without it returns all `water-*` sessions.
377
+ */
378
+ async function listPolygramSessions(botName = null) {
379
+ try {
380
+ const { stdout } = await runFn('tmux', ['list-sessions', '-F', '#{session_name}']);
381
+ const all = stdout.trim().split('\n').filter(Boolean);
382
+ const prefix = botName ? `${runnerPrefix}-${String(botName).replace(/[^\w-]/g, '_')}-` : `${runnerPrefix}-`;
383
+ return all.filter((n) => n.startsWith(prefix));
384
+ } catch {
385
+ return [];
386
+ }
387
+ }
388
+
389
+ return {
390
+ spawn,
391
+ sendControl,
392
+ pasteText,
393
+ pasteAndEnter,
394
+ capturePane,
395
+ captureWide,
396
+ sessionExists,
397
+ killSession,
398
+ listPolygramSessions,
399
+ sessionName: (b, c, t) => sessionName(b, c, t, sessionPrefix),
400
+ debugLogPath,
401
+ ensureLogDir,
402
+ sanitize,
403
+ // Test hook
404
+ _run: runFn,
405
+ };
406
+ }
407
+
408
+ module.exports = {
409
+ createTmuxRunner,
410
+ sessionName,
411
+ debugLogPath,
412
+ sanitize,
413
+ MULTILINE_SEPARATOR,
414
+ CONTROL_CHAR_RE,
415
+ };