polygram 0.10.0-rc.24 → 0.10.0-rc.26
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/config.example.json +4 -2
- package/lib/process/tmux-process.js +247 -22
- package/lib/tmux/tmux-runner.js +60 -121
- 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.26",
|
|
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",
|
package/config.example.json
CHANGED
|
@@ -98,7 +98,8 @@
|
|
|
98
98
|
"cwd": "/Users/you/admin-agent",
|
|
99
99
|
"requireMention": true,
|
|
100
100
|
"isolateTopics": true,
|
|
101
|
-
"_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
|
|
101
|
+
"_comment_topics": "rc.48: each topic entry is EITHER a string (legacy: just a label) OR an object with optional fields {name, agent, cwd, model, effort, permissionMode, isolateUserConfig}. Object form lets a topic override chat-level config. Per-topic permissionMode overrides chat-level — typical use: scope one topic to permissionMode:'default' (so settings.json gates apply) while the rest of the chat stays on bypassPermissions. Object form requires isolateTopics: true (each topic gets its own SDK Query); polygram emits a startup warning otherwise.",
|
|
102
|
+
"_comment_isolateUserConfig": "0.10.0, tmux backend only: isolateUserConfig:true spawns the topic's claude TUI cut off from the user-level ~/.claude config — passes --strict-mcp-config (zero MCP servers load) and --setting-sources project,local (drops ~/.claude/settings.json; the spawn cwd's own .claude/settings.json still loads). Use it when a topic's agent would otherwise inherit slow user-global MCP servers whose cold-start (tens of seconds) wedges the TUI before it can accept a prompt. Settable at chat OR topic level (topic wins). Default false.",
|
|
102
103
|
"topics": {
|
|
103
104
|
"100": "Customer A",
|
|
104
105
|
"200": {
|
|
@@ -107,7 +108,8 @@
|
|
|
107
108
|
"cwd": "/Users/you/customer-b-projects",
|
|
108
109
|
"model": "opus",
|
|
109
110
|
"effort": "high",
|
|
110
|
-
"permissionMode": "default"
|
|
111
|
+
"permissionMode": "default",
|
|
112
|
+
"isolateUserConfig": true
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
},
|
|
@@ -210,6 +210,12 @@ class TmuxProcess extends Process {
|
|
|
210
210
|
queueCap = 50, // P0.1 parity: SDK enforces queueCap=50 too
|
|
211
211
|
pollScheduler = null, // O1 optimization: shared cross-process tick
|
|
212
212
|
pasteConfirmMs = 2500, // Phase 3 §5: paste-gating JSONL-confirm timeout
|
|
213
|
+
// B7: a primary paste's submit is confirmed by its correlation
|
|
214
|
+
// token surfacing in a JSONL `user-message`. submitConfirmMs is the
|
|
215
|
+
// per-attempt wait for that line; submitConfirmRetries extra Enter
|
|
216
|
+
// presses before giving up loud.
|
|
217
|
+
submitConfirmMs = 1500,
|
|
218
|
+
submitConfirmRetries = 4,
|
|
213
219
|
} = {}) {
|
|
214
220
|
super({ sessionKey, chatId, threadId, label });
|
|
215
221
|
if (!runner) throw new TypeError('TmuxProcess: runner required');
|
|
@@ -309,6 +315,24 @@ class TmuxProcess extends Process {
|
|
|
309
315
|
this._pasteLock = createAsyncLock();
|
|
310
316
|
this._pasteConfirms = new Map(); // token → resolve fn
|
|
311
317
|
this.pasteConfirmMs = pasteConfirmMs;
|
|
318
|
+
|
|
319
|
+
// ─── B7: JSONL-token submit confirmation ──────────────────────
|
|
320
|
+
// shumorobot msgs 789 / 791 / 803: a large primary prompt collapses
|
|
321
|
+
// in the claude TUI into a `[Pasted text #N]` placeholder; the
|
|
322
|
+
// single post-paste Enter is absorbed mid-ingest and the prompt
|
|
323
|
+
// sits UNSUBMITTED — the turn never starts. B5 tried to confirm the
|
|
324
|
+
// submit by capture-pane, but the TUI renders the collapsed paste
|
|
325
|
+
// asynchronously, so capture-pane false-positives ("box looks
|
|
326
|
+
// clear → submitted ✓") on a transient frame. The ONLY reliable
|
|
327
|
+
// "the prompt reached claude" signal is the JSONL `user-message`
|
|
328
|
+
// line that reproduces THIS paste's correlation token verbatim.
|
|
329
|
+
// `_submitConfirms` maps token → resolve fn, fired ONLY by a
|
|
330
|
+
// `user-message` (NOT by `queue-operation enqueue` — an enqueue
|
|
331
|
+
// means the paste was parked in the TUI queue, not registered as a
|
|
332
|
+
// turn).
|
|
333
|
+
this._submitConfirms = new Map(); // token → resolve fn
|
|
334
|
+
this.submitConfirmMs = submitConfirmMs;
|
|
335
|
+
this.submitConfirmRetries = submitConfirmRetries;
|
|
312
336
|
}
|
|
313
337
|
|
|
314
338
|
get cost() { return 3; }
|
|
@@ -323,7 +347,7 @@ class TmuxProcess extends Process {
|
|
|
323
347
|
*
|
|
324
348
|
* @param {object} ctx
|
|
325
349
|
* @param {string|null} [ctx.existingSessionId] — for --resume
|
|
326
|
-
* @param {object} [ctx.chatConfig={}] — supplies model, effort, cwd, agent, permissionMode
|
|
350
|
+
* @param {object} [ctx.chatConfig={}] — supplies model, effort, cwd, agent, permissionMode, isolateUserConfig
|
|
327
351
|
* @param {string} [ctx.model] — override (rare; e.g. tests)
|
|
328
352
|
* @param {string} [ctx.effort] — override
|
|
329
353
|
* @param {string} [ctx.cwd] — override
|
|
@@ -354,6 +378,21 @@ class TmuxProcess extends Process {
|
|
|
354
378
|
const cwd = ctx.cwd || topicConfig.cwd || chatConfig.cwd;
|
|
355
379
|
const agent = topicConfig.agent || chatConfig.agent;
|
|
356
380
|
const permissionMode = topicConfig.permissionMode || chatConfig.permissionMode || 'acceptEdits';
|
|
381
|
+
// `isolateUserConfig` (topic- or chat-level, topic wins — same
|
|
382
|
+
// merge path as agent/cwd/permissionMode). When true, the spawned
|
|
383
|
+
// claude TUI is cut off from the user-level `~/.claude` config:
|
|
384
|
+
// no user-level MCP servers, plugins, or settings load. Decided
|
|
385
|
+
// fix for the Music topic incident — the music-curation agent was
|
|
386
|
+
// pulling in user-global MCP servers (serena ~27.5 s, peekaboo
|
|
387
|
+
// ~9 s, context7) and the ~45 s MCP cold-start left the TUI
|
|
388
|
+
// accepting a pasted prompt but dropping the submitted Enter, so
|
|
389
|
+
// polygram's paste never submitted and the turn failed (broke the
|
|
390
|
+
// Music topic 5+ times). Default OFF — every other topic is
|
|
391
|
+
// unaffected unless it explicitly opts in.
|
|
392
|
+
const isolateUserConfig =
|
|
393
|
+
topicConfig.isolateUserConfig != null
|
|
394
|
+
? topicConfig.isolateUserConfig === true
|
|
395
|
+
: chatConfig.isolateUserConfig === true;
|
|
357
396
|
|
|
358
397
|
// Pre-allocate the sessionId via --session-id flag (v9 finding).
|
|
359
398
|
// claude accepts a valid UUID and uses it as THE session ID for the
|
|
@@ -376,6 +415,25 @@ class TmuxProcess extends Process {
|
|
|
376
415
|
}
|
|
377
416
|
args.push('--debug-file', this.debugLogPath);
|
|
378
417
|
if (agent) args.push('--agent', agent);
|
|
418
|
+
// isolateUserConfig: cut the spawned TUI off from `~/.claude`.
|
|
419
|
+
// --strict-mcp-config — claude CLI v2.1.142: "Only use MCP
|
|
420
|
+
// servers from --mcp-config, ignoring all other MCP
|
|
421
|
+
// configurations." Passed ALONE (no --mcp-config) → zero MCP
|
|
422
|
+
// servers load. Hard guarantee that no plugin-provided OR
|
|
423
|
+
// directly-registered MCP server (serena/peekaboo/context7)
|
|
424
|
+
// starts, so there is no ~45 s cold-start window.
|
|
425
|
+
// --setting-sources project,local — load only project + local
|
|
426
|
+
// settings, NOT `user`. Drops `~/.claude/settings.json`
|
|
427
|
+
// (user-level plugins/skills/settings) while the spawn cwd's
|
|
428
|
+
// own `.claude/settings.json` still loads — so the rekordbox
|
|
429
|
+
// project's WebFetch allowlist + dontAsk mode still apply.
|
|
430
|
+
// No --mcp-config needed: the music-curation plugin ships NO MCP
|
|
431
|
+
// server (its .claude-plugin/plugin.json declares no `mcpServers`;
|
|
432
|
+
// it is all-Bash), so --strict-mcp-config alone is clean.
|
|
433
|
+
if (isolateUserConfig) {
|
|
434
|
+
args.push('--strict-mcp-config');
|
|
435
|
+
args.push('--setting-sources', 'project,local');
|
|
436
|
+
}
|
|
379
437
|
// Cross-backend parity: SDK appends polygram's Telegram display
|
|
380
438
|
// hint to every agent's systemPrompt (lib/sdk/build-options.js).
|
|
381
439
|
// Without this, the spawned claude session has no idea it's
|
|
@@ -563,15 +621,8 @@ class TmuxProcess extends Process {
|
|
|
563
621
|
// rc.13.1: pasteAndEnter holds a per-session async lock around
|
|
564
622
|
// paste + Enter so a concurrent injectUserMessage paste cannot
|
|
565
623
|
// interleave keystrokes with this primary prompt.
|
|
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
624
|
const result = await this._pasteAndEnter(
|
|
574
|
-
this._embedToken(turn.prompt, turn.token)
|
|
625
|
+
this._embedToken(turn.prompt, turn.token));
|
|
575
626
|
if (result.stripped > 0) {
|
|
576
627
|
this.logger.warn?.(
|
|
577
628
|
`[${this.label}] stripped ${result.stripped} control chars from prompt`,
|
|
@@ -579,6 +630,34 @@ class TmuxProcess extends Process {
|
|
|
579
630
|
this.emit('prompt-sanitized', { stripped: result.stripped, source: 'send' });
|
|
580
631
|
}
|
|
581
632
|
|
|
633
|
+
// B7 (shumorobot msgs 789/791/803): a PRIMARY turn pasting into
|
|
634
|
+
// an idle TUI MUST start a turn. A production polygram prompt is
|
|
635
|
+
// ~1-2KB → the claude TUI collapses it into a `[Pasted text #N]`
|
|
636
|
+
// placeholder whose single post-paste Enter can be absorbed
|
|
637
|
+
// mid-ingest, leaving the prompt UNSUBMITTED — the turn never
|
|
638
|
+
// starts. `_confirmSubmitViaJsonl` confirms the submit landed by
|
|
639
|
+
// waiting for this paste's correlation token to surface in a
|
|
640
|
+
// JSONL `user-message` (the ONLY reliable signal — capture-pane
|
|
641
|
+
// false-positives on the collapsed placeholder); it re-sends
|
|
642
|
+
// Enter on a miss and, after bounded retries, REJECTS with
|
|
643
|
+
// TMUX_SUBMIT_FAILED.
|
|
644
|
+
//
|
|
645
|
+
// The confirm drives TWO derived promises below:
|
|
646
|
+
// submitConfirmP — rejects TMUX_SUBMIT_FAILED → a racer that
|
|
647
|
+
// fails the turn fast and loud; on success it never settles.
|
|
648
|
+
// submitOkP — resolves ONLY once the submit is confirmed;
|
|
649
|
+
// on failure it never settles. It GATES the capture-pane
|
|
650
|
+
// racer: an "idle" capture-pane is meaningless until the turn
|
|
651
|
+
// has actually started — pre-gate, a `[Pasted text #N]`
|
|
652
|
+
// prompt sitting unsubmitted reads as an idle (=="complete")
|
|
653
|
+
// pane, so the capture racer would win with TMUX_NO_JSONL_TEXT
|
|
654
|
+
// and mask the real TMUX_SUBMIT_FAILED cause.
|
|
655
|
+
const confirmP = turn.token
|
|
656
|
+
? this._confirmSubmitViaJsonl(turn.token, turn)
|
|
657
|
+
: Promise.resolve(); // no token — nothing to confirm
|
|
658
|
+
const submitConfirmP = confirmP.then(() => new Promise(() => {}));
|
|
659
|
+
const submitOkP = confirmP.then(() => true, () => new Promise(() => {}));
|
|
660
|
+
|
|
582
661
|
// R7: an ABSOLUTE timeout wrapping the whole race. The
|
|
583
662
|
// capture-pane completion detector re-checks its deadline only
|
|
584
663
|
// BETWEEN `captureWide` subprocess calls — if a single `tmux
|
|
@@ -617,11 +696,32 @@ class TmuxProcess extends Process {
|
|
|
617
696
|
timeoutMs: turnTimeoutMs, abortP,
|
|
618
697
|
});
|
|
619
698
|
// The capture-pane loop may end via the absolute-deadline abort
|
|
620
|
-
// (it returns the abort sentinel)
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
699
|
+
// (it returns the abort sentinel) or reject (its own timeout).
|
|
700
|
+
// Both are swallowed here — the `turnDeadlineP` reject below is
|
|
701
|
+
// what actually fails the turn.
|
|
702
|
+
//
|
|
703
|
+
// B7: gate the capture racer behind `submitOkP`. A capture-pane
|
|
704
|
+
// "idle" reading only means "the turn completed" once the turn
|
|
705
|
+
// has actually STARTED. If the paste never submitted, the pane is
|
|
706
|
+
// idle because the prompt is still sitting in the input box — not
|
|
707
|
+
// because a turn finished. Without the gate the capture racer
|
|
708
|
+
// would win with TMUX_NO_JSONL_TEXT and mask the real
|
|
709
|
+
// TMUX_SUBMIT_FAILED. `captureCompleteP` is still awaited inside
|
|
710
|
+
// the racer (so its rejection is always handled — never orphaned
|
|
711
|
+
// into an unhandled rejection) and the poll loop / scheduler
|
|
712
|
+
// refcount lifecycle is unchanged; only the racer's eligibility
|
|
713
|
+
// to WIN is gated on submit confirmation.
|
|
714
|
+
const captureRaceP = (async () => {
|
|
715
|
+
let buf;
|
|
716
|
+
try {
|
|
717
|
+
buf = await captureCompleteP;
|
|
718
|
+
} catch {
|
|
719
|
+
return new Promise(() => {}); // capture's own timeout — turnDeadlineP fails the turn
|
|
720
|
+
}
|
|
721
|
+
if (buf === ABORT_SENTINEL) return new Promise(() => {});
|
|
722
|
+
await submitOkP; // gate: capture cannot win pre-submit-confirm
|
|
723
|
+
return { kind: 'capture' };
|
|
724
|
+
})();
|
|
625
725
|
|
|
626
726
|
let resolvedVia = 'jsonl';
|
|
627
727
|
let winner;
|
|
@@ -631,6 +731,8 @@ class TmuxProcess extends Process {
|
|
|
631
731
|
captureRaceP,
|
|
632
732
|
turnDeadlineP,
|
|
633
733
|
turn.interruptP.then(() => ({ kind: 'interrupt' })),
|
|
734
|
+
// B7: TMUX_SUBMIT_FAILED rejection fails the turn fast.
|
|
735
|
+
submitConfirmP,
|
|
634
736
|
]);
|
|
635
737
|
|
|
636
738
|
// If capture-pane won but the turn used a tool, the agent is
|
|
@@ -1151,6 +1253,10 @@ class TmuxProcess extends Process {
|
|
|
1151
1253
|
// Phase 3 §5: a user-message proves its pastes landed — release
|
|
1152
1254
|
// the paste gate for those tokens.
|
|
1153
1255
|
this._confirmPaste(tokens);
|
|
1256
|
+
// B7: a user-message is the proof that a primary paste actually
|
|
1257
|
+
// STARTED a turn (claude registered the prompt). Release any
|
|
1258
|
+
// _confirmSubmitViaJsonl waiter for these tokens.
|
|
1259
|
+
this._confirmSubmit(tokens);
|
|
1154
1260
|
let matched = [];
|
|
1155
1261
|
for (const tok of tokens) {
|
|
1156
1262
|
const t = this._ledger.find(
|
|
@@ -1166,7 +1272,15 @@ class TmuxProcess extends Process {
|
|
|
1166
1272
|
const orphan = this._ledger.find(
|
|
1167
1273
|
(x) => x.kind === 'primary' && x.state === 'pasted',
|
|
1168
1274
|
);
|
|
1169
|
-
if (orphan)
|
|
1275
|
+
if (orphan) {
|
|
1276
|
+
matched = [orphan];
|
|
1277
|
+
// B7: a token-less user-message claimed by the orphan
|
|
1278
|
+
// fallback still PROVES that primary's prompt started — so
|
|
1279
|
+
// release its submit-confirm waiter under the orphan's own
|
|
1280
|
+
// token (the `_confirmSubmit(tokens)` above could not, the
|
|
1281
|
+
// user-message carried no token to match).
|
|
1282
|
+
this._confirmSubmit([orphan.token]);
|
|
1283
|
+
}
|
|
1170
1284
|
}
|
|
1171
1285
|
if (matched.length === 0) {
|
|
1172
1286
|
// Genuinely unrecognised — diagnostic, then ignore. (A
|
|
@@ -1290,19 +1404,33 @@ class TmuxProcess extends Process {
|
|
|
1290
1404
|
* pastes can no longer concatenate. The lock is released
|
|
1291
1405
|
* asynchronously (on confirm/timeout) so it gates only the NEXT
|
|
1292
1406
|
* paste, never delays this caller.
|
|
1407
|
+
*
|
|
1408
|
+
* B7 (shumorobot msgs 789/791/803): `_pasteAndEnter` does NOT itself
|
|
1409
|
+
* confirm the paste SUBMITTED. A large prompt collapses in the claude
|
|
1410
|
+
* TUI into a `[Pasted text #N]` placeholder whose single post-paste
|
|
1411
|
+
* Enter can be absorbed mid-ingest, leaving the prompt unsubmitted —
|
|
1412
|
+
* but that submit-confirmation runs as a concurrent racer in
|
|
1413
|
+
* `_runTurn` (`_confirmSubmitViaJsonl`), NOT here. Blocking
|
|
1414
|
+
* `_pasteAndEnter` on the confirm would hold `_pasteLock` across the
|
|
1415
|
+
* whole confirm window and stall every following paste — an autosteer
|
|
1416
|
+
* that should fold into the primary turn could never paste.
|
|
1293
1417
|
*/
|
|
1294
|
-
async _pasteAndEnter(text
|
|
1418
|
+
async _pasteAndEnter(text) {
|
|
1295
1419
|
const token = this._extractTokens(text)[0] || null;
|
|
1296
1420
|
const release = await this._pasteLock.acquire(this.tmuxName);
|
|
1297
1421
|
let result;
|
|
1298
1422
|
try {
|
|
1423
|
+
// B7: the runner no longer does capture-pane submit-confirmation
|
|
1424
|
+
// (it false-positived on `[Pasted text #N]`). The runner just
|
|
1425
|
+
// pastes + Enter. Submit confirmation for a PRIMARY turn is
|
|
1426
|
+
// JSONL-token-based and runs as a CONCURRENT racer in `_runTurn`
|
|
1427
|
+
// (`_confirmSubmitViaJsonl`) — NOT here. Blocking `_pasteAndEnter`
|
|
1428
|
+
// on the confirm would hold `_pasteLock` across the whole confirm
|
|
1429
|
+
// window and stall every following paste (an autosteer that
|
|
1430
|
+
// SHOULD fold into the primary turn could never paste). The
|
|
1431
|
+
// confirm is a watchdog, not a paste-pipeline gate.
|
|
1299
1432
|
if (typeof this.runner.pasteAndEnter === 'function') {
|
|
1300
|
-
|
|
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 });
|
|
1433
|
+
result = await this.runner.pasteAndEnter(this.tmuxName, text);
|
|
1306
1434
|
} else {
|
|
1307
1435
|
result = await this.runner.pasteText(this.tmuxName, text);
|
|
1308
1436
|
await this.runner.sendControl(this.tmuxName, 'Enter');
|
|
@@ -1321,6 +1449,91 @@ class TmuxProcess extends Process {
|
|
|
1321
1449
|
return result;
|
|
1322
1450
|
}
|
|
1323
1451
|
|
|
1452
|
+
/**
|
|
1453
|
+
* B7: confirm a primary paste actually submitted by waiting for its
|
|
1454
|
+
* correlation `token` to surface in a JSONL `user-message`. On each
|
|
1455
|
+
* miss, re-send Enter (the prior Enter was absorbed by the TUI's
|
|
1456
|
+
* bracketed-paste ingest of a `[Pasted text #N]` block). After
|
|
1457
|
+
* `submitConfirmRetries` exhausted misses, throw `TMUX_SUBMIT_FAILED`.
|
|
1458
|
+
*
|
|
1459
|
+
* The signal is the `user-message` ONLY — not `queue-operation`. A
|
|
1460
|
+
* `queue-operation enqueue` means the paste was parked in the TUI
|
|
1461
|
+
* queue, which for a primary paste into an idle TUI would itself be
|
|
1462
|
+
* wrong; the genuine "the prompt started a turn" proof is the
|
|
1463
|
+
* `user-message` claude writes when it registers the prompt.
|
|
1464
|
+
*
|
|
1465
|
+
* Runs as a concurrent racer in `_runTurn` (NOT a blocking gate in
|
|
1466
|
+
* `_pasteAndEnter` — that would hold `_pasteLock` across the confirm
|
|
1467
|
+
* window and stall a folding autosteer's paste). `turn` is the owning
|
|
1468
|
+
* Turn: if it reaches a terminal state (the real result/capture
|
|
1469
|
+
* racer already won, or the turn was killed) the retry loop bails so
|
|
1470
|
+
* a stray retry Enter cannot land in an unrelated turn.
|
|
1471
|
+
*/
|
|
1472
|
+
async _confirmSubmitViaJsonl(token, turn = null) {
|
|
1473
|
+
for (let attempt = 0; attempt <= this.submitConfirmRetries; attempt += 1) {
|
|
1474
|
+
const confirmed = await this._awaitSubmitConfirm(token);
|
|
1475
|
+
if (confirmed) return; // submitted ✓
|
|
1476
|
+
// The turn already settled some other way (result/capture/kill)
|
|
1477
|
+
// — the submit clearly is no longer the open question. Stop:
|
|
1478
|
+
// re-sending Enter or throwing now would be wrong.
|
|
1479
|
+
if (turn && (turn.state === 'done' || turn.state === 'failed')) return;
|
|
1480
|
+
if (attempt === this.submitConfirmRetries) break; // out of retries
|
|
1481
|
+
// The tokened user-message never arrived — the prompt is still
|
|
1482
|
+
// sitting in the input box as `[Pasted text #N]`. Re-send Enter.
|
|
1483
|
+
this.logger.debug?.(
|
|
1484
|
+
`[${this.label}] paste not submitted (no user-message for ${token}), `
|
|
1485
|
+
+ `re-sending Enter (attempt ${attempt + 1})`,
|
|
1486
|
+
);
|
|
1487
|
+
try {
|
|
1488
|
+
await this.runner.sendControl(this.tmuxName, 'Enter');
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
// A dead session — the turn will fail loud via another racer.
|
|
1491
|
+
this.logger.debug?.(`[${this.label}] retry Enter failed: ${err.message}`);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
throw Object.assign(
|
|
1496
|
+
new Error(
|
|
1497
|
+
`TmuxProcess: prompt never submitted — no JSONL user-message for `
|
|
1498
|
+
+ `${token} after ${this.submitConfirmRetries + 1} Enter attempts`,
|
|
1499
|
+
),
|
|
1500
|
+
{ code: 'TMUX_SUBMIT_FAILED', tmuxName: this.tmuxName, token },
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Resolve `true` once `token` surfaces in a JSONL `user-message`
|
|
1506
|
+
* (via `_confirmSubmit`), or `false` after `submitConfirmMs`.
|
|
1507
|
+
* Distinct from `_awaitPasteConfirm`: that releases the next-paste
|
|
1508
|
+
* barrier on user-message OR queue-operation; this confirms a turn
|
|
1509
|
+
* actually STARTED, so it accepts the `user-message` signal only.
|
|
1510
|
+
*/
|
|
1511
|
+
_awaitSubmitConfirm(token) {
|
|
1512
|
+
return new Promise((resolve) => {
|
|
1513
|
+
let done = false;
|
|
1514
|
+
let timer = null;
|
|
1515
|
+
const finish = (ok) => {
|
|
1516
|
+
if (done) return;
|
|
1517
|
+
done = true;
|
|
1518
|
+
if (timer) clearTimeout(timer);
|
|
1519
|
+
this._submitConfirms.delete(token);
|
|
1520
|
+
resolve(ok);
|
|
1521
|
+
};
|
|
1522
|
+
this._submitConfirms.set(token, () => finish(true));
|
|
1523
|
+
timer = setTimeout(() => finish(false), this.submitConfirmMs);
|
|
1524
|
+
timer.unref?.();
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/** B7: mark tokens as submit-confirmed — fired from a JSONL
|
|
1529
|
+
* `user-message` (NOT queue-operation). */
|
|
1530
|
+
_confirmSubmit(tokens) {
|
|
1531
|
+
for (const t of tokens) {
|
|
1532
|
+
const finish = this._submitConfirms.get(t);
|
|
1533
|
+
if (finish) finish();
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1324
1537
|
/**
|
|
1325
1538
|
* Resolve once `token` surfaces in a JSONL user-message /
|
|
1326
1539
|
* queue-operation, or after `pasteConfirmMs` (bounded barrier).
|
|
@@ -1927,6 +2140,18 @@ class TmuxProcess extends Process {
|
|
|
1927
2140
|
for (const finish of [...this._pasteConfirms.values()]) {
|
|
1928
2141
|
try { finish(); } catch { /* swallow */ }
|
|
1929
2142
|
}
|
|
2143
|
+
// B7: release any pending submit-confirm waiters too — a
|
|
2144
|
+
// `_confirmSubmitViaJsonl` blocked on a tokened user-message from a
|
|
2145
|
+
// now-dead session would otherwise burn its whole retry budget.
|
|
2146
|
+
// Each waiter's stored fn resolves it as confirmed, so the confirm
|
|
2147
|
+
// loop returns at once instead of retrying; the in-flight turn is
|
|
2148
|
+
// already rejected by `drainQueue` above, so the turn settles loud
|
|
2149
|
+
// regardless. (`_confirmSubmitViaJsonl` also bails on its own when
|
|
2150
|
+
// the owning turn reaches a terminal state — this is belt-and-
|
|
2151
|
+
// braces for a confirm whose turn ref it never received.)
|
|
2152
|
+
for (const finish of [...this._submitConfirms.values()]) {
|
|
2153
|
+
try { finish(); } catch { /* swallow */ }
|
|
2154
|
+
}
|
|
1930
2155
|
if (this._sessionLogTail) {
|
|
1931
2156
|
try { this._sessionLogTail.close(); } catch { /* swallow */ }
|
|
1932
2157
|
this._sessionLogTail = null;
|
package/lib/tmux/tmux-runner.js
CHANGED
|
@@ -46,78 +46,65 @@ 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
|
|
50
|
-
//
|
|
51
|
-
// #
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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 };
|
|
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.
|
|
104
65
|
|
|
105
66
|
// ─── execFile wrapper ────────────────────────────────────────────────
|
|
106
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
|
+
|
|
107
86
|
/**
|
|
108
87
|
* Promise-wrapped childProcess.execFile. Returns { stdout, stderr }.
|
|
109
|
-
* Rejects on non-zero exit with err.stdout + err.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.
|
|
110
92
|
*/
|
|
111
93
|
function run(cmd, args, opts = {}) {
|
|
112
94
|
return new Promise((resolve, reject) => {
|
|
113
|
-
childProcess.execFile(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
);
|
|
121
108
|
});
|
|
122
109
|
}
|
|
123
110
|
|
|
@@ -195,16 +182,11 @@ function ensureLogDir(logPath) {
|
|
|
195
182
|
* @param {object} [opts.logger=console]
|
|
196
183
|
* @param {Function} [opts.runFn] — override the underlying execFile
|
|
197
184
|
* 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.
|
|
200
185
|
*/
|
|
201
186
|
function createTmuxRunner({
|
|
202
187
|
logger = console,
|
|
203
188
|
runFn = run,
|
|
204
|
-
submitConfirm = {},
|
|
205
189
|
} = {}) {
|
|
206
|
-
const submitCfg = { ...DEFAULT_SUBMIT_CONFIRM, ...submitConfirm };
|
|
207
|
-
|
|
208
190
|
async function spawn({
|
|
209
191
|
name,
|
|
210
192
|
cwd,
|
|
@@ -278,21 +260,17 @@ function createTmuxRunner({
|
|
|
278
260
|
/**
|
|
279
261
|
* Paste a prompt body + press Enter, atomically per session.
|
|
280
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
|
+
*
|
|
281
270
|
* @param {string} name tmux session
|
|
282
271
|
* @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
272
|
*/
|
|
295
|
-
async function pasteAndEnter(name, text
|
|
273
|
+
async function pasteAndEnter(name, text) {
|
|
296
274
|
const release = await inputLock.acquire(name);
|
|
297
275
|
try {
|
|
298
276
|
const res = await pasteText(name, text);
|
|
@@ -306,46 +284,7 @@ function createTmuxRunner({
|
|
|
306
284
|
// Enter's processing. 50ms is enough on the TUI we tested
|
|
307
285
|
// against (claude v2.1.142); see AGENTS.md pinned version.
|
|
308
286
|
await new Promise((r) => setTimeout(r, 50));
|
|
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
|
-
);
|
|
287
|
+
return res;
|
|
349
288
|
} finally {
|
|
350
289
|
release();
|
|
351
290
|
}
|
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.26",
|
|
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": {
|