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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/process/hook-event-tail.js +16 -5
- package/lib/process/tmux-process.js +246 -60
- package/lib/process-manager.js +21 -0
- package/lib/sdk/callbacks.js +61 -0
- 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.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,
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
|
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
|
-
|
|
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 :
|
|
849
|
-
//
|
|
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
|
-
{
|
|
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'
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
//
|
|
1641
|
-
//
|
|
1642
|
-
//
|
|
1643
|
-
//
|
|
1644
|
-
//
|
|
1645
|
-
//
|
|
1646
|
-
//
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
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
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
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;
|
package/lib/process-manager.js
CHANGED
|
@@ -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 {
|
package/lib/sdk/callbacks.js
CHANGED
|
@@ -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.
|
|
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": {
|