polygram 0.10.0-rc.41 → 0.10.0-rc.42

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.42",
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
  });
@@ -302,6 +302,22 @@ class TmuxProcess extends Process {
302
302
 
303
303
  // Tunables
304
304
  this.readyTimeoutMs = readyTimeoutMs;
305
+ // rc.42 #7 (review-driven): validate timer config at construction
306
+ // so a misconfigured process fails loud here instead of silently
307
+ // mid-turn (NaN → setInterval ≈1 ms spin; 0/negative → instant
308
+ // idle-timeout).
309
+ for (const [name, v] of [
310
+ ['turnTimeoutMs', turnTimeoutMs],
311
+ ['hardBackstopMs', hardBackstopMs],
312
+ ['stopGraceMs', stopGraceMs],
313
+ ]) {
314
+ if (!Number.isFinite(v) || v < 0) {
315
+ throw Object.assign(
316
+ new TypeError(`TmuxProcess: ${name} must be a finite non-negative number (got ${v})`),
317
+ { code: 'TMUX_INVALID_TIMEOUT_CONFIG', field: name, value: v },
318
+ );
319
+ }
320
+ }
305
321
  this.turnTimeoutMs = turnTimeoutMs;
306
322
  this.hardBackstopMs = hardBackstopMs;
307
323
  this.stopGraceMs = stopGraceMs;
@@ -645,8 +661,10 @@ class TmuxProcess extends Process {
645
661
  this._armSessionLogTail({ resuming: Boolean(ctx.existingSessionId) });
646
662
  // H1 — same-pattern hook tail. Only arm when the settings
647
663
  // write succeeded above (otherwise there's nothing to tail).
664
+ // rc.42 #5: on `--resume`, pass skipExisting through so
665
+ // prior-process hook events aren't replayed into this turn.
648
666
  if (this._hookNdjsonPath) {
649
- this._armHookTail();
667
+ this._armHookTail({ resuming: Boolean(ctx.existingSessionId) });
650
668
  }
651
669
 
652
670
  // G6 — block until TUI is responsive.
@@ -777,7 +795,22 @@ class TmuxProcess extends Process {
777
795
  this.inFlight = true;
778
796
  turn.state = 'pasted';
779
797
  turn.startedAt = this._now();
780
- const turnTimeoutMs = turn.opts.timeoutMs || this.turnTimeoutMs;
798
+ // rc.42 #7 (review-driven): validate the resolved turnTimeoutMs.
799
+ // NaN would coerce setInterval cadence to ≈1 ms (spin-loop);
800
+ // 0 or negative would trip the idle-ceiling on the first poll.
801
+ // Neither is reachable via current defaults, but a config override
802
+ // can produce them. Fall back to the instance default (already
803
+ // validated as a finite positive number at construction time).
804
+ const rawTimeoutMs = turn.opts.timeoutMs || this.turnTimeoutMs;
805
+ const turnTimeoutMs = (Number.isFinite(rawTimeoutMs) && rawTimeoutMs > 0)
806
+ ? rawTimeoutMs
807
+ : this.turnTimeoutMs;
808
+ if (turnTimeoutMs !== rawTimeoutMs) {
809
+ this.logger.warn?.(
810
+ `[${this.label}] invalid turn timeoutMs (${rawTimeoutMs}); `
811
+ + `falling back to ${turnTimeoutMs} ms`,
812
+ );
813
+ }
781
814
  // Internal turn-done signal — settled by _flushActiveGroup when
782
815
  // this turn's group is flushed on a terminal `result`.
783
816
  turn.resultPromise = new Promise((resolve) => { turn.settleResult = resolve; });
@@ -845,15 +878,46 @@ class TmuxProcess extends Process {
845
878
  // outstanding (subsumes B10 — capture can no
846
879
  // longer settle a turn mid-subagent, so the old
847
880
  // nested re-wait is unnecessary)
848
- // - timeout : W1 absolute deadline (one setTimeout, not a
849
- // racer)
881
+ // - timeout : EITHER idle-ceiling poller (#5a) OR
882
+ // hard-backstop setTimeout (#5b) — see H3
883
+ // in `_awaitSettle`. The `reason` field on
884
+ // the outcome carries which racer fired so
885
+ // operators can distinguish a wedged-silent
886
+ // turn (idle-ceiling) from a 4-hour runaway
887
+ // tool loop (hard-backstop).
850
888
  const outcome = await this._awaitSettle(turn, { turnTimeoutMs, confirmP });
851
889
 
852
890
  if (outcome.kind === 'submit-fail') throw outcome.err;
853
891
  if (outcome.kind === 'timeout') {
892
+ // rc.42 #1 (review-driven): thread the racer-specific
893
+ // `reason` + observed `idleMs` onto the thrown Error AND
894
+ // emit a `turn-timeout` event (mirrors sdk-process.js's
895
+ // pattern at line 532) so the events DB records WHICH
896
+ // racer fired. Pre-rc.42 the diagnostic value of H3 was
897
+ // silently dropped — operators couldn't distinguish a
898
+ // wedged-silent subagent (idle-ceiling) from a 4-hour
899
+ // runaway tool loop (hard-backstop).
900
+ this.logger.warn?.(
901
+ `[${this.label}] turn timeout (${outcome.reason || 'unknown'}`
902
+ + `${outcome.idleMs != null ? `, idle ${Math.round(outcome.idleMs)} ms` : ''})`,
903
+ );
904
+ this.emit('turn-timeout', {
905
+ turnId: turn.turnId,
906
+ reason: outcome.reason || null,
907
+ idleMs: outcome.idleMs ?? null,
908
+ turnTimeoutMs,
909
+ hardBackstopMs: this.hardBackstopMs,
910
+ sessionId: this.claudeSessionId,
911
+ backend: 'tmux',
912
+ });
854
913
  throw Object.assign(
855
914
  new Error('TmuxProcess: turn did not complete in time'),
856
- { code: 'TMUX_TURN_TIMEOUT', tmuxName: this.tmuxName },
915
+ {
916
+ code: 'TMUX_TURN_TIMEOUT',
917
+ tmuxName: this.tmuxName,
918
+ reason: outcome.reason || null,
919
+ idleMs: outcome.idleMs ?? null,
920
+ },
857
921
  );
858
922
  }
859
923
 
@@ -877,6 +941,20 @@ class TmuxProcess extends Process {
877
941
  resultSubtype = outcome.ev.subtype || 'success';
878
942
  stopReason = outcome.ev.stopReason || null;
879
943
  if (outcome.ev.sessionId) this.claudeSessionId = outcome.ev.sessionId;
944
+ // rc.42 #15 (review-driven): if the settle came via the H4
945
+ // Stop-hook synth (not the JSONL `result`), surface that
946
+ // distinction. Track it on `resolvedVia` so the result event
947
+ // downstream consumers see the provenance, and emit a
948
+ // `stop-hook-resolved` event for forensic count of how often
949
+ // Stop actually rescued a JSONL-stuck turn.
950
+ if (outcome.ev.via === 'stop-hook') {
951
+ resolvedVia = 'stop-hook';
952
+ this.emit('stop-hook-resolved', {
953
+ turnId: turn.turnId,
954
+ sessionId: this.claudeSessionId,
955
+ backend: 'tmux',
956
+ });
957
+ }
880
958
  // R10: a genuinely-empty terminal `result` — end_turn, no
881
959
  // reply text, AND no tool ran this turn — is the agent
882
960
  // producing literally nothing (a thinking-only terminal
@@ -1048,7 +1126,12 @@ class TmuxProcess extends Process {
1048
1126
  * TMUX_SUBMIT_FAILED (B7)
1049
1127
  * { kind: 'quiesced' } — capture-pane idle AND the predicate
1050
1128
  * says it is SAFE to conclude
1051
- * { kind: 'timeout' } — W1 absolute deadline
1129
+ * { kind: 'timeout',
1130
+ * reason: 'idle-ceiling' — H3 idle-poller (#5a)
1131
+ * | 'hard-backstop' — H3 absolute backstop (#5b)
1132
+ * | 'idle-poller-error' — defensive: throw inside the
1133
+ * idle-poller callback (rc.42 #3)
1134
+ * idleMs? } — observed idle for idle-ceiling
1052
1135
  *
1053
1136
  * The structural win over the old race:
1054
1137
  * - B7 gate: capture quiescence is ignored until
@@ -1149,9 +1232,21 @@ class TmuxProcess extends Process {
1149
1232
  Math.min(IDLE_POLL_INTERVAL_MS, Math.floor(turnTimeoutMs / 4)),
1150
1233
  );
1151
1234
  idlePoller = setInterval(() => {
1152
- const idleMs = this._now() - turn.lastActivityAt;
1153
- if (idleMs >= turnTimeoutMs) {
1154
- finish({ kind: 'timeout', reason: 'idle-ceiling', idleMs });
1235
+ // rc.42 #3 (review-driven): try/catch around the body.
1236
+ // Adaptive poll cadence can fire as often as every 50 ms;
1237
+ // a repeating throw here would trip process-guard's
1238
+ // 100-in-5s sliding-window panicExit(2) and kill the
1239
+ // daemon. Catch + finish-on-error fails fast instead.
1240
+ try {
1241
+ const idleMs = this._now() - turn.lastActivityAt;
1242
+ if (idleMs >= turnTimeoutMs) {
1243
+ finish({ kind: 'timeout', reason: 'idle-ceiling', idleMs });
1244
+ }
1245
+ } catch (err) {
1246
+ this.logger.warn?.(
1247
+ `[${this.label}] idle-poller error: ${err.message}`,
1248
+ );
1249
+ finish({ kind: 'timeout', reason: 'idle-poller-error' });
1155
1250
  }
1156
1251
  }, pollIntervalMs);
1157
1252
  idlePoller.unref?.();
@@ -1164,10 +1259,19 @@ class TmuxProcess extends Process {
1164
1259
  // from turn start.
1165
1260
  const backstopRemaining = Math.max(
1166
1261
  0, (turn.startedAt + this.hardBackstopMs) - this._now());
1167
- hardBackstopTimer = setTimeout(
1168
- () => finish({ kind: 'timeout', reason: 'hard-backstop' }),
1169
- backstopRemaining,
1170
- );
1262
+ // rc.42 #3 (review-driven): try/catch in the one-shot
1263
+ // setTimeout callback. Symmetric with the idle-poller +
1264
+ // Stop-synth fixes — protects against any future change
1265
+ // that introduces a throw surface here.
1266
+ hardBackstopTimer = setTimeout(() => {
1267
+ try {
1268
+ finish({ kind: 'timeout', reason: 'hard-backstop' });
1269
+ } catch (err) {
1270
+ this.logger.warn?.(
1271
+ `[${this.label}] hard-backstop error: ${err.message}`,
1272
+ );
1273
+ }
1274
+ }, backstopRemaining);
1171
1275
  hardBackstopTimer.unref?.();
1172
1276
  });
1173
1277
  }
@@ -1188,6 +1292,15 @@ class TmuxProcess extends Process {
1188
1292
  // rejects in `_runTurn`, never resolving `resultPromise`) would
1189
1293
  // otherwise leave a dangling Map entry. Defensive + cheap.
1190
1294
  if (turn?.token) this._submitConfirms.delete(turn.token);
1295
+ // rc.42 #6 (review-driven): clear the H4 Stop-synth setTimeout
1296
+ // when the turn retires through any other path. Idempotency
1297
+ // makes a post-retirement settleResult call harmless, but the
1298
+ // timer handle would otherwise sit in the event loop for up to
1299
+ // `stopGraceMs` past the turn's death.
1300
+ if (turn?._stopSynthTimer) {
1301
+ clearTimeout(turn._stopSynthTimer);
1302
+ turn._stopSynthTimer = null;
1303
+ }
1191
1304
  const qi = this.pendingQueue.indexOf(turn);
1192
1305
  if (qi >= 0) this.pendingQueue.splice(qi, 1);
1193
1306
  this._dropFromActiveGroup(turn);
@@ -1595,16 +1708,36 @@ class TmuxProcess extends Process {
1595
1708
  *
1596
1709
  * See docs/0.10.0-tmux-hook-observability.md.
1597
1710
  */
1598
- _armHookTail() {
1711
+ _armHookTail({ resuming = false } = {}) {
1599
1712
  if (this._hookTail) return; // idempotent
1600
1713
  if (!this._hookNdjsonPath) {
1601
1714
  this.logger.warn?.(`[${this.label}] _armHookTail: no ndjson path, skipping`);
1602
1715
  return;
1603
1716
  }
1604
- const tail = createHookTail({ path: this._hookNdjsonPath, logger: this.logger });
1717
+ // rc.42 #5 (review-driven): on `--resume`, the per-session hook
1718
+ // ndjson kept by `writeHookFiles` (opened in append mode) still
1719
+ // carries the prior process's events. `skipExisting:true`
1720
+ // mirrors `_armSessionLogTail`'s handling so historic Stop
1721
+ // events don't replay into the fresh turn (would arm a synth
1722
+ // settle on a freshly-pasted prompt with stale text) and stale
1723
+ // heartbeats don't reset the new turn's idle clock.
1724
+ const tail = createHookTail({
1725
+ path: this._hookNdjsonPath,
1726
+ skipExisting: resuming,
1727
+ logger: this.logger,
1728
+ });
1605
1729
  tail.on('event', (ev) => this._handleHookEvent(ev));
1606
1730
  tail.on('error', (err) => {
1607
1731
  this.logger.warn?.(`[${this.label}] hook-tail error: ${err.message}`);
1732
+ // rc.42 #8 (review-driven): make the tail-degradation
1733
+ // observable so msg-884-shaped silent regressions surface in
1734
+ // the events DB instead of just the daemon log.
1735
+ this.emit('hook-tail-error', {
1736
+ message: err.message,
1737
+ path: this._hookNdjsonPath,
1738
+ sessionId: this.claudeSessionId,
1739
+ backend: 'tmux',
1740
+ });
1608
1741
  });
1609
1742
  tail.start();
1610
1743
  this._hookTail = tail;
@@ -1635,54 +1768,97 @@ class TmuxProcess extends Process {
1635
1768
  * forwarded — observer-only metrics for stream-reliability soak.
1636
1769
  */
1637
1770
  _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}`);
1771
+ // rc.42 #2 (review-driven): wrap the whole body in try/catch.
1772
+ // pipeHookParser emits 'event' synchronously inside LogTail's
1773
+ // `for (const line of parts)` loop in _readNew; a throw here
1774
+ // would propagate back into that loop AFTER _offset is already
1775
+ // advanced past the unread lines, silently dropping every
1776
+ // remaining line in the batch. With H3 making hook events
1777
+ // load-bearing for liveness, lost events cause false idle
1778
+ // timeouts. Catch + warn + continue keeps the rest of the
1779
+ // batch flowing.
1780
+ try {
1781
+ // H3: every hook event (except the diagnostic types) is liveness
1782
+ // evidence. Heartbeat every turn we can identify as in-flight so
1783
+ // the idle-ceiling poller resets. We don't differentiate by event
1784
+ // type even Notification or UserPromptSubmit prove claude is
1785
+ // active in this session.
1786
+ //
1787
+ // Two scopes are searched (deduped via Set): active group turns
1788
+ // (the steady state once `user-message` has landed) AND the
1789
+ // pendingQueue head (the PRE-active window between turn start
1790
+ // and the first `user-message`). Hook events can fire in either
1791
+ // window — e.g. `UserPromptSubmit` arrives just after claude
1792
+ // receives the paste but BEFORE the `user-message` is echoed
1793
+ // back into the JSONL. Without the pendingQueue fallback, that
1794
+ // window leaves the turn un-heartbeated and the idle poller
1795
+ // could fire on a turn that's actively starting up.
1796
+ if (ev?.type && ev.type !== 'parse-error' && ev.type !== 'unknown') {
1797
+ const turns = new Set(this._activeGroup?.turns || []);
1798
+ const head = this.pendingQueue[0];
1799
+ if (head) turns.add(head);
1800
+ for (const t of turns) {
1801
+ this._heartbeat(t, `hook:${ev.type}`);
1802
+ }
1659
1803
  }
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?.();
1804
+ // H4: Stop hook → synthesize a settle for the primary turn after
1805
+ // a grace, so JSONL `result` (which carries richer metadata)
1806
+ // wins when both fire. If JSONL never arrives broken stream,
1807
+ // stuck parser the Stop synth settles the turn instead of
1808
+ // stranding it. Idempotent: a later JSONL settleResult call is
1809
+ // a no-op once the promise has resolved.
1810
+ //
1811
+ // rc.42 #6 (review-driven): per-primary `_stopSynthScheduled`
1812
+ // guard + stored timer ref so kill()/`_finishTurn` can clear
1813
+ // the pending synth. Without these, repeated Stop events
1814
+ // accumulate N independent timers (rare in production, but a
1815
+ // possible memory leak), and a synth scheduled against a
1816
+ // primary that retires via another path (timeout, interrupt)
1817
+ // fires post-mortem against a freed promise. Idempotency
1818
+ // makes both harmless TODAY; defensive future-proofing.
1819
+ if (ev?.type === 'Stop') {
1820
+ const primary = (this._activeGroup?.turns || [])
1821
+ .find((t) => t.kind === 'primary');
1822
+ if (primary
1823
+ && typeof primary.settleResult === 'function'
1824
+ && !primary._stopSynthScheduled) {
1825
+ const synth = {
1826
+ text: primary.text || ev.lastAssistantMessage || '',
1827
+ subtype: 'success',
1828
+ stopReason: 'stop_hook',
1829
+ sessionId: this.claudeSessionId,
1830
+ via: 'stop-hook',
1831
+ };
1832
+ primary._stopSynthScheduled = true;
1833
+ // rc.42 #3 (review-driven): try/catch in the timer callback.
1834
+ // settleResult is a Promise resolver (cannot throw under
1835
+ // current spec), but a future refactor where settleResult
1836
+ // gates on instance state could; the surrounding setTimeout
1837
+ // has no recovery path otherwise.
1838
+ primary._stopSynthTimer = setTimeout(() => {
1839
+ try {
1840
+ // Recheck the turn is still in a state where the synth
1841
+ // is meaningful — if `_finishTurn` already retired it,
1842
+ // settleResult is idempotent but skipping is cleaner.
1843
+ if (typeof primary.settleResult === 'function') {
1844
+ primary.settleResult(synth);
1845
+ }
1846
+ } catch (err) {
1847
+ this.logger.warn?.(
1848
+ `[${this.label}] Stop-synth settle error: ${err.message}`,
1849
+ );
1850
+ }
1851
+ }, this.stopGraceMs);
1852
+ primary._stopSynthTimer.unref?.();
1853
+ }
1683
1854
  }
1855
+ this.emit('hook-event', ev);
1856
+ } catch (err) {
1857
+ this.logger.warn?.(
1858
+ `[${this.label}] _handleHookEvent error (${ev?.type || 'unknown'}): `
1859
+ + `${err.message}`,
1860
+ );
1684
1861
  }
1685
- this.emit('hook-event', ev);
1686
1862
  }
1687
1863
 
1688
1864
  _handleSessionEvent(ev) {
@@ -3008,6 +3184,16 @@ class TmuxProcess extends Process {
3008
3184
  for (const finish of [...this._submitConfirms.values()]) {
3009
3185
  try { finish(); } catch { /* swallow */ }
3010
3186
  }
3187
+ // rc.42 #6 (review-driven): drop pending H4 Stop-synth timers
3188
+ // across every turn the ledger still holds. Symmetric with the
3189
+ // _finishTurn cleanup — kill() bypasses _finishTurn for the
3190
+ // drainQueue'd turns, so do it here.
3191
+ for (const turn of this._ledger) {
3192
+ if (turn?._stopSynthTimer) {
3193
+ try { clearTimeout(turn._stopSynthTimer); } catch { /* swallow */ }
3194
+ turn._stopSynthTimer = null;
3195
+ }
3196
+ }
3011
3197
  if (this._sessionLogTail) {
3012
3198
  try { this._sessionLogTail.close(); } catch { /* swallow */ }
3013
3199
  this._sessionLogTail = null;
@@ -98,6 +98,27 @@ 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',
101
122
  };
102
123
 
103
124
  class ProcessManager {
@@ -459,6 +459,67 @@ 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
+
462
523
  onInjectFail: (sessionKey, payload /* , entry */) => {
463
524
  try {
464
525
  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.42",
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": {