polygram 0.10.0-rc.22 → 0.10.0-rc.24
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/process/tmux-process.js +64 -4
- package/lib/tmux/tmux-runner.js +122 -3
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.10.0-rc.
|
|
4
|
+
"version": "0.10.0-rc.24",
|
|
5
5
|
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
|
@@ -563,7 +563,15 @@ class TmuxProcess extends Process {
|
|
|
563
563
|
// rc.13.1: pasteAndEnter holds a per-session async lock around
|
|
564
564
|
// paste + Enter so a concurrent injectUserMessage paste cannot
|
|
565
565
|
// interleave keystrokes with this primary prompt.
|
|
566
|
-
|
|
566
|
+
//
|
|
567
|
+
// confirmSubmit: this is a PRIMARY turn pasting into an idle TUI
|
|
568
|
+
// (the previous turn finished before _runTurn drained this one).
|
|
569
|
+
// A production polygram prompt is ~1-2KB → the TUI collapses it
|
|
570
|
+
// into a bracketed-paste block whose single Enter can be
|
|
571
|
+
// absorbed (2026-05-18 incident). confirmSubmit re-sends Enter
|
|
572
|
+
// until the input box clears, or fails loud.
|
|
573
|
+
const result = await this._pasteAndEnter(
|
|
574
|
+
this._embedToken(turn.prompt, turn.token), { confirmSubmit: true });
|
|
567
575
|
if (result.stripped > 0) {
|
|
568
576
|
this.logger.warn?.(
|
|
569
577
|
`[${this.label}] stripped ${result.stripped} control chars from prompt`,
|
|
@@ -1283,13 +1291,18 @@ class TmuxProcess extends Process {
|
|
|
1283
1291
|
* asynchronously (on confirm/timeout) so it gates only the NEXT
|
|
1284
1292
|
* paste, never delays this caller.
|
|
1285
1293
|
*/
|
|
1286
|
-
async _pasteAndEnter(text) {
|
|
1294
|
+
async _pasteAndEnter(text, { confirmSubmit = false } = {}) {
|
|
1287
1295
|
const token = this._extractTokens(text)[0] || null;
|
|
1288
1296
|
const release = await this._pasteLock.acquire(this.tmuxName);
|
|
1289
1297
|
let result;
|
|
1290
1298
|
try {
|
|
1291
1299
|
if (typeof this.runner.pasteAndEnter === 'function') {
|
|
1292
|
-
|
|
1300
|
+
// 2026-05-18 incident: confirmSubmit re-sends Enter if a large
|
|
1301
|
+
// bracketed paste didn't submit. ONLY for a primary turn's
|
|
1302
|
+
// paste into an idle TUI — never for an autosteer paste into a
|
|
1303
|
+
// mid-turn TUI (the TUI parks that in its own queue; the input
|
|
1304
|
+
// box holding it is expected, not a failed submit).
|
|
1305
|
+
result = await this.runner.pasteAndEnter(this.tmuxName, text, { confirmSubmit });
|
|
1293
1306
|
} else {
|
|
1294
1307
|
result = await this.runner.pasteText(this.tmuxName, text);
|
|
1295
1308
|
await this.runner.sendControl(this.tmuxName, 'Enter');
|
|
@@ -1350,6 +1363,40 @@ class TmuxProcess extends Process {
|
|
|
1350
1363
|
async _waitForReady() {
|
|
1351
1364
|
const deadline = this._now() + this.readyTimeoutMs;
|
|
1352
1365
|
let lastBuf = '';
|
|
1366
|
+
// B6 (shumorobot 2026-05-18, Music topic, twice): a slow
|
|
1367
|
+
// custom-agent spawn (`music-curation:music-curator` loading
|
|
1368
|
+
// several MCP servers) leaves the claude TUI mid-startup for
|
|
1369
|
+
// SECONDS — the production debug log shows MCP connections
|
|
1370
|
+
// spanning 14:45:31→14:45:40 (playwright 2.9s, context7 3.1s,
|
|
1371
|
+
// serena 3.8s, …) with the screen repainting hard the whole time
|
|
1372
|
+
// ("High write ratio: 100.0% writes"). Throughout that window the
|
|
1373
|
+
// TUI ALREADY renders its ready hint (`? for shortcuts` /
|
|
1374
|
+
// `bypass permissions on`) at the bottom of its startup banner.
|
|
1375
|
+
//
|
|
1376
|
+
// The old `_waitForReady` returned the INSTANT `READY_HINTS_RE`
|
|
1377
|
+
// matched — i.e. on the first poll, while MCP servers were still
|
|
1378
|
+
// loading. `start()` resolved early; the first `send()` pasted
|
|
1379
|
+
// the prompt into a TUI still ingesting startup, and the
|
|
1380
|
+
// submitted Enter was dropped → the prompt sat unsubmitted → the
|
|
1381
|
+
// turn never began (a fake THINKING→STALL followed). On a
|
|
1382
|
+
// no-agent TUI the startup window is sub-poll so it never bit; a
|
|
1383
|
+
// slow custom-agent spawn opens a multi-second window every time.
|
|
1384
|
+
//
|
|
1385
|
+
// The banner is NOT a usable "not ready" signal — it stays on the
|
|
1386
|
+
// pane indefinitely (the agent emits nothing pre-turn, so it
|
|
1387
|
+
// never scrolls into scrollback). The real discriminator is
|
|
1388
|
+
// QUIESCENCE: a genuinely-ready idle TUI produces a BYTE-STABLE
|
|
1389
|
+
// `capture-pane` between polls (static banner + empty input box +
|
|
1390
|
+
// ready hint), whereas a mid-startup TUI repaints every tick.
|
|
1391
|
+
//
|
|
1392
|
+
// Fix: require the ready hint to be present AND the captured pane
|
|
1393
|
+
// to be UNCHANGED across consecutive polls for `quiesceMs`
|
|
1394
|
+
// continuously before declaring ready — the same "must hold this
|
|
1395
|
+
// long" idea `_awaitTurnComplete` already uses for turn
|
|
1396
|
+
// completion. Bounded by `readyTimeoutMs`, so a genuinely wedged
|
|
1397
|
+
// spawn still throws TMUX_READY_TIMEOUT.
|
|
1398
|
+
let readySinceAt = null; // when the (hint + stable-pane) state began
|
|
1399
|
+
let prevBuf = null; // last poll's capture, for the stability compare
|
|
1353
1400
|
if (this.pollScheduler) this.pollScheduler.acquire();
|
|
1354
1401
|
try {
|
|
1355
1402
|
while (this._now() < deadline) {
|
|
@@ -1357,7 +1404,20 @@ class TmuxProcess extends Process {
|
|
|
1357
1404
|
// pane. Polling 1000 lines each tick is wasteful — cap at 80
|
|
1358
1405
|
// for a ~12× cheaper tmux subprocess.
|
|
1359
1406
|
lastBuf = await this.runner.captureWide(this.tmuxName, { lines: 80 });
|
|
1360
|
-
|
|
1407
|
+
// Ready ⇔ the hint is on the pane AND the pane is identical to
|
|
1408
|
+
// the previous poll (the MCP-loading repaint storm has
|
|
1409
|
+
// stopped). The first poll has no previous buffer to compare,
|
|
1410
|
+
// so it can never satisfy stability — readiness needs at
|
|
1411
|
+
// least two matching captures, which is the point.
|
|
1412
|
+
const hintPresent = READY_HINTS_RE.test(lastBuf);
|
|
1413
|
+
const paneStable = prevBuf !== null && lastBuf === prevBuf;
|
|
1414
|
+
if (hintPresent && paneStable) {
|
|
1415
|
+
if (readySinceAt == null) readySinceAt = this._now();
|
|
1416
|
+
if (this._now() - readySinceAt >= this.quiesceMs) return;
|
|
1417
|
+
} else {
|
|
1418
|
+
readySinceAt = null; // pane moved / hint gone → reset the clock
|
|
1419
|
+
}
|
|
1420
|
+
prevBuf = lastBuf;
|
|
1361
1421
|
await this._waitForNextTick();
|
|
1362
1422
|
}
|
|
1363
1423
|
} finally {
|
package/lib/tmux/tmux-runner.js
CHANGED
|
@@ -46,6 +46,62 @@ const MULTILINE_SEPARATOR = ' / ';
|
|
|
46
46
|
// via MULTILINE_SEPARATOR.
|
|
47
47
|
const CONTROL_CHAR_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
48
48
|
|
|
49
|
+
// 2026-05-18 incident: a ~1.2KB polygram prompt is collapsed by the
|
|
50
|
+
// claude TUI into a bracketed-paste block rendered as "[Pasted text
|
|
51
|
+
// #1]". A single Enter sent after the fixed paste drain does NOT
|
|
52
|
+
// submit it — the Enter is absorbed while the TUI is still ingesting
|
|
53
|
+
// the block, so the prompt sits unsubmitted in the input box and the
|
|
54
|
+
// turn never starts. `pasteAndEnter` confirms the submit landed by
|
|
55
|
+
// capture-pane and re-sends Enter if it didn't.
|
|
56
|
+
|
|
57
|
+
// The claude TUI draws its input box bracketed by horizontal-rule
|
|
58
|
+
// lines (long runs of the box-drawing char `─`). The input prompt is
|
|
59
|
+
// a `❯`-prefixed line BETWEEN those rules. CRUCIALLY, a SUBMITTED
|
|
60
|
+
// message is ALSO echoed with a `❯` prefix up in the conversation
|
|
61
|
+
// area — so a naive "any ❯ line with paste content" check
|
|
62
|
+
// false-positives on the echo of a just-submitted prompt. The input
|
|
63
|
+
// box must be identified structurally: it is the `❯` line adjacent
|
|
64
|
+
// to a horizontal rule.
|
|
65
|
+
const TUI_RULE_RE = /^[^\S\n]*─{20,}[^\S\n]*$/;
|
|
66
|
+
|
|
67
|
+
// Markers that mean "this line is holding an un-submitted paste":
|
|
68
|
+
// the bracketed-paste collapse marker, or a polygram-prompt opening
|
|
69
|
+
// tag (polygram only ever sends those as a paste body).
|
|
70
|
+
const HELD_PASTE_RE = /\[Pasted text|<polygram-info|<session-context|<channel\b/;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 2026-05-18 incident detector. Returns true when the claude TUI's
|
|
74
|
+
* INPUT BOX still holds an un-submitted paste — i.e. the prompt was
|
|
75
|
+
* pasted but the Enter did not submit it.
|
|
76
|
+
*
|
|
77
|
+
* Identifies the input box structurally: the `❯`-prefixed line that
|
|
78
|
+
* is adjacent to a horizontal-rule line. That distinguishes the live
|
|
79
|
+
* input box from the `❯`-prefixed echo of an already-submitted
|
|
80
|
+
* message in the conversation area (which would otherwise
|
|
81
|
+
* false-positive). An empty input box (`❯ ` with nothing after it)
|
|
82
|
+
* returns false — that is a clean, submitted state.
|
|
83
|
+
*/
|
|
84
|
+
function inputBoxHoldsPaste(pane) {
|
|
85
|
+
const lines = String(pane || '').split('\n');
|
|
86
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
87
|
+
const m = lines[i].match(/^[^\S\n]*❯[^\S\n]?(.*)$/);
|
|
88
|
+
if (!m) continue;
|
|
89
|
+
// Adjacent to a horizontal rule above or below ⇒ this is the
|
|
90
|
+
// input box, not a submitted-message echo.
|
|
91
|
+
const ruleAbove = i > 0 && TUI_RULE_RE.test(lines[i - 1]);
|
|
92
|
+
const ruleBelow = i + 1 < lines.length && TUI_RULE_RE.test(lines[i + 1]);
|
|
93
|
+
if (!ruleAbove && !ruleBelow) continue;
|
|
94
|
+
if (HELD_PASTE_RE.test(m[1])) return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Submit-confirmation defaults — overridable per construction for
|
|
100
|
+
// tests. `retries` extra Enter presses after the first; `pollMs` the
|
|
101
|
+
// settle wait before each capture-pane check; `drainMs` the wait
|
|
102
|
+
// after a re-sent Enter before re-checking.
|
|
103
|
+
const DEFAULT_SUBMIT_CONFIRM = { retries: 4, pollMs: 200, drainMs: 250 };
|
|
104
|
+
|
|
49
105
|
// ─── execFile wrapper ────────────────────────────────────────────────
|
|
50
106
|
|
|
51
107
|
/**
|
|
@@ -139,8 +195,15 @@ function ensureLogDir(logPath) {
|
|
|
139
195
|
* @param {object} [opts.logger=console]
|
|
140
196
|
* @param {Function} [opts.runFn] — override the underlying execFile
|
|
141
197
|
* wrapper (for tests). Same signature: (cmd, args, opts?) → Promise.
|
|
198
|
+
* @param {object} [opts.submitConfirm] — large-paste submit-confirm
|
|
199
|
+
* tunables { retries, pollMs, drainMs }; tests shorten these.
|
|
142
200
|
*/
|
|
143
|
-
function createTmuxRunner({
|
|
201
|
+
function createTmuxRunner({
|
|
202
|
+
logger = console,
|
|
203
|
+
runFn = run,
|
|
204
|
+
submitConfirm = {},
|
|
205
|
+
} = {}) {
|
|
206
|
+
const submitCfg = { ...DEFAULT_SUBMIT_CONFIRM, ...submitConfirm };
|
|
144
207
|
|
|
145
208
|
async function spawn({
|
|
146
209
|
name,
|
|
@@ -212,7 +275,24 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
|
|
|
212
275
|
// sessions don't block each other. Within one session, pasteText
|
|
213
276
|
// + sendControl(Enter) hold the lock atomically.
|
|
214
277
|
const inputLock = createAsyncLock();
|
|
215
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Paste a prompt body + press Enter, atomically per session.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} name tmux session
|
|
282
|
+
* @param {string} text prompt body
|
|
283
|
+
* @param {object} [opts]
|
|
284
|
+
* @param {boolean} [opts.confirmSubmit=false] — when true, CONFIRM
|
|
285
|
+
* the Enter actually submitted the prompt (capture-pane the input
|
|
286
|
+
* box, re-send Enter if it still holds the paste, throw if it
|
|
287
|
+
* never clears). Use ONLY for a primary turn's paste into an
|
|
288
|
+
* IDLE TUI — the 2026-05-18 large-bracketed-paste bug. Must be
|
|
289
|
+
* FALSE for an autosteer paste into a BUSY (mid-turn) TUI: there
|
|
290
|
+
* the TUI legitimately parks the paste in its own input queue
|
|
291
|
+
* (`queue-operation enqueue`), so the input box holding the paste
|
|
292
|
+
* is EXPECTED, not a failed submit — re-sending Enter into a
|
|
293
|
+
* streaming TUI would corrupt the queue.
|
|
294
|
+
*/
|
|
295
|
+
async function pasteAndEnter(name, text, { confirmSubmit = false } = {}) {
|
|
216
296
|
const release = await inputLock.acquire(name);
|
|
217
297
|
try {
|
|
218
298
|
const res = await pasteText(name, text);
|
|
@@ -226,7 +306,46 @@ function createTmuxRunner({ logger = console, runFn = run } = {}) {
|
|
|
226
306
|
// Enter's processing. 50ms is enough on the TUI we tested
|
|
227
307
|
// against (claude v2.1.142); see AGENTS.md pinned version.
|
|
228
308
|
await new Promise((r) => setTimeout(r, 50));
|
|
229
|
-
|
|
309
|
+
|
|
310
|
+
// 2026-05-18 incident: a large (~1.2KB) bracketed paste is NOT
|
|
311
|
+
// submitted by that single Enter — the Enter is absorbed while
|
|
312
|
+
// the TUI is still ingesting the "[Pasted text #1]" block. The
|
|
313
|
+
// fixed drain is a timing guess that fails for big pastes.
|
|
314
|
+
// CONFIRM the submit landed by capture-pane; if the input box
|
|
315
|
+
// still holds the paste, re-send Enter (bounded retries). If it
|
|
316
|
+
// never clears, throw — pasteAndEnter must not report success
|
|
317
|
+
// for an unsubmitted prompt (a fake THINKING state otherwise).
|
|
318
|
+
//
|
|
319
|
+
// ONLY for a primary paste into an idle TUI (confirmSubmit). An
|
|
320
|
+
// autosteer paste into a mid-turn TUI is DELIBERATELY parked in
|
|
321
|
+
// the TUI's input queue — the input box holding it is correct,
|
|
322
|
+
// not a failure; re-sending Enter there would corrupt the queue.
|
|
323
|
+
if (!confirmSubmit) return res;
|
|
324
|
+
for (let attempt = 0; attempt <= submitCfg.retries; attempt += 1) {
|
|
325
|
+
await new Promise((r) => setTimeout(r, submitCfg.pollMs));
|
|
326
|
+
let pane;
|
|
327
|
+
try {
|
|
328
|
+
pane = await capturePane(name, { lines: 60 });
|
|
329
|
+
} catch (err) {
|
|
330
|
+
// capture-pane itself failed — can't confirm. Don't claim
|
|
331
|
+
// success; surface it.
|
|
332
|
+
throw Object.assign(
|
|
333
|
+
new Error(`pasteAndEnter: submit confirmation failed: ${err.message}`),
|
|
334
|
+
{ code: 'TMUX_SUBMIT_FAILED', cause: err },
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (!inputBoxHoldsPaste(pane)) return res; // submitted ✓
|
|
338
|
+
if (attempt === submitCfg.retries) break; // out of retries
|
|
339
|
+
// Still stuck — the paste sits in the input box. Re-send
|
|
340
|
+
// Enter and give the TUI a moment before re-checking.
|
|
341
|
+
logger.debug?.(`[tmux-runner] ${name}: paste not submitted, re-sending Enter (attempt ${attempt + 1})`);
|
|
342
|
+
await sendControl(name, 'Enter');
|
|
343
|
+
await new Promise((r) => setTimeout(r, submitCfg.drainMs));
|
|
344
|
+
}
|
|
345
|
+
throw Object.assign(
|
|
346
|
+
new Error(`pasteAndEnter: prompt never submitted after ${submitCfg.retries + 1} Enter attempts`),
|
|
347
|
+
{ code: 'TMUX_SUBMIT_FAILED', tmuxName: name },
|
|
348
|
+
);
|
|
230
349
|
} finally {
|
|
231
350
|
release();
|
|
232
351
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.10.0-rc.
|
|
3
|
+
"version": "0.10.0-rc.24",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc/client.js",
|
|
6
6
|
"bin": {
|