polygram 0.12.0-rc.2 → 0.12.0-rc.21

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.
@@ -91,10 +91,6 @@ function _maybeWarnR12Migration({ rawPm, canonical, chatId, threadId, chatCfg, t
91
91
  * @param {number} [opts.queryCloseTimeoutMs]
92
92
  * @param {object} [opts.tmuxRunner] — required when ANY chat routes to 'cli'
93
93
  * @param {string} [opts.botName] — required when ANY chat routes to 'cli'
94
- * @param {object} [opts.pollScheduler] — DEPRECATED in 0.12 — was used by the
95
- * removed tmux backend to share one setInterval across all chats; CliProcess's
96
- * per-session pongWatchdog handles its own cadence. Param kept for caller
97
- * back-compat; ignored. Will be removed in 0.13.
98
94
  * @param {Function} [opts.toolDispatcher] — required when ANY chat routes to 'cli'.
99
95
  * async ({sessionKey, chatId, threadId, toolName, text, files}) => {ok, error?}.
100
96
  * Called when Claude's reply (or react/edit_message) tool fires inside a
@@ -113,7 +109,6 @@ function createProcessFactory({
113
109
  queryCloseTimeoutMs,
114
110
  tmuxRunner = null,
115
111
  botName = null,
116
- pollScheduler = null,
117
112
  toolDispatcher = null,
118
113
  channelsClaudeBin = null,
119
114
  } = {}) {
@@ -51,6 +51,9 @@ const KNOWN_EVENT_NAMES = new Set([
51
51
  'SubagentStop',
52
52
  'Stop',
53
53
  'Notification',
54
+ // 0.12.0-rc.13: compaction lifecycle (carry `trigger` + custom_instructions).
55
+ 'PreCompact',
56
+ 'PostCompact',
54
57
  ]);
55
58
 
56
59
  /**
@@ -87,6 +90,10 @@ function normalizeHookEvent(raw) {
87
90
  prompt: raw?.prompt ?? null,
88
91
  stopHookActive: raw?.stop_hook_active ?? null,
89
92
  lastAssistantMessage: raw?.last_assistant_message ?? null,
93
+ // PreCompact/PostCompact payload: trigger distinguishes auto vs manual
94
+ // compaction; custom_instructions is the `/compact <hint>` text (manual).
95
+ trigger: raw?.trigger ?? null,
96
+ customInstructions: raw?.custom_instructions ?? null,
90
97
  receivedAtMs: raw?.polygram_received_at_ms ?? null,
91
98
  raw,
92
99
  };
@@ -44,6 +44,13 @@ const HOOK_EVENTS = [
44
44
  'SubagentStop',
45
45
  'Stop',
46
46
  'Notification',
47
+ // 0.12.0-rc.13: compaction lifecycle. PreCompact fires when claude is about
48
+ // to (auto-)compact — the moment that detaches the channels MCP bridge.
49
+ // PostCompact fires after, when context has dropped (used to re-arm the
50
+ // per-chat compaction warning). Both confirmed supported by the pinned CLI
51
+ // (2.1.142) and carry a `trigger: auto|manual` field.
52
+ 'PreCompact',
53
+ 'PostCompact',
47
54
  ];
48
55
 
49
56
  /**
@@ -41,6 +41,12 @@ const CALLBACK_TO_EVENT = {
41
41
  onAssistantMessageStart: 'assistant-message-start',
42
42
  onAutonomousAssistantMessage: 'autonomous-assistant-message',
43
43
  onCompactBoundary: 'compact-boundary',
44
+ // 0.12.0-rc.13: per-chat compaction warning. CliProcess emits
45
+ // 'compaction-warn' {kind:'proactive'|'reactive', pct?} when (proactive)
46
+ // context crosses the chat's threshold at turn-end, or (reactive) claude is
47
+ // auto-compacting now. The callback posts a chat message proposing /compact
48
+ // — opt-in per chat. See docs/0.12.0-file-send.md / lib/compaction-warn.js.
49
+ onCompactionWarn: 'compaction-warn',
44
50
  onQueueDrop: 'queue-drop',
45
51
  onThinking: 'thinking',
46
52
  // Tmux backend: TUI shows in-pane approval prompt. SDK backend
@@ -464,7 +464,10 @@ function createSdkCallbacks({
464
464
  const detail = {
465
465
  chat_id: getChatIdFromKey(sessionKey),
466
466
  session_key: sessionKey,
467
- backend: 'tmux',
467
+ // Finding 0.12-M3: tmux backend was deleted in 0.12; these hook
468
+ // handlers only ever fire on the CLI driver now — default to 'cli'
469
+ // (honor an explicit payload.backend if a caller ever sets one).
470
+ backend: payload?.backend ?? 'cli',
468
471
  hook_type: payload?.type ?? null,
469
472
  claude_session_id: payload?.sessionId ?? null,
470
473
  tool_name: payload?.toolName ?? null,
@@ -555,7 +558,7 @@ function createSdkCallbacks({
555
558
  logEvent('turn-timeout', {
556
559
  chat_id: getChatIdFromKey(sessionKey),
557
560
  session_key: sessionKey,
558
- backend: 'tmux',
561
+ backend: payload?.backend ?? 'cli', // Finding 0.12-M3
559
562
  turn_id: payload?.turnId ?? null,
560
563
  reason: payload?.reason ?? null,
561
564
  idle_ms: payload?.idleMs ?? null,
@@ -568,6 +571,42 @@ function createSdkCallbacks({
568
571
  }
569
572
  },
570
573
 
574
+ // 0.12.0-rc.13: per-chat compaction warning. CliProcess emits
575
+ // 'compaction-warn' when context crosses the chat's threshold at turn-end
576
+ // (proactive) or claude is auto-compacting now (reactive). Post a chat
577
+ // message proposing /compact so the user can compact on their terms BEFORE
578
+ // an auto-compaction interrupts a turn (and detaches the channels bridge).
579
+ // Opt-in per chat (lib/compaction-warn.js) — CliProcess only emits when
580
+ // enabled, so no extra config gate is needed here. Best-effort send.
581
+ onCompactionWarn: (sessionKey, payload /* , entry */) => {
582
+ try {
583
+ const chatId = getChatIdFromKey(sessionKey);
584
+ const threadIdRaw = getThreadIdFromKey(sessionKey);
585
+ const threadId = threadIdRaw ? parseInt(threadIdRaw, 10) : null;
586
+ const kind = payload?.kind === 'reactive' ? 'reactive' : 'proactive';
587
+ logEvent('compaction-warn', {
588
+ chat_id: chatId,
589
+ session_key: sessionKey,
590
+ kind,
591
+ pct: payload?.pct ?? null,
592
+ backend: payload?.backend ?? 'cli',
593
+ });
594
+ if (!bot) return;
595
+ const text = kind === 'reactive'
596
+ ? '🗜️ Auto-compacting now — context filled up. If this turn goes quiet, please resend. (Tip: running `/compact` at a natural break avoids mid-task compactions.)'
597
+ : `📚 Heads up — this chat's context is ~${payload?.pct ?? '?'}% full. To avoid an auto-compaction that can interrupt a turn, run \`/compact\` (optionally with a hint, e.g. \`/compact keep the recent decisions\`) at a natural break — or \`/new\` for a fresh start.`;
598
+ tg(bot, 'sendMessage', {
599
+ chat_id: chatId,
600
+ text,
601
+ ...(threadId ? { message_thread_id: threadId } : {}),
602
+ }, { source: 'compaction-warn', botName }).catch((err) => {
603
+ logger.error?.(`[${botName}] compaction-warn send failed: ${err.message}`);
604
+ });
605
+ } catch (err) {
606
+ logger.error?.(`[${botName}] compaction-warn handler: ${err.message}`);
607
+ }
608
+ },
609
+
571
610
  // 0.10.0 rc.42 #8: tmux backend hook-tail error observability.
572
611
  // Persistent failures of the hook ndjson tail degrade H3 idle-
573
612
  // ceiling accuracy and H4 Stop-synth coverage with no surface
@@ -578,7 +617,7 @@ function createSdkCallbacks({
578
617
  logEvent('hook-tail-error', {
579
618
  chat_id: getChatIdFromKey(sessionKey),
580
619
  session_key: sessionKey,
581
- backend: 'tmux',
620
+ backend: payload?.backend ?? 'cli', // Finding 0.12-M3 (fires on the CLI hook tail)
582
621
  message: (payload?.message || '').slice(0, 200),
583
622
  path: payload?.path ?? null,
584
623
  claude_session_id: payload?.sessionId ?? null,
@@ -596,7 +635,7 @@ function createSdkCallbacks({
596
635
  logEvent('stop-hook-resolved', {
597
636
  chat_id: getChatIdFromKey(sessionKey),
598
637
  session_key: sessionKey,
599
- backend: 'tmux',
638
+ backend: payload?.backend ?? 'cli', // Finding 0.12-M3
600
639
  turn_id: payload?.turnId ?? null,
601
640
  claude_session_id: payload?.sessionId ?? null,
602
641
  });
@@ -614,7 +653,7 @@ function createSdkCallbacks({
614
653
  logEvent('session-age-prompt-dismissed', {
615
654
  chat_id: getChatIdFromKey(sessionKey),
616
655
  session_key: sessionKey,
617
- backend: 'tmux',
656
+ backend: payload?.backend ?? 'cli', // Finding 0.12-M3
618
657
  claude_session_id: payload?.sessionId ?? null,
619
658
  });
620
659
  } catch (err) {
@@ -680,7 +719,7 @@ function createSdkCallbacks({
680
719
  // ON json_extract(s.detail_json, '$.tool_use_id') =
681
720
  // json_extract(d.detail_json, '$.tool_use_id')
682
721
  // WHERE s.kind='subagent-start' AND d.kind='subagent-done';
683
- onSubagentStart: (sessionKey, payload /* , entry */) => {
722
+ onSubagentStart: (sessionKey, payload, entry) => {
684
723
  try {
685
724
  logEvent('subagent-start', {
686
725
  chat_id: getChatIdFromKey(sessionKey),
@@ -689,13 +728,23 @@ function createSdkCallbacks({
689
728
  agent_type: payload?.agentType ?? null,
690
729
  tool_use_id: payload?.toolUseId ?? null,
691
730
  });
731
+ // Findings L9/L14: drive the head reactor into the distinct SUBAGENT
732
+ // state so a running subagent shows 👾 rather than freezing on the
733
+ // prior tool's emoji. The plan promised this; previously the handler
734
+ // only persisted the DB row and never touched the reactor.
735
+ const r = entry?.pendingQueue?.[0]?.context?.reactor;
736
+ if (r) r.setState('SUBAGENT');
692
737
  } catch (err) {
693
738
  logger.error?.(`[${botName}] subagent-start handler: ${err.message}`);
694
739
  }
695
740
  },
696
741
 
697
- onSubagentDone: (sessionKey, payload /* , entry */) => {
742
+ onSubagentDone: (sessionKey, payload, entry) => {
698
743
  try {
744
+ // L9/L14: heartbeat at subagent end so the cascade/stall clock
745
+ // resets; the next tool's PreToolUse sets the following state.
746
+ const r = entry?.pendingQueue?.[0]?.context?.reactor;
747
+ if (r && typeof r.heartbeat === 'function') r.heartbeat();
699
748
  logEvent('subagent-done', {
700
749
  chat_id: getChatIdFromKey(sessionKey),
701
750
  session_key: sessionKey,
@@ -703,6 +752,11 @@ function createSdkCallbacks({
703
752
  agent_type: payload?.agentType ?? null,
704
753
  agent_id: payload?.agentId ?? null,
705
754
  duration_ms: payload?.durationMs ?? null,
755
+ // Finding 0.12-M4: persist the originating Agent tool_use_id so the
756
+ // documented subagent-start/subagent-done soak JOIN on
757
+ // $.tool_use_id matches (subagent-done's tool_use_id is recovered
758
+ // in cli-process.js from the paired Agent PreToolUse).
759
+ tool_use_id: payload?.toolUseId ?? null,
706
760
  });
707
761
  } catch (err) {
708
762
  logger.error?.(`[${botName}] subagent-done handler: ${err.message}`);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * album-reactions — apply one status reaction to every message of a Telegram
3
+ * album (the anchor + its siblings), so a multi-file send shows the same emoji
4
+ * on each item instead of only the first.
5
+ *
6
+ * Background: Telegram delivers an album as N separate messages sharing a
7
+ * media_group_id; polygram coalesces them into ONE turn anchored on the first.
8
+ * The status reactor therefore only ever reacted to that anchor, leaving the
9
+ * sibling files with no visible reaction (the rc.16 observation). This mirrors
10
+ * the reactor's emoji onto the siblings.
11
+ *
12
+ * Semantics:
13
+ * - The ANCHOR (first id) is awaited so a failure surfaces to the reactor's
14
+ * own error handling (same as the single-message path).
15
+ * - SIBLINGS are best-effort: a failure on one must not drop the anchor's
16
+ * reaction or the other siblings (and must not throw — reactions are
17
+ * cosmetic). They also can't share the anchor's fate of being retried.
18
+ * - Calls are sequential to respect Telegram's setMessageReaction rate limit
19
+ * (~5/s/chat) — an album is ≤10 items so this stays well within budget.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ /**
25
+ * @param {object} opts
26
+ * @param {Function} opts.tg async (bot, method, params, meta) => any
27
+ * @param {*} opts.bot
28
+ * @param {string} opts.chatId
29
+ * @param {number[]} opts.msgIds [anchor, ...siblings] — anchor first
30
+ * @param {string|null} opts.emoji emoji to set, or null/'' to clear
31
+ * @param {string} [opts.botName]
32
+ */
33
+ async function applyReactionToMessages({ tg, bot, chatId, msgIds, emoji, botName } = {}) {
34
+ const reaction = emoji ? [{ type: 'emoji', emoji }] : [];
35
+ const ids = Array.isArray(msgIds) ? msgIds : [];
36
+ for (let i = 0; i < ids.length; i++) {
37
+ const params = { chat_id: chatId, message_id: ids[i], reaction };
38
+ const meta = {
39
+ source: i === 0 ? 'status-reaction' : 'status-reaction-album-sibling',
40
+ botName,
41
+ };
42
+ if (i === 0) {
43
+ await tg(bot, 'setMessageReaction', params, meta); // anchor: surface failure
44
+ } else {
45
+ await tg(bot, 'setMessageReaction', params, meta).catch(() => {}); // siblings: best-effort
46
+ }
47
+ }
48
+ }
49
+
50
+ module.exports = { applyReactionToMessages };
@@ -28,6 +28,7 @@ const {
28
28
  getRetryAfterMs,
29
29
  } = require('./format');
30
30
  const { isSafeToRetry, redactBotToken } = require('../error/net');
31
+ const { coerceFileParams } = require('./input-file');
31
32
 
32
33
  // Topic deletion race: a user can delete a forum topic while a turn is in
33
34
  // flight, turning a valid `message_thread_id` into a 404. Telegram's error
@@ -112,6 +113,14 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
112
113
  const chatId = params.chat_id != null ? String(params.chat_id) : null;
113
114
  const threadId = params.message_thread_id != null ? String(params.message_thread_id) : null;
114
115
 
116
+ // File-upload bug fix (2026-05-31): coerce a `{ source: '/abs/path' }`
117
+ // file param into a grammy InputFile so local-file uploads actually work.
118
+ // grammy doesn't recognize the bare envelope → it failed every send with
119
+ // "Wrong port number". Single choke point: fixes channels reply(files)
120
+ // AND the IPC send path at once. No-op for non-file methods / file_id /
121
+ // URL strings / existing InputFile instances.
122
+ coerceFileParams(method, params);
123
+
115
124
  // 0.7.4: empty-text short-circuit. Pre-fix, an empty params.text on
116
125
  // sendMessage/editMessageText reached Telegram and 400'd with
117
126
  // "message text is empty"; the row was marked failed and propagated
@@ -0,0 +1,76 @@
1
+ /**
2
+ * input-file — coerce file-upload params into grammy InputFile instances.
3
+ *
4
+ * The bug (2026-05-31, shumorobot Music): callers passed a Telegraf-style
5
+ * `{ source: '/abs/path' }` envelope as the file param (document/photo/…).
6
+ * grammy 1.x does NOT recognize that shape — it's not an InputFile, so
7
+ * grammy serializes it as a plain object and Telegram tries to read it as
8
+ * a URL/file_id, failing with "invalid file HTTP URL: Wrong port number".
9
+ * Result: file-send NEVER worked (channels reply(files) AND the IPC path
10
+ * both produced this exact error). The existing dispatcher test used a fake
11
+ * `send` and only asserted the METHOD, so it couldn't catch the bad shape.
12
+ *
13
+ * grammy uploads a local file only when the param is `new InputFile(path)`.
14
+ * This helper normalizes, at the single send choke point (tg()), the
15
+ * `{ source: <abs path> }` envelope → `new InputFile(path)`, leaving every
16
+ * other shape untouched:
17
+ * - string file_id / https URL → pass through (Telegram resolves)
18
+ * - existing InputFile instance → pass through (already correct)
19
+ * - Buffer / stream → pass through (grammy handles)
20
+ *
21
+ * Only the explicit `{ source: string }` envelope is transformed — bare
22
+ * path strings are intentionally NOT coerced (a Telegram file_id is also a
23
+ * bare string; coercing would break sends-by-id).
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const { InputFile } = require('grammy');
29
+
30
+ // method → the params field that carries the file.
31
+ const FILE_FIELD_BY_METHOD = {
32
+ sendPhoto: 'photo',
33
+ sendDocument: 'document',
34
+ sendAudio: 'audio',
35
+ sendVideo: 'video',
36
+ sendAnimation: 'animation',
37
+ sendVoice: 'voice',
38
+ sendVideoNote: 'video_note',
39
+ };
40
+
41
+ /**
42
+ * Return a grammy-uploadable value for a single file param, or the original
43
+ * value unchanged if it's not the `{ source }` envelope we coerce.
44
+ */
45
+ function coerceFileValue(val) {
46
+ if (val && typeof val === 'object' && !(val instanceof InputFile)
47
+ && typeof val.source === 'string' && val.source.length > 0) {
48
+ // { source: '/abs/path' } | { source: 'https://…', filename } → InputFile
49
+ return new InputFile(val.source, val.filename);
50
+ }
51
+ return val;
52
+ }
53
+
54
+ /**
55
+ * Mutate `params` in place so its file field (if any) is grammy-uploadable.
56
+ * No-op for non-file methods and for params with no file field set.
57
+ *
58
+ * @param {string} method
59
+ * @param {object} params
60
+ * @returns {object} the same params object (for chaining)
61
+ */
62
+ function coerceFileParams(method, params) {
63
+ if (!params || typeof params !== 'object') return params;
64
+ const field = FILE_FIELD_BY_METHOD[method];
65
+ if (!field) return params;
66
+ if (params[field] != null) {
67
+ params[field] = coerceFileValue(params[field]);
68
+ }
69
+ return params;
70
+ }
71
+
72
+ module.exports = {
73
+ coerceFileParams,
74
+ coerceFileValue,
75
+ FILE_FIELD_BY_METHOD,
76
+ };
@@ -55,6 +55,11 @@ const STATES = {
55
55
  // mid-turn user message is buffered for the next PostToolBatch
56
56
  // injection.
57
57
  AUTOSTEERED: { label: 'autosteered', chain: ['✍', '👀'] },
58
+ // 0.12 (Findings L9/L14): distinct in-progress reaction for a running
59
+ // subagent (Agent PreToolUse → SubagentStop). Driven by onSubagentStart.
60
+ // Preferred 👾 (NOT 🤖 — 🤖 is REACTION_INVALID for bots, same class as
61
+ // the rc.37 🧐 bug); falls back to 🔥 then 🤔, all bot-usable.
62
+ SUBAGENT: { label: 'subagent', chain: ['👾', '🔥', '🤔'] },
58
63
  DONE: { label: 'done', chain: ['👍'] },
59
64
  ERROR: { label: 'error', chain: ['🤯', '🤔'] },
60
65
  STALL: { label: 'stall', chain: ['🥱', '🤔'] },
@@ -42,6 +42,7 @@
42
42
  const EventEmitter = require('events');
43
43
  const fs = require('fs');
44
44
  const path = require('path');
45
+ const { StringDecoder } = require('string_decoder');
45
46
 
46
47
  const DEFAULT_INTERVAL_MS = 100;
47
48
  // Slow safety-net poll when fs.watch is active. Catches any events
@@ -91,6 +92,13 @@ class LogTail extends EventEmitter {
91
92
  this.fs = fsOverride || fs;
92
93
  this._offset = 0;
93
94
  this._buf = '';
95
+ // L8: decode bytes through a StringDecoder so a multibyte UTF-8 char
96
+ // split across two read chunks (the 64KB DEFAULT_CHUNK_BYTES boundary)
97
+ // isn't corrupted into U+FFFD. The decoder holds an incomplete trailing
98
+ // sequence until the continuation bytes arrive on the next read. The
99
+ // hook ndjson carries large non-ASCII tool payloads, so this is
100
+ // load-bearing on the CliProcess observability path.
101
+ this._decoder = new StringDecoder('utf8');
94
102
  this._closed = false;
95
103
  this._timer = null;
96
104
  this._watcher = null;
@@ -260,7 +268,9 @@ class LogTail extends EventEmitter {
260
268
  const readSize = Math.min(remaining, buffer.length);
261
269
  const { bytesRead } = await fd.read(buffer, 0, readSize, this._offset + totalRead);
262
270
  if (bytesRead === 0) break;
263
- this._buf += buffer.slice(0, bytesRead).toString('utf8');
271
+ // L8: StringDecoder.write instead of per-chunk toString('utf8') so a
272
+ // multibyte char straddling the read boundary survives intact.
273
+ this._buf += this._decoder.write(buffer.subarray(0, bytesRead));
264
274
  totalRead += bytesRead;
265
275
  }
266
276
  this._offset += totalRead;
@@ -17,6 +17,19 @@
17
17
  * - if `readySignal` regex matches the captured pane content, resolve
18
18
  * - if `Date.now()` exceeds the deadline, throw with `err.code = timeoutCode`
19
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
+ *
20
33
  * Each trigger is one-shot per gate run (tracked by `name` in a Set).
21
34
  *
22
35
  * Caller supplies:
@@ -40,7 +53,10 @@ const DEFAULT_SETTLE_MS = 500;
40
53
  * @param {string} opts.tmuxName — tmux session name to poll
41
54
  * @param {Array<{name:string, regex:RegExp, key:string}>} opts.triggers
42
55
  * @param {RegExp} opts.readySignal — match → resolve
43
- * @param {number} [opts.deadlineMs=30000]
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.
44
60
  * @param {number} [opts.pollMs=300]
45
61
  * @param {number} [opts.settleMs=500]
46
62
  * @param {string} [opts.timeoutCode='TUI_STARTUP_TIMEOUT']
@@ -54,6 +70,7 @@ async function runStartupGate({
54
70
  triggers = [],
55
71
  readySignal,
56
72
  deadlineMs = DEFAULT_DEADLINE_MS,
73
+ stallMs,
57
74
  pollMs = DEFAULT_POLL_MS,
58
75
  settleMs = DEFAULT_SETTLE_MS,
59
76
  timeoutCode = 'TUI_STARTUP_TIMEOUT',
@@ -70,6 +87,7 @@ async function runStartupGate({
70
87
 
71
88
  const startedAt = Date.now();
72
89
  const deadline = startedAt + deadlineMs;
90
+ const stallEnabled = Number.isFinite(stallMs) && stallMs > 0;
73
91
  const seen = new Set();
74
92
  const matchedTriggers = [];
75
93
  // rc.4: remember the most recent successful pane snapshot. If the gate
@@ -78,8 +96,37 @@ async function runStartupGate({
78
96
  // this, "claude exits code 0 after dev-channels Enter" surfaces as a
79
97
  // 30-second `can't find pane` spam with no diagnostic about WHY.
80
98
  let lastPane = null;
99
+ // Progress-aware gate: timestamp of the last observed pane CHANGE (or
100
+ // trigger send). Only consulted when stallEnabled.
101
+ let lastActivityAt = startedAt;
102
+ // Music incident (2026-06-01): the stall timer must NOT arm while the pane
103
+ // is still BLANK. A blank-and-unchanging pane means claude hasn't started
104
+ // rendering yet (slow cold-start), NOT that it wedged — the TUI for some
105
+ // topics takes 30-45s to first-render. Arming the stall timer on a blank
106
+ // pane killed a legitimate slow spawn at stallMs with a false "wedged".
107
+ // So the stall clock only runs once the pane has shown non-whitespace
108
+ // content; before that, only the absolute `deadlineMs` governs.
109
+ let sawContent = false;
81
110
 
82
111
  while (Date.now() < deadline) {
112
+ // Stall check (progress-aware): the pane RENDERED something and has then
113
+ // been static for stallMs → genuinely wedged. Gated on sawContent so a
114
+ // blank cold-start isn't mistaken for a wedge. Fires early so a truly
115
+ // hung TUI fails fast, while an actively-progressing one (download bar,
116
+ // dialog navigation) keeps resetting lastActivityAt below.
117
+ if (stallEnabled && sawContent && Date.now() - lastActivityAt >= stallMs) {
118
+ const err = new Error(
119
+ `[${label}] startup gate: pane rendered then went static for ${stallMs}ms for ${tmuxName} ` +
120
+ `(matched: ${matchedTriggers.length ? matchedTriggers.join(', ') : 'none'}). ` +
121
+ `Appears wedged. Last pane content:\n` +
122
+ _formatPaneTail(lastPane),
123
+ );
124
+ err.code = timeoutCode;
125
+ err.lastPane = lastPane;
126
+ err.matchedTriggers = matchedTriggers;
127
+ err.reason = 'stall';
128
+ throw err;
129
+ }
83
130
  let pane;
84
131
  try {
85
132
  pane = await runner.captureWide(tmuxName);
@@ -107,6 +154,19 @@ async function runStartupGate({
107
154
  await new Promise(r => setTimeout(r, settleMs));
108
155
  continue;
109
156
  }
157
+ // First non-whitespace content = the TUI has started rendering. Only
158
+ // from here does the stall timer become meaningful (before this, a blank
159
+ // pane is cold-start, governed by the absolute deadline). Seed
160
+ // lastActivityAt at the moment content first appears so the stall window
161
+ // is measured from "rendered", not from spawn.
162
+ if (!sawContent && pane && pane.trim().length > 0) {
163
+ sawContent = true;
164
+ lastActivityAt = Date.now();
165
+ }
166
+ // Progress signal: any change in pane content is activity → reset the
167
+ // stall clock. A captureWide that returns the SAME bytes is NOT
168
+ // activity (a frozen download bar at 24% reads identically each poll).
169
+ if (pane !== lastPane) lastActivityAt = Date.now();
110
170
  lastPane = pane;
111
171
 
112
172
  // Walk triggers in declaration order — first match (and not yet seen) wins
@@ -122,6 +182,10 @@ async function runStartupGate({
122
182
  seen.add(trigger.name);
123
183
  matchedTriggers.push(trigger.name);
124
184
  matched = true;
185
+ // Sending a key is activity — navigating the TUI counts as progress
186
+ // even if the pre-transition pane text was static (e.g. a dialog we
187
+ // just answered). Reset the stall clock so we don't fail mid-nav.
188
+ lastActivityAt = Date.now();
125
189
  // Settle window so the TUI transitions out of the dialog before next poll
126
190
  await new Promise(r => setTimeout(r, settleMs));
127
191
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.12.0-rc.2",
3
+ "version": "0.12.0-rc.21",
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": {