polygram 0.17.10 → 0.17.12

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