polygram 0.10.0-rc.41 → 0.10.0-rc.43

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.
@@ -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.41",
4
+ "version": "0.10.0-rc.43",
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",
@@ -121,15 +121,26 @@ function pipeHookParser(tail) {
121
121
 
122
122
  /**
123
123
  * One-shot helper: build a LogTail at the given path with the
124
- * H1-typical config (watch mode, no skipExisting because a fresh
125
- * spawn's ndjson is empty), wire the hook parser, and return it.
126
- * Caller calls `.start()` and `.on('event', ...)`.
124
+ * H1-typical config (watch mode), wire the hook parser, and return
125
+ * it. Caller calls `.start()` and `.on('event', ...)`.
126
+ *
127
+ * `skipExisting`:
128
+ * - false (default) for a FRESH spawn — the ndjson was just
129
+ * touched at start time and is empty, so any future write IS a
130
+ * new event.
131
+ * - true for a `--resume` spawn — `writeHookFiles` uses 'a' mode
132
+ * (append) and never truncates, so the prior session's hook
133
+ * events are still on disk. Without skipExisting they replay
134
+ * into the fresh process, arming a Stop synth against the
135
+ * fresh turn (H4) and heartbeating it (H3) from stale events.
136
+ * rc.42 #5 (review-driven): mirror what `_armSessionLogTail`
137
+ * already does for the JSONL tail.
127
138
  */
128
- function createHookTail({ path: filePath, logger = console } = {}) {
139
+ function createHookTail({ path: filePath, skipExisting = false, logger = console } = {}) {
129
140
  const tail = new LogTail({
130
141
  path: filePath,
131
142
  intervalMs: 50,
132
- skipExisting: false,
143
+ skipExisting,
133
144
  useWatch: 'auto',
134
145
  logger,
135
146
  });
@@ -225,6 +225,32 @@ const DEFAULT_STOP_GRACE_MS = 2_000; // 2 s
225
225
  // block, but `_waitForReady` runs only at startup before any turn).
226
226
  const DEFAULT_READY_DEBUG_QUIET_MS = 1000;
227
227
 
228
+ // rc.43 (shumorobot Music topic, 2026-05-22 21:11): claude TUI on
229
+ // `--resume` of a session that has crossed an age/token threshold
230
+ // (~8h / ~120k tokens observed) renders an INTERACTIVE menu instead
231
+ // of the chat input:
232
+ //
233
+ // This session is 8h 38m old and 117.6k tokens.
234
+ // Resuming the full session will consume a substantial portion of
235
+ // your usage limits. We recommend resuming from a summary.
236
+ // ❯ 1. Resume from summary (recommended)
237
+ // 2. Resume full session as-is
238
+ // 3. Don't ask me again
239
+ // Enter to confirm · Esc to cancel
240
+ //
241
+ // `_waitForReady` doesn't recognise this menu — there's no
242
+ // `? for shortcuts` / `accept edits on` / `bypass permissions on`
243
+ // hint visible. It times out at `readyTimeoutMs` (120 s) and polygram
244
+ // fails the user's message with a generic "TUI did not signal ready"
245
+ // error. Detection in the pane and pressing Enter dismisses the menu
246
+ // (selects option 1 = resume from summary, the safe default).
247
+ //
248
+ // The detection regex matches a distinctive substring from the menu
249
+ // header that doesn't appear during normal turns or other startup
250
+ // banners — looking for the literal "Resume from summary" option
251
+ // combined with the "Resuming the full session" rationale.
252
+ const SESSION_AGE_PROMPT_RE = /Resuming the full session.*Resume from summary/s;
253
+
228
254
  // R7: sentinel returned by _awaitTurnComplete when its poll loop is
229
255
  // stopped by the caller's absolute-deadline abort (rather than by a
230
256
  // real READY quiescence or its own internal timeout). _runTurn maps
@@ -302,6 +328,22 @@ class TmuxProcess extends Process {
302
328
 
303
329
  // Tunables
304
330
  this.readyTimeoutMs = readyTimeoutMs;
331
+ // rc.42 #7 (review-driven): validate timer config at construction
332
+ // so a misconfigured process fails loud here instead of silently
333
+ // mid-turn (NaN → setInterval ≈1 ms spin; 0/negative → instant
334
+ // idle-timeout).
335
+ for (const [name, v] of [
336
+ ['turnTimeoutMs', turnTimeoutMs],
337
+ ['hardBackstopMs', hardBackstopMs],
338
+ ['stopGraceMs', stopGraceMs],
339
+ ]) {
340
+ if (!Number.isFinite(v) || v < 0) {
341
+ throw Object.assign(
342
+ new TypeError(`TmuxProcess: ${name} must be a finite non-negative number (got ${v})`),
343
+ { code: 'TMUX_INVALID_TIMEOUT_CONFIG', field: name, value: v },
344
+ );
345
+ }
346
+ }
305
347
  this.turnTimeoutMs = turnTimeoutMs;
306
348
  this.hardBackstopMs = hardBackstopMs;
307
349
  this.stopGraceMs = stopGraceMs;
@@ -645,8 +687,10 @@ class TmuxProcess extends Process {
645
687
  this._armSessionLogTail({ resuming: Boolean(ctx.existingSessionId) });
646
688
  // H1 — same-pattern hook tail. Only arm when the settings
647
689
  // write succeeded above (otherwise there's nothing to tail).
690
+ // rc.42 #5: on `--resume`, pass skipExisting through so
691
+ // prior-process hook events aren't replayed into this turn.
648
692
  if (this._hookNdjsonPath) {
649
- this._armHookTail();
693
+ this._armHookTail({ resuming: Boolean(ctx.existingSessionId) });
650
694
  }
651
695
 
652
696
  // G6 — block until TUI is responsive.
@@ -777,7 +821,22 @@ class TmuxProcess extends Process {
777
821
  this.inFlight = true;
778
822
  turn.state = 'pasted';
779
823
  turn.startedAt = this._now();
780
- const turnTimeoutMs = turn.opts.timeoutMs || this.turnTimeoutMs;
824
+ // rc.42 #7 (review-driven): validate the resolved turnTimeoutMs.
825
+ // NaN would coerce setInterval cadence to ≈1 ms (spin-loop);
826
+ // 0 or negative would trip the idle-ceiling on the first poll.
827
+ // Neither is reachable via current defaults, but a config override
828
+ // can produce them. Fall back to the instance default (already
829
+ // validated as a finite positive number at construction time).
830
+ const rawTimeoutMs = turn.opts.timeoutMs || this.turnTimeoutMs;
831
+ const turnTimeoutMs = (Number.isFinite(rawTimeoutMs) && rawTimeoutMs > 0)
832
+ ? rawTimeoutMs
833
+ : this.turnTimeoutMs;
834
+ if (turnTimeoutMs !== rawTimeoutMs) {
835
+ this.logger.warn?.(
836
+ `[${this.label}] invalid turn timeoutMs (${rawTimeoutMs}); `
837
+ + `falling back to ${turnTimeoutMs} ms`,
838
+ );
839
+ }
781
840
  // Internal turn-done signal — settled by _flushActiveGroup when
782
841
  // this turn's group is flushed on a terminal `result`.
783
842
  turn.resultPromise = new Promise((resolve) => { turn.settleResult = resolve; });
@@ -845,15 +904,46 @@ class TmuxProcess extends Process {
845
904
  // outstanding (subsumes B10 — capture can no
846
905
  // longer settle a turn mid-subagent, so the old
847
906
  // nested re-wait is unnecessary)
848
- // - timeout : W1 absolute deadline (one setTimeout, not a
849
- // racer)
907
+ // - timeout : EITHER idle-ceiling poller (#5a) OR
908
+ // hard-backstop setTimeout (#5b) — see H3
909
+ // in `_awaitSettle`. The `reason` field on
910
+ // the outcome carries which racer fired so
911
+ // operators can distinguish a wedged-silent
912
+ // turn (idle-ceiling) from a 4-hour runaway
913
+ // tool loop (hard-backstop).
850
914
  const outcome = await this._awaitSettle(turn, { turnTimeoutMs, confirmP });
851
915
 
852
916
  if (outcome.kind === 'submit-fail') throw outcome.err;
853
917
  if (outcome.kind === 'timeout') {
918
+ // rc.42 #1 (review-driven): thread the racer-specific
919
+ // `reason` + observed `idleMs` onto the thrown Error AND
920
+ // emit a `turn-timeout` event (mirrors sdk-process.js's
921
+ // pattern at line 532) so the events DB records WHICH
922
+ // racer fired. Pre-rc.42 the diagnostic value of H3 was
923
+ // silently dropped — operators couldn't distinguish a
924
+ // wedged-silent subagent (idle-ceiling) from a 4-hour
925
+ // runaway tool loop (hard-backstop).
926
+ this.logger.warn?.(
927
+ `[${this.label}] turn timeout (${outcome.reason || 'unknown'}`
928
+ + `${outcome.idleMs != null ? `, idle ${Math.round(outcome.idleMs)} ms` : ''})`,
929
+ );
930
+ this.emit('turn-timeout', {
931
+ turnId: turn.turnId,
932
+ reason: outcome.reason || null,
933
+ idleMs: outcome.idleMs ?? null,
934
+ turnTimeoutMs,
935
+ hardBackstopMs: this.hardBackstopMs,
936
+ sessionId: this.claudeSessionId,
937
+ backend: 'tmux',
938
+ });
854
939
  throw Object.assign(
855
940
  new Error('TmuxProcess: turn did not complete in time'),
856
- { code: 'TMUX_TURN_TIMEOUT', tmuxName: this.tmuxName },
941
+ {
942
+ code: 'TMUX_TURN_TIMEOUT',
943
+ tmuxName: this.tmuxName,
944
+ reason: outcome.reason || null,
945
+ idleMs: outcome.idleMs ?? null,
946
+ },
857
947
  );
858
948
  }
859
949
 
@@ -877,6 +967,20 @@ class TmuxProcess extends Process {
877
967
  resultSubtype = outcome.ev.subtype || 'success';
878
968
  stopReason = outcome.ev.stopReason || null;
879
969
  if (outcome.ev.sessionId) this.claudeSessionId = outcome.ev.sessionId;
970
+ // rc.42 #15 (review-driven): if the settle came via the H4
971
+ // Stop-hook synth (not the JSONL `result`), surface that
972
+ // distinction. Track it on `resolvedVia` so the result event
973
+ // downstream consumers see the provenance, and emit a
974
+ // `stop-hook-resolved` event for forensic count of how often
975
+ // Stop actually rescued a JSONL-stuck turn.
976
+ if (outcome.ev.via === 'stop-hook') {
977
+ resolvedVia = 'stop-hook';
978
+ this.emit('stop-hook-resolved', {
979
+ turnId: turn.turnId,
980
+ sessionId: this.claudeSessionId,
981
+ backend: 'tmux',
982
+ });
983
+ }
880
984
  // R10: a genuinely-empty terminal `result` — end_turn, no
881
985
  // reply text, AND no tool ran this turn — is the agent
882
986
  // producing literally nothing (a thinking-only terminal
@@ -1048,7 +1152,12 @@ class TmuxProcess extends Process {
1048
1152
  * TMUX_SUBMIT_FAILED (B7)
1049
1153
  * { kind: 'quiesced' } — capture-pane idle AND the predicate
1050
1154
  * says it is SAFE to conclude
1051
- * { kind: 'timeout' } — W1 absolute deadline
1155
+ * { kind: 'timeout',
1156
+ * reason: 'idle-ceiling' — H3 idle-poller (#5a)
1157
+ * | 'hard-backstop' — H3 absolute backstop (#5b)
1158
+ * | 'idle-poller-error' — defensive: throw inside the
1159
+ * idle-poller callback (rc.42 #3)
1160
+ * idleMs? } — observed idle for idle-ceiling
1052
1161
  *
1053
1162
  * The structural win over the old race:
1054
1163
  * - B7 gate: capture quiescence is ignored until
@@ -1149,9 +1258,21 @@ class TmuxProcess extends Process {
1149
1258
  Math.min(IDLE_POLL_INTERVAL_MS, Math.floor(turnTimeoutMs / 4)),
1150
1259
  );
1151
1260
  idlePoller = setInterval(() => {
1152
- const idleMs = this._now() - turn.lastActivityAt;
1153
- if (idleMs >= turnTimeoutMs) {
1154
- finish({ kind: 'timeout', reason: 'idle-ceiling', idleMs });
1261
+ // rc.42 #3 (review-driven): try/catch around the body.
1262
+ // Adaptive poll cadence can fire as often as every 50 ms;
1263
+ // a repeating throw here would trip process-guard's
1264
+ // 100-in-5s sliding-window panicExit(2) and kill the
1265
+ // daemon. Catch + finish-on-error fails fast instead.
1266
+ try {
1267
+ const idleMs = this._now() - turn.lastActivityAt;
1268
+ if (idleMs >= turnTimeoutMs) {
1269
+ finish({ kind: 'timeout', reason: 'idle-ceiling', idleMs });
1270
+ }
1271
+ } catch (err) {
1272
+ this.logger.warn?.(
1273
+ `[${this.label}] idle-poller error: ${err.message}`,
1274
+ );
1275
+ finish({ kind: 'timeout', reason: 'idle-poller-error' });
1155
1276
  }
1156
1277
  }, pollIntervalMs);
1157
1278
  idlePoller.unref?.();
@@ -1164,10 +1285,19 @@ class TmuxProcess extends Process {
1164
1285
  // from turn start.
1165
1286
  const backstopRemaining = Math.max(
1166
1287
  0, (turn.startedAt + this.hardBackstopMs) - this._now());
1167
- hardBackstopTimer = setTimeout(
1168
- () => finish({ kind: 'timeout', reason: 'hard-backstop' }),
1169
- backstopRemaining,
1170
- );
1288
+ // rc.42 #3 (review-driven): try/catch in the one-shot
1289
+ // setTimeout callback. Symmetric with the idle-poller +
1290
+ // Stop-synth fixes — protects against any future change
1291
+ // that introduces a throw surface here.
1292
+ hardBackstopTimer = setTimeout(() => {
1293
+ try {
1294
+ finish({ kind: 'timeout', reason: 'hard-backstop' });
1295
+ } catch (err) {
1296
+ this.logger.warn?.(
1297
+ `[${this.label}] hard-backstop error: ${err.message}`,
1298
+ );
1299
+ }
1300
+ }, backstopRemaining);
1171
1301
  hardBackstopTimer.unref?.();
1172
1302
  });
1173
1303
  }
@@ -1188,6 +1318,15 @@ class TmuxProcess extends Process {
1188
1318
  // rejects in `_runTurn`, never resolving `resultPromise`) would
1189
1319
  // otherwise leave a dangling Map entry. Defensive + cheap.
1190
1320
  if (turn?.token) this._submitConfirms.delete(turn.token);
1321
+ // rc.42 #6 (review-driven): clear the H4 Stop-synth setTimeout
1322
+ // when the turn retires through any other path. Idempotency
1323
+ // makes a post-retirement settleResult call harmless, but the
1324
+ // timer handle would otherwise sit in the event loop for up to
1325
+ // `stopGraceMs` past the turn's death.
1326
+ if (turn?._stopSynthTimer) {
1327
+ clearTimeout(turn._stopSynthTimer);
1328
+ turn._stopSynthTimer = null;
1329
+ }
1191
1330
  const qi = this.pendingQueue.indexOf(turn);
1192
1331
  if (qi >= 0) this.pendingQueue.splice(qi, 1);
1193
1332
  this._dropFromActiveGroup(turn);
@@ -1595,16 +1734,36 @@ class TmuxProcess extends Process {
1595
1734
  *
1596
1735
  * See docs/0.10.0-tmux-hook-observability.md.
1597
1736
  */
1598
- _armHookTail() {
1737
+ _armHookTail({ resuming = false } = {}) {
1599
1738
  if (this._hookTail) return; // idempotent
1600
1739
  if (!this._hookNdjsonPath) {
1601
1740
  this.logger.warn?.(`[${this.label}] _armHookTail: no ndjson path, skipping`);
1602
1741
  return;
1603
1742
  }
1604
- const tail = createHookTail({ path: this._hookNdjsonPath, logger: this.logger });
1743
+ // rc.42 #5 (review-driven): on `--resume`, the per-session hook
1744
+ // ndjson kept by `writeHookFiles` (opened in append mode) still
1745
+ // carries the prior process's events. `skipExisting:true`
1746
+ // mirrors `_armSessionLogTail`'s handling so historic Stop
1747
+ // events don't replay into the fresh turn (would arm a synth
1748
+ // settle on a freshly-pasted prompt with stale text) and stale
1749
+ // heartbeats don't reset the new turn's idle clock.
1750
+ const tail = createHookTail({
1751
+ path: this._hookNdjsonPath,
1752
+ skipExisting: resuming,
1753
+ logger: this.logger,
1754
+ });
1605
1755
  tail.on('event', (ev) => this._handleHookEvent(ev));
1606
1756
  tail.on('error', (err) => {
1607
1757
  this.logger.warn?.(`[${this.label}] hook-tail error: ${err.message}`);
1758
+ // rc.42 #8 (review-driven): make the tail-degradation
1759
+ // observable so msg-884-shaped silent regressions surface in
1760
+ // the events DB instead of just the daemon log.
1761
+ this.emit('hook-tail-error', {
1762
+ message: err.message,
1763
+ path: this._hookNdjsonPath,
1764
+ sessionId: this.claudeSessionId,
1765
+ backend: 'tmux',
1766
+ });
1608
1767
  });
1609
1768
  tail.start();
1610
1769
  this._hookTail = tail;
@@ -1635,54 +1794,97 @@ class TmuxProcess extends Process {
1635
1794
  * forwarded — observer-only metrics for stream-reliability soak.
1636
1795
  */
1637
1796
  _handleHookEvent(ev) {
1638
- // H3: every hook event (except the diagnostic types) is liveness
1639
- // evidence. Heartbeat every turn we can identify as in-flight so
1640
- // the idle-ceiling poller resets. We don't differentiate by event
1641
- // type even Notification or UserPromptSubmit prove claude is
1642
- // active in this session.
1643
- //
1644
- // Two scopes are searched (deduped via Set): active group turns
1645
- // (the steady state once `user-message` has landed) AND the
1646
- // pendingQueue head (the PRE-active window between turn start
1647
- // and the first `user-message`). Hook events can fire in either
1648
- // window e.g. `UserPromptSubmit` arrives just after claude
1649
- // receives the paste but BEFORE the `user-message` is echoed
1650
- // back into the JSONL. Without the pendingQueue fallback, that
1651
- // window leaves the turn un-heartbeated and the idle poller
1652
- // could fire on a turn that's actively starting up.
1653
- if (ev?.type && ev.type !== 'parse-error' && ev.type !== 'unknown') {
1654
- const turns = new Set(this._activeGroup?.turns || []);
1655
- const head = this.pendingQueue[0];
1656
- if (head) turns.add(head);
1657
- for (const t of turns) {
1658
- this._heartbeat(t, `hook:${ev.type}`);
1797
+ // rc.42 #2 (review-driven): wrap the whole body in try/catch.
1798
+ // pipeHookParser emits 'event' synchronously inside LogTail's
1799
+ // `for (const line of parts)` loop in _readNew; a throw here
1800
+ // would propagate back into that loop AFTER _offset is already
1801
+ // advanced past the unread lines, silently dropping every
1802
+ // remaining line in the batch. With H3 making hook events
1803
+ // load-bearing for liveness, lost events cause false idle
1804
+ // timeouts. Catch + warn + continue keeps the rest of the
1805
+ // batch flowing.
1806
+ try {
1807
+ // H3: every hook event (except the diagnostic types) is liveness
1808
+ // evidence. Heartbeat every turn we can identify as in-flight so
1809
+ // the idle-ceiling poller resets. We don't differentiate by event
1810
+ // type even Notification or UserPromptSubmit prove claude is
1811
+ // active in this session.
1812
+ //
1813
+ // Two scopes are searched (deduped via Set): active group turns
1814
+ // (the steady state once `user-message` has landed) AND the
1815
+ // pendingQueue head (the PRE-active window between turn start
1816
+ // and the first `user-message`). Hook events can fire in either
1817
+ // window — e.g. `UserPromptSubmit` arrives just after claude
1818
+ // receives the paste but BEFORE the `user-message` is echoed
1819
+ // back into the JSONL. Without the pendingQueue fallback, that
1820
+ // window leaves the turn un-heartbeated and the idle poller
1821
+ // could fire on a turn that's actively starting up.
1822
+ if (ev?.type && ev.type !== 'parse-error' && ev.type !== 'unknown') {
1823
+ const turns = new Set(this._activeGroup?.turns || []);
1824
+ const head = this.pendingQueue[0];
1825
+ if (head) turns.add(head);
1826
+ for (const t of turns) {
1827
+ this._heartbeat(t, `hook:${ev.type}`);
1828
+ }
1659
1829
  }
1660
- }
1661
- // H4: Stop hook synthesize a settle for the primary turn after
1662
- // a grace, so JSONL `result` (which carries richer metadata)
1663
- // wins when both fire. If JSONL never arrives broken stream,
1664
- // stuck parser the Stop synth settles the turn instead of
1665
- // stranding it. Idempotent: a later JSONL settleResult call is
1666
- // a no-op once the promise has resolved.
1667
- if (ev?.type === 'Stop') {
1668
- const primary = (this._activeGroup?.turns || [])
1669
- .find((t) => t.kind === 'primary');
1670
- if (primary && typeof primary.settleResult === 'function') {
1671
- const synth = {
1672
- text: primary.text || ev.lastAssistantMessage || '',
1673
- subtype: 'success',
1674
- stopReason: 'stop_hook',
1675
- sessionId: this.claudeSessionId,
1676
- via: 'stop-hook',
1677
- };
1678
- const timer = setTimeout(
1679
- () => primary.settleResult(synth),
1680
- this.stopGraceMs,
1681
- );
1682
- timer.unref?.();
1830
+ // H4: Stop hook → synthesize a settle for the primary turn after
1831
+ // a grace, so JSONL `result` (which carries richer metadata)
1832
+ // wins when both fire. If JSONL never arrives broken stream,
1833
+ // stuck parser the Stop synth settles the turn instead of
1834
+ // stranding it. Idempotent: a later JSONL settleResult call is
1835
+ // a no-op once the promise has resolved.
1836
+ //
1837
+ // rc.42 #6 (review-driven): per-primary `_stopSynthScheduled`
1838
+ // guard + stored timer ref so kill()/`_finishTurn` can clear
1839
+ // the pending synth. Without these, repeated Stop events
1840
+ // accumulate N independent timers (rare in production, but a
1841
+ // possible memory leak), and a synth scheduled against a
1842
+ // primary that retires via another path (timeout, interrupt)
1843
+ // fires post-mortem against a freed promise. Idempotency
1844
+ // makes both harmless TODAY; defensive future-proofing.
1845
+ if (ev?.type === 'Stop') {
1846
+ const primary = (this._activeGroup?.turns || [])
1847
+ .find((t) => t.kind === 'primary');
1848
+ if (primary
1849
+ && typeof primary.settleResult === 'function'
1850
+ && !primary._stopSynthScheduled) {
1851
+ const synth = {
1852
+ text: primary.text || ev.lastAssistantMessage || '',
1853
+ subtype: 'success',
1854
+ stopReason: 'stop_hook',
1855
+ sessionId: this.claudeSessionId,
1856
+ via: 'stop-hook',
1857
+ };
1858
+ primary._stopSynthScheduled = true;
1859
+ // rc.42 #3 (review-driven): try/catch in the timer callback.
1860
+ // settleResult is a Promise resolver (cannot throw under
1861
+ // current spec), but a future refactor where settleResult
1862
+ // gates on instance state could; the surrounding setTimeout
1863
+ // has no recovery path otherwise.
1864
+ primary._stopSynthTimer = setTimeout(() => {
1865
+ try {
1866
+ // Recheck the turn is still in a state where the synth
1867
+ // is meaningful — if `_finishTurn` already retired it,
1868
+ // settleResult is idempotent but skipping is cleaner.
1869
+ if (typeof primary.settleResult === 'function') {
1870
+ primary.settleResult(synth);
1871
+ }
1872
+ } catch (err) {
1873
+ this.logger.warn?.(
1874
+ `[${this.label}] Stop-synth settle error: ${err.message}`,
1875
+ );
1876
+ }
1877
+ }, this.stopGraceMs);
1878
+ primary._stopSynthTimer.unref?.();
1879
+ }
1683
1880
  }
1881
+ this.emit('hook-event', ev);
1882
+ } catch (err) {
1883
+ this.logger.warn?.(
1884
+ `[${this.label}] _handleHookEvent error (${ev?.type || 'unknown'}): `
1885
+ + `${err.message}`,
1886
+ );
1684
1887
  }
1685
- this.emit('hook-event', ev);
1686
1888
  }
1687
1889
 
1688
1890
  _handleSessionEvent(ev) {
@@ -2415,6 +2617,10 @@ class TmuxProcess extends Process {
2415
2617
  // next polls, which is what resets the clock.
2416
2618
  let prevDebugSize = null;
2417
2619
  let lastGrowthAt = null;
2620
+ // rc.43: track whether we've already dismissed the session-age
2621
+ // prompt this wait, so we don't fire Enter every poll if claude
2622
+ // is slow to re-render after dismissing it.
2623
+ let sessionAgePromptDismissed = false;
2418
2624
  if (this.pollScheduler) this.pollScheduler.acquire();
2419
2625
  try {
2420
2626
  while (this._now() < deadline) {
@@ -2422,6 +2628,41 @@ class TmuxProcess extends Process {
2422
2628
  // pane. Polling 1000 lines each tick is wasteful — cap at 80
2423
2629
  // for a ~12× cheaper tmux subprocess.
2424
2630
  lastBuf = await this.runner.captureWide(this.tmuxName, { lines: 80 });
2631
+ // rc.43: if claude rendered the session-age "resume from
2632
+ // summary" prompt (only happens on `--resume` of an aged
2633
+ // session, see SESSION_AGE_PROMPT_RE comment), press Enter
2634
+ // once to confirm the default selection (option 1 — resume
2635
+ // from summary) and emit a `session-age-prompt-dismissed`
2636
+ // event for forensics. The TUI then proceeds to load the
2637
+ // summary and the ready hint appears normally; subsequent
2638
+ // polls take the standard B6/B8 path.
2639
+ if (!sessionAgePromptDismissed
2640
+ && SESSION_AGE_PROMPT_RE.test(lastBuf)) {
2641
+ this.logger.warn?.(
2642
+ `[${this.label}] claude TUI showed session-age resume prompt; `
2643
+ + `auto-dismissing with Enter (select option 1 — resume from summary)`,
2644
+ );
2645
+ this.emit('session-age-prompt-dismissed', {
2646
+ sessionId: this.claudeSessionId,
2647
+ backend: 'tmux',
2648
+ });
2649
+ try {
2650
+ await this.runner.sendControl(this.tmuxName, 'Enter');
2651
+ } catch (err) {
2652
+ this.logger.warn?.(
2653
+ `[${this.label}] sendControl(Enter) for session-age dismissal failed: `
2654
+ + `${err.message}`,
2655
+ );
2656
+ }
2657
+ sessionAgePromptDismissed = true;
2658
+ // Reset readiness clock + prev-pane so the menu's content
2659
+ // doesn't satisfy the byte-stability check while claude is
2660
+ // reloading from the summary.
2661
+ readySinceAt = null;
2662
+ prevBuf = null;
2663
+ await this._waitForNextTick();
2664
+ continue;
2665
+ }
2425
2666
  // Ready ⇔ the hint is on the pane AND the pane is identical to
2426
2667
  // the previous poll (the MCP-loading repaint storm has
2427
2668
  // stopped). The first poll has no previous buffer to compare,
@@ -3008,6 +3249,16 @@ class TmuxProcess extends Process {
3008
3249
  for (const finish of [...this._submitConfirms.values()]) {
3009
3250
  try { finish(); } catch { /* swallow */ }
3010
3251
  }
3252
+ // rc.42 #6 (review-driven): drop pending H4 Stop-synth timers
3253
+ // across every turn the ledger still holds. Symmetric with the
3254
+ // _finishTurn cleanup — kill() bypasses _finishTurn for the
3255
+ // drainQueue'd turns, so do it here.
3256
+ for (const turn of this._ledger) {
3257
+ if (turn?._stopSynthTimer) {
3258
+ try { clearTimeout(turn._stopSynthTimer); } catch { /* swallow */ }
3259
+ turn._stopSynthTimer = null;
3260
+ }
3261
+ }
3011
3262
  if (this._sessionLogTail) {
3012
3263
  try { this._sessionLogTail.close(); } catch { /* swallow */ }
3013
3264
  this._sessionLogTail = null;
@@ -3039,4 +3290,6 @@ class TmuxProcess extends Process {
3039
3290
  module.exports = {
3040
3291
  TmuxProcess,
3041
3292
  CLAUDE_CLI_PINNED_VERSION,
3293
+ // rc.43 — exported for unit-test coverage of the menu pattern.
3294
+ SESSION_AGE_PROMPT_RE,
3042
3295
  };
@@ -98,6 +98,31 @@ const CALLBACK_TO_EVENT = {
98
98
  // unification). SDK backend never emits — hooks are tmux-specific.
99
99
  // See docs/0.10.0-tmux-hook-observability.md.
100
100
  onHookEvent: 'hook-event',
101
+ // 0.10.0 rc.42 (review-driven #1): tmux backend turn-timeout event.
102
+ // Mirrors sdk-process.js's `_logEvent('turn-timeout', ...)` so both
103
+ // backends emit the same diagnostic. Payload distinguishes
104
+ // `idle-ceiling` vs `hard-backstop` (the H3 racers) so operators can
105
+ // tell a wedged-silent subagent from a runaway tool loop.
106
+ onTurnTimeout: 'turn-timeout',
107
+ // 0.10.0 rc.42 (review-driven #8): tmux backend hook-tail
108
+ // degradation event. The hook ndjson is load-bearing for H3 idle
109
+ // heartbeats; a persistently broken tail silently resurrects
110
+ // msg-884-class kills. Emitting the event surfaces the degradation
111
+ // in the events DB so it's visible in forensics, not just
112
+ // logger.warn.
113
+ onHookTailError: 'hook-tail-error',
114
+ // 0.10.0 rc.42 (review-driven #15): tmux backend stop-hook-resolved
115
+ // event. Fires when a turn settled via the H4 Stop-hook synth path
116
+ // instead of the canonical JSONL `result` (i.e. JSONL was broken or
117
+ // stuck and Stop rescued the turn). The synth's `via: 'stop-hook'`
118
+ // field was previously dead — only the tests read it. Persisting
119
+ // the event lets the soak count how often H4 actually fires its
120
+ // rescue contract.
121
+ onStopHookResolved: 'stop-hook-resolved',
122
+ // 0.10.0 rc.43: claude TUI's "This session is N old…" interactive
123
+ // menu auto-dismissed by `_waitForReady`. Surfacing the event so
124
+ // soak can count how often aged-session resumes hit this path.
125
+ onSessionAgePromptDismissed: 'session-age-prompt-dismissed',
101
126
  };
102
127
 
103
128
  class ProcessManager {
@@ -459,6 +459,84 @@ function createSdkCallbacks({
459
459
  }
460
460
  },
461
461
 
462
+ // 0.10.0 rc.42 #1: tmux backend turn-timeout observability.
463
+ // H3 introduced two timeout racers (idle-ceiling, hard-backstop)
464
+ // but their `reason`/`idleMs` were silently dropped at the throw
465
+ // site, so the events DB couldn't distinguish a wedged-silent
466
+ // subagent (msg-884 shape) from a 4-hour runaway tool loop. The
467
+ // handler persists the distinguisher.
468
+ onTurnTimeout: (sessionKey, payload /* , entry */) => {
469
+ try {
470
+ logEvent('turn-timeout', {
471
+ chat_id: getChatIdFromKey(sessionKey),
472
+ session_key: sessionKey,
473
+ backend: 'tmux',
474
+ turn_id: payload?.turnId ?? null,
475
+ reason: payload?.reason ?? null,
476
+ idle_ms: payload?.idleMs ?? null,
477
+ turn_timeout_ms: payload?.turnTimeoutMs ?? null,
478
+ hard_backstop_ms: payload?.hardBackstopMs ?? null,
479
+ claude_session_id: payload?.sessionId ?? null,
480
+ });
481
+ } catch (err) {
482
+ logger.error?.(`[${botName}] turn-timeout handler: ${err.message}`);
483
+ }
484
+ },
485
+
486
+ // 0.10.0 rc.42 #8: tmux backend hook-tail error observability.
487
+ // Persistent failures of the hook ndjson tail degrade H3 idle-
488
+ // ceiling accuracy and H4 Stop-synth coverage with no surface
489
+ // signal. Record one event per error so post-mortem can correlate
490
+ // unexpected idle-timeouts to a broken tail.
491
+ onHookTailError: (sessionKey, payload /* , entry */) => {
492
+ try {
493
+ logEvent('hook-tail-error', {
494
+ chat_id: getChatIdFromKey(sessionKey),
495
+ session_key: sessionKey,
496
+ backend: 'tmux',
497
+ message: (payload?.message || '').slice(0, 200),
498
+ path: payload?.path ?? null,
499
+ claude_session_id: payload?.sessionId ?? null,
500
+ });
501
+ } catch (err) {
502
+ logger.error?.(`[${botName}] hook-tail-error handler: ${err.message}`);
503
+ }
504
+ },
505
+
506
+ // 0.10.0 rc.42 #15: H4 Stop-hook synth fired and won the race
507
+ // against JSONL `result` (or JSONL never landed). Forensic count
508
+ // of how often Stop actually rescues a stuck JSONL stream.
509
+ onStopHookResolved: (sessionKey, payload /* , entry */) => {
510
+ try {
511
+ logEvent('stop-hook-resolved', {
512
+ chat_id: getChatIdFromKey(sessionKey),
513
+ session_key: sessionKey,
514
+ backend: 'tmux',
515
+ turn_id: payload?.turnId ?? null,
516
+ claude_session_id: payload?.sessionId ?? null,
517
+ });
518
+ } catch (err) {
519
+ logger.error?.(`[${botName}] stop-hook-resolved handler: ${err.message}`);
520
+ }
521
+ },
522
+
523
+ // 0.10.0 rc.43: claude TUI session-age resume prompt was
524
+ // auto-dismissed by `_waitForReady`. Counting these helps decide
525
+ // whether to push the "Don't ask me again" option globally vs
526
+ // keep the auto-dismiss as a safety net.
527
+ onSessionAgePromptDismissed: (sessionKey, payload /* , entry */) => {
528
+ try {
529
+ logEvent('session-age-prompt-dismissed', {
530
+ chat_id: getChatIdFromKey(sessionKey),
531
+ session_key: sessionKey,
532
+ backend: 'tmux',
533
+ claude_session_id: payload?.sessionId ?? null,
534
+ });
535
+ } catch (err) {
536
+ logger.error?.(`[${botName}] session-age-prompt-dismissed handler: ${err.message}`);
537
+ }
538
+ },
539
+
462
540
  onInjectFail: (sessionKey, payload /* , entry */) => {
463
541
  try {
464
542
  const msgId = payload?.msgId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.41",
3
+ "version": "0.10.0-rc.43",
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": {