polygram 0.17.11 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/error/classify.js +14 -0
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +188 -41
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- package/lib/tmux/tmux-runner.js +0 -412
package/lib/tmux/startup-gate.js
DELETED
|
@@ -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
|
-
};
|
package/lib/tmux/tmux-runner.js
DELETED
|
@@ -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
|
-
};
|