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