switchroom 0.13.35 → 0.13.37

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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Shared "render-and-fit" helper for approval cards that wrap
3
+ * user-supplied content in HTML framing. (#1762 / #1767)
4
+ *
5
+ * Telegram's `sendMessage` caps the body at 4096 chars and we render
6
+ * with `parse_mode=HTML`. Worst-case escape inflates raw content up
7
+ * to 5x (`&` → `&`), so a naive raw-input cap is unsafe — the
8
+ * post-escape body can blow past the limit and `sendMessage` then
9
+ * returns a generic 400 that surfaces upstream as a silent
10
+ * `E_DENIED`.
11
+ *
12
+ * This helper binary-searches the largest prefix of the RAW content
13
+ * whose rendered body still fits under `cap`, snaps to the last
14
+ * newline so we don't cut mid-line (and never cut mid-entity like
15
+ * `&am|p;` — raw doesn't contain entities yet), and appends a
16
+ * sentinel pointing at the attached full content (if any).
17
+ *
18
+ * Callers own the framing: pass a `render(slice)` closure that
19
+ * embeds the slice in whatever escaped envelope they want, and the
20
+ * helper guarantees the returned `body` fits.
21
+ *
22
+ * Both `config-approval-handler.ts` (config-edit diffs) and
23
+ * `drive-write-approval.ts` (Drive write preview cards) use this.
24
+ */
25
+
26
+ export interface TruncateRawToFitInput {
27
+ /** Raw, un-escaped content to slice. */
28
+ raw: string;
29
+ /**
30
+ * Build the full rendered card body from a (possibly truncated)
31
+ * raw slice. The closure owns HTML escaping + all framing. Called
32
+ * O(log n) times during the binary search; keep it cheap.
33
+ */
34
+ render: (rawSlice: string) => string;
35
+ /**
36
+ * Maximum rendered length (chars). Should be set below Telegram's
37
+ * 4096 hard limit to leave margin for invisible framing wobble.
38
+ */
39
+ cap: number;
40
+ /**
41
+ * Marker appended to the truncated slice before re-rendering — e.g.
42
+ * `"\n[… diff continues, see attached file]"`. The render closure
43
+ * receives `rawSlice + sentinel` so the marker is visible inside
44
+ * the same envelope (code block etc.).
45
+ */
46
+ sentinel: string;
47
+ /** Absolute hard cap for the defensive last-resort raw cut. Default `cap + 196`. */
48
+ hardLimit?: number;
49
+ }
50
+
51
+ export interface TruncateRawToFitResult {
52
+ /** Rendered body, guaranteed to fit within `cap` (best-effort) or `hardLimit` (defensive). */
53
+ body: string;
54
+ /** True iff the helper had to truncate (raw was sliced or hard-cut). */
55
+ truncated: boolean;
56
+ }
57
+
58
+ /**
59
+ * Try the full content first; if it fits, return as-is. Otherwise
60
+ * binary-search the largest raw prefix whose rendered body fits,
61
+ * snap to the last newline boundary, append the sentinel, re-render
62
+ * and return.
63
+ *
64
+ * Defensive last resort: if even the empty-slice + sentinel render
65
+ * overflows (means the framing alone exceeds `cap` — caller bug or
66
+ * adversarial reason field that slipped past clipping), we hard-cut
67
+ * the rendered body to `hardLimit` chars. Should be unreachable in
68
+ * production but cheaper than crashing.
69
+ */
70
+ export function truncateRawToFit(
71
+ input: TruncateRawToFitInput,
72
+ ): TruncateRawToFitResult {
73
+ const { raw, render, cap, sentinel } = input;
74
+ const hardLimit = input.hardLimit ?? cap + 196;
75
+
76
+ const fullBody = render(raw);
77
+ if (fullBody.length <= cap) {
78
+ return { body: fullBody, truncated: false };
79
+ }
80
+
81
+ // Binary-search the largest raw prefix length whose rendered body
82
+ // fits (with sentinel suffixed before render). We track the best
83
+ // slice rather than just the length so we can snap after the loop.
84
+ let lo = 0;
85
+ let hi = raw.length;
86
+ let bestSliceLen = 0;
87
+ while (lo <= hi) {
88
+ const mid = (lo + hi) >>> 1;
89
+ const candidate = raw.slice(0, mid) + sentinel;
90
+ if (render(candidate).length <= cap) {
91
+ bestSliceLen = mid;
92
+ lo = mid + 1;
93
+ } else {
94
+ hi = mid - 1;
95
+ }
96
+ }
97
+
98
+ // Snap to the last newline within the chosen raw prefix so we
99
+ // never cut a line in half. If a single unbroken line exceeds
100
+ // the budget, fall through with the char-truncated slice — the
101
+ // caller's framing (e.g. `<pre>`) handles the visual gracefully.
102
+ let chosenRaw = raw.slice(0, bestSliceLen);
103
+ const lastNl = chosenRaw.lastIndexOf("\n");
104
+ if (lastNl > 0) chosenRaw = chosenRaw.slice(0, lastNl);
105
+
106
+ let body = render(chosenRaw + sentinel);
107
+
108
+ // Defensive: framing-alone overflow. Hard-cut to hardLimit so the
109
+ // outbound sendMessage at least has a chance of succeeding.
110
+ if (body.length > hardLimit) {
111
+ body = body.slice(0, hardLimit - 1);
112
+ }
113
+ return { body, truncated: true };
114
+ }
@@ -1,45 +1,65 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Stop hook — auto-interrupt for silent-end turns.
3
+ * Stop hook — deterministic guardrail that a turn ended with a final
4
+ * reply tool call.
4
5
  *
5
- * When a Claude Code session ends without the agent delivering a final
6
- * answer to the user, the Telegram gateway writes a state file at
7
- * $TELEGRAM_STATE_DIR/silent-end-pending.json. This hook reads that file and,
8
- * if a first-time silent-end is detected (retryCount === 0), returns a
9
- * decision:block to re-prompt the agent instead of letting the session close.
6
+ * Closes #1775. The pre-fix hook depended on the gateway's
7
+ * `$TELEGRAM_STATE_DIR/silent-end-pending.json` file as its block/allow
8
+ * signal. That file is written by the gateway's `turn_end` handler,
9
+ * which runs DOWNSTREAM of session-tail processing the `turn_duration`
10
+ * JSONL line and the JSONL line is itself written AFTER
11
+ * `stop_hook_summary`. Live evidence on clerk (12 correlated samples,
12
+ * 2026-05-25): state file lands ~175ms (range 111-287ms) after the
13
+ * hook fires. The race is structurally always lost. The hook never
14
+ * saw its OWN turn's silent-end signal; the mechanism only worked
15
+ * one-turn-delayed via stale state from prior turns.
10
16
  *
11
- * #1664 "no final answer delivered" covers two cases: (a) the turn ended
12
- * with zero outbound (the original case), and (b) the model sent only an
13
- * interim ack via reply/stream_reply but left its real answer as plain
14
- * transcript text, which the gateway renders into an ephemeral draft and
15
- * never finalizes. The re-prompt below tells the model to send its answer
16
- * through the reply tool, or reply NO_REPLY if it genuinely has nothing to
17
- * add / already delivered.
17
+ * Fix: the hook now reads `transcript_path` from its event input
18
+ * (Claude Code flushes assistant content to the JSONL before firing
19
+ * Stop hooks verified empirically because `secret-scrub-stop.mjs`
20
+ * already reads `transcript_path` at Stop time successfully) and
21
+ * scans the CURRENT turn's tool_use entries for a qualifying reply.
22
+ * No race window the decision is derived from the transcript that
23
+ * is on disk at the moment the hook runs.
18
24
  *
19
- * On the second silent-end (retryCount >= MAX_RETRIES), the hook allows the
20
- * stop. The gateway's turn-end path (recordSilentTurnEnd in silent-end.ts)
21
- * detects the exhausted re-prompt and delivers a user-facing fallback
22
- * message so the turn never silently vanishes (#1161).
25
+ * The gateway's state file is preserved for retry-count
26
+ * bookkeeping (the 1-retry budget + `silent-end.ts` user-facing
27
+ * fallback chain). The SIGNAL changes; the budget mechanism does
28
+ * not.
29
+ *
30
+ * #1664 — "no final answer delivered" covers two cases: (a) the turn
31
+ * ended with zero outbound, and (b) the model sent only an interim
32
+ * ack via reply/stream_reply but left its real answer as plain
33
+ * transcript text. The transcript scan handles BOTH cleanly:
34
+ * - case (a) → no tool_use of reply tools in the turn → block
35
+ * - case (b) → tool_use present but `isFinalAnswerReply` returns
36
+ * false on every call → block
23
37
  *
24
38
  * Carve-outs preserved:
25
- * - wasAutonomous=true turns: the gateway never writes a state file for
26
- * these (no reply expected on autonomous wakeup turns).
27
- * - Turns with running sub-agents: the gateway only fires onSilentEnd after
28
- * all sub-agents have finished (same gate as completeTurnFully).
39
+ * - NO_REPLY / HEARTBEAT_OK silent markers (`gateway.ts:6692`) allow
40
+ * - Sub-agent (`isSidechain:true`) lines skipped (the parent's
41
+ * reply obligation is not satisfied by a sub-agent's reply tool)
42
+ * - Cron-fired turns DO carry a topic chat and reach the silent-end
43
+ * path (`silent-end.ts:219-224`) — they must emit NO_REPLY
44
+ * explicitly, not be specially exempted here
29
45
  *
30
46
  * Protocol:
31
47
  * Input: JSON on stdin — { session_id, transcript_path, ... }
32
48
  * Output: exit 0 + empty stdout → allow stop.
33
49
  * exit 0 + JSON stdout { decision: "block", reason: "..." } → re-prompt.
34
50
  *
35
- * Fail-open on any error if we can't read/write the state file, allow stop
36
- * rather than blocking every session close.
51
+ * Fail-open on every error path (no transcript / unreadable / no
52
+ * turn-start anchor / state-file write failure) — blocking on a
53
+ * malfunction is worse than the original race because it loops
54
+ * every session close.
37
55
  */
38
56
 
39
57
  import { readFileSync, writeFileSync, existsSync } from 'node:fs'
40
58
  import { join } from 'node:path'
41
59
  import { homedir } from 'node:os'
42
60
 
61
+ import { scanTurnForFinalReply } from './silent-end-scan.mjs'
62
+
43
63
  // MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
44
64
  // (this hook is a standalone .mjs and can't import the TS module).
45
65
  const MAX_RETRIES = 1
@@ -60,52 +80,109 @@ function main() {
60
80
  const raw = readStdin().trim()
61
81
  if (!raw) process.exit(0)
62
82
 
63
- // Parse the Stop hook input (fail-open)
64
- let _event
83
+ let event
65
84
  try {
66
- _event = JSON.parse(raw)
85
+ event = JSON.parse(raw)
67
86
  } catch {
68
87
  process.exit(0)
69
88
  }
70
89
 
71
- const stateDir = getStateDir()
72
- const statePath = join(stateDir, 'silent-end-pending.json')
73
-
74
- if (!existsSync(statePath)) {
75
- // No silent-end pending normal completion, allow stop.
90
+ const transcriptPath = event?.transcript_path
91
+ if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
92
+ // No transcript → can't scan → fail-open. Pre-fix the hook fell
93
+ // back to the state-file signal here; we deliberately do NOT do
94
+ // that anymore because the state-file signal is structurally
95
+ // stale (race-loses every time).
76
96
  process.exit(0)
77
97
  }
78
98
 
79
- let state
99
+ let jsonl
80
100
  try {
81
- state = JSON.parse(readFileSync(statePath, 'utf8'))
82
- } catch {
83
- // Corrupt state file — fail-open, allow stop.
101
+ jsonl = readFileSync(transcriptPath, 'utf8')
102
+ } catch (err) {
103
+ process.stderr.write(
104
+ `[silent-end-interrupt] failed to read transcript ${transcriptPath}: ${err.message}\n`,
105
+ )
106
+ process.exit(0)
107
+ }
108
+
109
+ const decision = scanTurnForFinalReply(jsonl)
110
+
111
+ // 'allow' (qualifying reply or silent marker) and 'unknown' (no
112
+ // turn-start anchor in the scanned range — session restart,
113
+ // compaction, etc.) both allow the stop.
114
+ if (decision.decided !== 'block') {
84
115
  process.exit(0)
85
116
  }
86
117
 
118
+ // Retry-budget bookkeeping. The state file is read/written here
119
+ // as a counter ONLY — the decision was already made from the
120
+ // transcript above. If a state file exists from a prior turn that
121
+ // never got cleared (clean shutdown not perfect), this read still
122
+ // works; if absent, retryCount defaults to 0.
123
+ const stateDir = getStateDir()
124
+ const statePath = join(stateDir, 'silent-end-pending.json')
125
+
126
+ let state = {}
127
+ if (existsSync(statePath)) {
128
+ try {
129
+ state = JSON.parse(readFileSync(statePath, 'utf8'))
130
+ } catch {
131
+ // Corrupt — treat as fresh.
132
+ state = {}
133
+ }
134
+ }
135
+
87
136
  const retryCount = typeof state.retryCount === 'number' ? state.retryCount : 0
88
137
 
89
138
  if (retryCount >= MAX_RETRIES) {
90
- // Retry exhausted let the session end so the gateway can render the
91
- // warning card.
139
+ // Budget spent. Let the session end so the gateway's
140
+ // `silent-end.ts:recordUndeliveredTurnEnd` path delivers the
141
+ // user-facing fallback (the gateway sees `silentEnd.exhausted ===
142
+ // true` and posts SILENT_END_FALLBACK_TEXT).
92
143
  process.stderr.write(
93
144
  `[silent-end-interrupt] retry exhausted (retryCount=${retryCount} >= MAX_RETRIES=${MAX_RETRIES}) — allowing stop\n`,
94
145
  )
95
146
  process.exit(0)
96
147
  }
97
148
 
98
- // First silent-end: increment retryCount and block to re-prompt the agent.
149
+ // Persist incremented retry count so a follow-up Stop in the same
150
+ // chat hits the exhaustion branch above. The gateway's existing
151
+ // clearSilentEndState path (`silent-end.ts:155-180`) handles
152
+ // resetting on successful delivery.
153
+ //
154
+ // CRITICAL: include `turnKey` (and the supporting `chatId` / `threadId`)
155
+ // when the scan derived them from the enqueue envelope. The gateway's
156
+ // `recordSilentTurnEnd` (`silent-end.ts:114`) preserves retryCount
157
+ // ONLY when `prev.turnKey === args.turnKey`. Without turnKey here,
158
+ // the gateway's later write (~175ms after the hook) sees `prev.turnKey
159
+ // === undefined`, fails the match, and resets retryCount to 0 — which
160
+ // doubles the effective re-prompt budget vs. the design. With turnKey
161
+ // present (same chatKey shape the gateway uses), the match succeeds
162
+ // and the budget is honored.
163
+ const nextState = {
164
+ ...state,
165
+ retryCount: retryCount + 1,
166
+ timestamp: Date.now(),
167
+ }
168
+ if (decision.turnKey) {
169
+ nextState.turnKey = decision.turnKey
170
+ nextState.chatId = decision.chatId
171
+ if (decision.threadId != null) {
172
+ nextState.threadId = decision.threadId
173
+ }
174
+ }
99
175
  try {
100
- writeFileSync(statePath, JSON.stringify({ ...state, retryCount: retryCount + 1 }), 'utf8')
176
+ writeFileSync(statePath, JSON.stringify(nextState), 'utf8')
101
177
  } catch (err) {
102
178
  process.stderr.write(`[silent-end-interrupt] failed to update state file: ${err.message}\n`)
103
- // Fail-open: allow stop rather than blocking forever.
179
+ // Fail-open: a retry-count write failure shouldn't loop the
180
+ // session forever.
104
181
  process.exit(0)
105
182
  }
106
183
 
107
184
  process.stderr.write(
108
- `[silent-end-interrupt] blocking stop to re-prompt agent (chatId=${state.chatId ?? '?'} retryCount was ${retryCount})\n`,
185
+ `[silent-end-interrupt] blocking stop to re-prompt agent (transcriptScan=${decision.reason} retryCount was ${retryCount})\n`,
109
186
  )
110
187
 
111
188
  process.stdout.write(
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Pure helpers for the silent-end Stop hook — extracted so unit tests
3
+ * can exercise the scan logic without spawning the .mjs subprocess.
4
+ *
5
+ * Closes the race documented in #1775: the gateway writes
6
+ * `silent-end-pending.json` only AFTER the Stop hook fires (the
7
+ * gateway's `turn_end` handler runs downstream of the `turn_duration`
8
+ * JSONL line, which is itself written AFTER `stop_hook_summary`). The
9
+ * fix: the hook stops depending on the gateway's state file as its
10
+ * SIGNAL and instead scans `transcript_path` directly. Claude Code
11
+ * flushes assistant content to the JSONL before firing Stop hooks
12
+ * (verified empirically: `telegram-plugin/hooks/secret-scrub-stop.mjs`
13
+ * already reads `transcript_path` at Stop time successfully in
14
+ * production), so a transcript scan is race-free.
15
+ *
16
+ * The state file is preserved for retry-count bookkeeping (the
17
+ * 1-retry budget + user-facing fallback chain in `silent-end.ts`),
18
+ * but it is no longer the signal that drives the block/allow
19
+ * decision.
20
+ *
21
+ * Same `isFinalAnswerReply` predicate the gateway applies at every
22
+ * reply callsite (`final-answer-detect.ts:78-83`):
23
+ * done===true OR !disableNotification OR text.length >= 200
24
+ *
25
+ * Plus the `NO_REPLY` / `HEARTBEAT_OK` silent-marker carve-out — if
26
+ * the model explicitly emitted that sentinel through the reply tool,
27
+ * the turn is "intentionally silent" and the hook must allow stop.
28
+ *
29
+ * Sidechain filter: sub-agent (Task) tool_use lines that leak into
30
+ * the parent transcript with `isSidechain:true` are skipped. The
31
+ * sub-agent's OWN replies live in `subagents/agent-<id>.jsonl` (per
32
+ * `session-tail.ts:277-281`) and never count toward the parent's
33
+ * delivery obligation.
34
+ */
35
+
36
+ const REPLY_TOOLS = new Set([
37
+ 'mcp__switchroom-telegram__reply',
38
+ 'mcp__switchroom-telegram__stream_reply',
39
+ ])
40
+ const FINAL_ANSWER_MIN_CHARS = 200
41
+ // Match the gateway's silent-marker classifier (gateway.ts:6692 — the
42
+ // `isSilentFlushMarker` helper accepts trailing punctuation + case
43
+ // variants like "NO_REPLY." / "no_reply").
44
+ const SILENT_MARKER_RE = /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i
45
+
46
+ /**
47
+ * Predicate ported from `telegram-plugin/final-answer-detect.ts:78-83`.
48
+ * Kept in this .mjs so the hook is fully self-contained (no TS import).
49
+ * If the TS file ever diverges, the test fixture below (T14) catches it.
50
+ */
51
+ export function isFinalAnswerReply({ text, disableNotification, done }) {
52
+ if (done === true) return true
53
+ if (!disableNotification) return true
54
+ if ((text ?? '').length >= FINAL_ANSWER_MIN_CHARS) return true
55
+ return false
56
+ }
57
+
58
+ /**
59
+ * Parse a `<channel ...>` envelope's chat_id and message_thread_id
60
+ * attributes. Same shape session-tail.ts:125-140 uses to derive these
61
+ * from the enqueue line's `content` string.
62
+ *
63
+ * Returns `null` if the envelope can't be parsed (caller treats as
64
+ * "no turn key derivable" and writes a turnKey-less state file —
65
+ * still functional, just loses retry-count preservation across the
66
+ * hook→gateway write order).
67
+ *
68
+ * @param {string} content
69
+ * @returns {{ chatId: string | null, threadId: number | null }}
70
+ */
71
+ function parseChannelEnvelope(content) {
72
+ if (typeof content !== 'string') return { chatId: null, threadId: null }
73
+ const chatMatch = content.match(/chat_id="([^"]+)"/)
74
+ const threadMatch = content.match(/message_thread_id="([^"]+)"/)
75
+ const threadRaw = threadMatch ? Number(threadMatch[1]) : NaN
76
+ return {
77
+ chatId: chatMatch ? chatMatch[1] : null,
78
+ threadId: Number.isFinite(threadRaw) && threadRaw !== 0 ? threadRaw : null,
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Build the turnKey the gateway will use for `recordSilentTurnEnd`'s
84
+ * write of the state file. Matches `chatKey(chatId, threadId)` shape
85
+ * at `gateway/chat-key.ts:46`: `${chatId}:${threadId || '_'}`.
86
+ *
87
+ * @param {string} chatId
88
+ * @param {number | null} threadId
89
+ * @returns {string}
90
+ */
91
+ function buildTurnKey(chatId, threadId) {
92
+ return `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
93
+ }
94
+
95
+ /**
96
+ * Scan a JSONL transcript and decide whether the current turn ended
97
+ * with a final reply delivered.
98
+ *
99
+ * Returns:
100
+ * { decided: 'allow', reason } — qualifying reply OR silent marker found
101
+ * { decided: 'block', reason, turnKey?, chatId?, threadId? }
102
+ * — turn-start found, no qualifying reply,
103
+ * no marker. `turnKey`/`chatId`/`threadId`
104
+ * populated from the enqueue's channel
105
+ * envelope so the hook can write a state
106
+ * file shape that matches what the
107
+ * gateway's `recordSilentTurnEnd` would
108
+ * write — keeping the retry-count
109
+ * preservation gate at
110
+ * `silent-end.ts:114` happy when the
111
+ * gateway's later write reads back the
112
+ * hook's state.
113
+ * { decided: 'unknown', reason } — couldn't locate turn-start; caller fail-open
114
+ *
115
+ * Turn-start anchor: the most recent `queue-operation`/`enqueue` line
116
+ * (the inbound message the gateway pushed onto the session). For
117
+ * queued mid-turn messages (multiple `enqueue` lines per "turn"), we
118
+ * anchor on the LAST enqueue — the model is responsible for at least
119
+ * the most recent message. (Mild over-allow risk on the multi-enqueue
120
+ * edge case where the model replied combined ahead of the second
121
+ * enqueue's append; accepted residual.)
122
+ *
123
+ * @param {string} jsonl
124
+ * @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, chatId?: string, threadId?: number | null }}
125
+ */
126
+ export function scanTurnForFinalReply(jsonl) {
127
+ const lines = jsonl.split('\n')
128
+
129
+ // 1. Walk backward to most-recent queue-operation/enqueue.
130
+ let startIdx = -1
131
+ let envelope = { chatId: null, threadId: null }
132
+ for (let i = lines.length - 1; i >= 0; i--) {
133
+ const line = lines[i]
134
+ if (!line || line[0] !== '{') continue
135
+ let obj
136
+ try { obj = JSON.parse(line) } catch { continue }
137
+ if (obj?.type === 'queue-operation' && obj.operation === 'enqueue') {
138
+ startIdx = i
139
+ envelope = parseChannelEnvelope(obj.content)
140
+ break
141
+ }
142
+ }
143
+ if (startIdx < 0) {
144
+ return { decided: 'unknown', reason: 'no-turn-start' }
145
+ }
146
+
147
+ // 2. Scan forward from the turn start; look for qualifying tool_use
148
+ // or silent-marker text.
149
+ for (let i = startIdx + 1; i < lines.length; i++) {
150
+ const line = lines[i]
151
+ if (!line || line[0] !== '{') continue
152
+ let obj
153
+ try { obj = JSON.parse(line) } catch { continue }
154
+ // Skip sub-agent contamination (defensive — sub-agent lines should
155
+ // be in a separate transcript file, but `isSidechain:true` is the
156
+ // documented marker if they leak).
157
+ if (obj?.isSidechain === true) continue
158
+ if (obj?.type !== 'assistant') continue
159
+ const content = obj?.message?.content
160
+ if (!Array.isArray(content)) continue
161
+ for (const c of content) {
162
+ if (c?.type !== 'tool_use') continue
163
+ if (!REPLY_TOOLS.has(c.name)) continue
164
+ const input = c.input ?? {}
165
+ const text = String(input.text ?? '')
166
+ // Silent-marker carve-out: the operator explicitly signaled
167
+ // "intentionally silent" (cron HEARTBEAT_OK, model-driven
168
+ // NO_REPLY). Don't block — same posture as the gateway's
169
+ // silent-marker suppression at gateway.ts:6692.
170
+ if (SILENT_MARKER_RE.test(text.trim())) {
171
+ return { decided: 'allow', reason: 'silent-marker' }
172
+ }
173
+ if (isFinalAnswerReply({
174
+ text,
175
+ disableNotification: input.disable_notification === true,
176
+ done: input.done === true,
177
+ })) {
178
+ return { decided: 'allow', reason: 'final-reply' }
179
+ }
180
+ }
181
+ }
182
+
183
+ const block = { decided: 'block', reason: 'no-final-reply' }
184
+ if (envelope.chatId) {
185
+ block.chatId = envelope.chatId
186
+ block.threadId = envelope.threadId
187
+ block.turnKey = buildTurnKey(envelope.chatId, envelope.threadId)
188
+ }
189
+ return block
190
+ }
@@ -112,6 +112,20 @@ export interface PendingProgressDeps {
112
112
  nowMs?: () => number
113
113
  /** Optional poll interval override for tests. */
114
114
  pollIntervalMs?: number
115
+ /**
116
+ * Defense-in-depth (#1760). When provided, returns the gateway's
117
+ * `activeTurnStartedAt` epoch ms for this chat key, or undefined if no
118
+ * turn is currently active. The ticker uses this on every fire to detect
119
+ * a stale ambient: if a NEWER turn has started (epoch > our activatedAt)
120
+ * the prior turn's cross-turn pending-progress is by definition orphaned
121
+ * (the turn_end teardown was missed, e.g. SDK event dropped) and the
122
+ * ticker self-terminates instead of editing a stale anchor. Converts the
123
+ * #1760 failure mode from "stuck forever" to "at most one stale tick."
124
+ *
125
+ * Defaults to undefined — preserves prior behaviour for tests that
126
+ * exercise the ticker without a gateway.
127
+ */
128
+ isActiveTurnNewerThan?: (key: string, activatedAt: number) => boolean
115
129
  }
116
130
 
117
131
  interface State {
@@ -276,7 +290,14 @@ export function noteTurnEnd(key: string): void {
276
290
  */
277
291
  export function clearPending(
278
292
  key: string,
279
- reason: 'inbound' | 'handback' | 'progress' | 'timeout' | 'manual',
293
+ reason:
294
+ | 'inbound'
295
+ | 'handback'
296
+ | 'progress'
297
+ | 'timeout'
298
+ | 'manual'
299
+ | 'reply_finalize'
300
+ | 'stale_turn',
280
301
  ): void {
281
302
  if (!stateByKey.has(key)) return
282
303
  const s = stateByKey.get(key)!
@@ -337,6 +358,21 @@ function tick(now: number): void {
337
358
  continue
338
359
  }
339
360
 
361
+ // #1760 defense-in-depth: if a newer turn is currently active for
362
+ // this chat, the prior turn's cross-turn pending-progress is stale
363
+ // (the canonical teardown — turn_end or the next turn's reply-
364
+ // finalize — was missed). Drop the timer instead of editing the
365
+ // old anchor; the new turn will manage its own anchor via the
366
+ // regular noteOutbound / noteTurnEnd path. Converts "stuck forever"
367
+ // (the live #1760 evidence) into "at most one stale tick."
368
+ if (
369
+ activeDeps.isActiveTurnNewerThan != null
370
+ && activeDeps.isActiveTurnNewerThan(key, s.activatedAt)
371
+ ) {
372
+ clearPending(key, 'stale_turn')
373
+ continue
374
+ }
375
+
340
376
  const sinceEdit = s.lastEditAt == null ? 0 : now - s.lastEditAt
341
377
  if (sinceEdit < EDIT_INTERVAL_MS) continue
342
378