switchroom 0.19.4 → 0.19.6

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.
Files changed (43) hide show
  1. package/dist/auth-broker/index.js +7 -3
  2. package/dist/cli/autoaccept-poll.js +8 -2
  3. package/dist/cli/switchroom.js +20 -5
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +67 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +585 -302
  8. package/telegram-plugin/flushed-turn-supersede.ts +43 -7
  9. package/telegram-plugin/gateway/command-format.ts +253 -0
  10. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  11. package/telegram-plugin/gateway/gateway.ts +128 -259
  12. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  13. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  14. package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
  15. package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
  16. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  17. package/telegram-plugin/gateway/stream-render.ts +18 -1
  18. package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
  19. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  20. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  21. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  22. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  23. package/telegram-plugin/render/line-start-guard.ts +76 -4
  24. package/telegram-plugin/reply-owner-resolve.ts +43 -7
  25. package/telegram-plugin/rich-send.ts +8 -1
  26. package/telegram-plugin/tests/command-format.test.ts +212 -0
  27. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
  28. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  29. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  30. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  31. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
  32. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  33. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  34. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
  37. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  38. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  39. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  40. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  41. package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
  42. package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
  43. package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
@@ -40,6 +40,145 @@ export function handleWorkerResume(
40
40
  log(`telegram gateway: worker ${agentId} card RE-SURFACED — resumed via SendMessage after a genuine terminal (issue #3373)`)
41
41
  }
42
42
 
43
+ /**
44
+ * Worker-feed origin-race defer decision (issue: DM-misrouted worker card).
45
+ *
46
+ * The gateway picks a worker card's destination on the FIRST progress tick of
47
+ * a new sub-agent. That tick can beat the async `jsonl_agent_id` backfill that
48
+ * links the sub-agent's registry row to its origin turn (retried ~every 3s).
49
+ * Until the link lands, origin resolution returns null and the gateway's
50
+ * `resolveWorkerFeedChat` hard-falls back to the owner DM — CREATING the card
51
+ * there. The origin (supergroup + forum topic) resolves ~3s later, but the DM
52
+ * card already exists and stays the visible one.
53
+ *
54
+ * Decision: DEFER card creation while the agent is not yet linked to its
55
+ * origin AND no card exists yet for it. The subagent-watcher re-fires within
56
+ * seconds; once the backfill completes the origin resolves and the card is
57
+ * created in the correct chat+topic. Only CARD CREATION is deferred — if a
58
+ * card already exists, updates always proceed (`defer:false`). A bounded
59
+ * counter caps the wait: after `maxDeferrals` unlinked ticks (pathological
60
+ * backfill failure) it stops deferring so active work always gets a card.
61
+ *
62
+ * Pure so the seam is unit-testable — see worker-feed-dispatch.test.ts. The
63
+ * gateway must never inline this decision again.
64
+ */
65
+ export function decideWorkerFeedOriginDefer(input: {
66
+ /** True once `resolveSubagentOriginChat` returns a chat (row linked). */
67
+ originResolved: boolean
68
+ /** True if the feed already has a posted message for this worker. */
69
+ cardExists: boolean
70
+ /** Consecutive prior deferrals for this agent (0 on the first tick). */
71
+ priorDeferrals: number
72
+ /** Max consecutive deferrals before painting anyway. */
73
+ maxDeferrals: number
74
+ }): { defer: boolean; deferrals: number } {
75
+ const { originResolved, cardExists, priorDeferrals, maxDeferrals } = input
76
+ // A card already exists, or the origin has resolved: never defer.
77
+ if (originResolved || cardExists) return { defer: false, deferrals: 0 }
78
+ const deferrals = priorDeferrals + 1
79
+ // Bounded: stop deferring once we've waited long enough for the backfill.
80
+ if (deferrals >= maxDeferrals) return { defer: false, deferrals }
81
+ return { defer: true, deferrals }
82
+ }
83
+
84
+ /**
85
+ * The FULL worker-feed destination decision for a single onProgress tick,
86
+ * extracted verbatim from the gateway's inline `onProgress` block (issue
87
+ * #3460). It folds two concerns that the gateway used to run inline:
88
+ *
89
+ * 1. the origin-race defer choice (`decideWorkerFeedOriginDefer`), and
90
+ * 2. the chat/thread RESOLUTION the gateway's `resolveWorkerFeedChat`
91
+ * performed (origin chat → fleet chat / stamp-turn fallback → owner DM),
92
+ * including the exhausted-defer stamp-turn forum-thread carry (#3458).
93
+ *
94
+ * Returning a plain decision object lets the gateway keep only a thin
95
+ * delegation (defer-map bookkeeping + the two audit logs + the feed.update)
96
+ * and gives this whole path REAL regression coverage — the test drives THIS
97
+ * function, the same code the gateway runs, instead of a hand-rolled replica.
98
+ *
99
+ * Pure and side-effect-free: the two audit-log side effects the gateway used
100
+ * to emit inline (`exhausted`, `ownerDmFallback`) are returned as flags so the
101
+ * caller performs them against its module-level state. Behavior — routing — is
102
+ * identical to the prior inline path; only the seam moved.
103
+ *
104
+ * Precedence for a PAINT (mirrors `resolveWorkerFeedChat`):
105
+ * origin chat (when resolved, non-empty) → fleet chat, else stamp-turn chat
106
+ * when no fleet chat is configured (carrying the stamp-turn forum topic) →
107
+ * owner DM (durable floor). Never returns an empty chat for a paint unless
108
+ * every source is empty.
109
+ */
110
+ export type WorkerFeedDestination =
111
+ | { action: 'defer'; deferrals: number }
112
+ | {
113
+ action: 'paint'
114
+ chatId: string
115
+ threadId: number | undefined
116
+ /** New consecutive-deferral count to persist (0 once painting resumes). */
117
+ deferrals: number
118
+ /** True when painting only because the bounded defer cap was hit and the
119
+ * origin never linked — the gateway logs the "never linked" audit line. */
120
+ exhausted: boolean
121
+ /** True when the paint fell all the way to the owner DM (origin unresolved
122
+ * AND no fleet/stamp chat) — the gateway logs the once-per-agent misroute. */
123
+ ownerDmFallback: boolean
124
+ }
125
+
126
+ export function decideWorkerFeedDestination(input: {
127
+ /** Resolved origin chat/topic (`resolveSubagentOriginChat`), or null if the
128
+ * `jsonl_agent_id` backfill hasn't linked the row to its origin turn yet. */
129
+ origin: { chatId: string; threadId?: number } | null
130
+ /** True if the feed already has a posted message for this worker. */
131
+ cardExists: boolean
132
+ /** Consecutive prior deferrals for this agent (0 on the first tick). */
133
+ priorDeferrals: number
134
+ /** Max consecutive deferrals before painting anyway. */
135
+ maxDeferrals: number
136
+ /** The gateway's outer `fleetChatId` (may be empty). */
137
+ fleetChatId: string
138
+ /** Live turn's chat (`stampTurn.sessionChatId`) — the stamp-turn fallback
139
+ * used only when no fleet chat is configured. */
140
+ stampChatId?: string
141
+ /** Live turn's forum topic (`stampTurn.sessionThreadId`), carried on the
142
+ * stamp-turn fallback so an exhausted-defer paint lands in the origin
143
+ * topic, not General (#3458). */
144
+ stampThreadId?: number
145
+ /** Owner DM chat id (`loadAccess().allowFrom[0]`) — the durable floor. */
146
+ ownerDm: string
147
+ }): WorkerFeedDestination {
148
+ const { origin, cardExists, priorDeferrals, maxDeferrals, fleetChatId, stampChatId, stampThreadId, ownerDm } = input
149
+ const originResolved = origin != null
150
+ const { defer, deferrals } = decideWorkerFeedOriginDefer({
151
+ originResolved,
152
+ cardExists,
153
+ priorDeferrals,
154
+ maxDeferrals,
155
+ })
156
+ if (defer) return { action: 'defer', deferrals }
157
+ // Painting: exhausted-defer iff we're painting despite no origin and no card.
158
+ const exhausted = !originResolved && !cardExists
159
+ // Prefer the live turn's chat/topic over the owner DM when we must fall back
160
+ // (a misroute at least lands near the work) — only when NO fleet chat is set.
161
+ const usingStampFallback = fleetChatId.length === 0
162
+ const workerFleetChatId = usingStampFallback ? stampChatId ?? fleetChatId : fleetChatId
163
+ const fallbackThreadId = usingStampFallback ? stampThreadId : undefined
164
+ // resolveWorkerFeedChat precedence:
165
+ if (origin != null && origin.chatId.length > 0) {
166
+ return { action: 'paint', chatId: origin.chatId, threadId: origin.threadId, deferrals, exhausted, ownerDmFallback: false }
167
+ }
168
+ if (workerFleetChatId.length > 0) {
169
+ return { action: 'paint', chatId: workerFleetChatId, threadId: fallbackThreadId, deferrals, exhausted, ownerDmFallback: false }
170
+ }
171
+ const ownerDmFallback = origin == null && workerFleetChatId.length === 0 && ownerDm.length > 0
172
+ return {
173
+ action: 'paint',
174
+ chatId: ownerDm,
175
+ threadId: origin?.threadId ?? fallbackThreadId,
176
+ deferrals,
177
+ exhausted,
178
+ ownerDmFallback,
179
+ }
180
+ }
181
+
43
182
  export interface WorkerFeedDispatch {
44
183
  /** True when the sub-agent was dispatched with `run_in_background: true`. */
45
184
  isBackground: boolean
@@ -58,7 +58,13 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
58
58
  import { join } from 'node:path'
59
59
  import { homedir } from 'node:os'
60
60
 
61
- import { scanTurnForFinalReply } from './silent-end-scan.mjs'
61
+ import {
62
+ scanTurnForFinalReply,
63
+ decideStopHookDisposition,
64
+ isTurnFlushSafetyEnabledEnv,
65
+ isCapturedProseDeliveryEnabledEnv,
66
+ isGatewayHeartbeatFresh,
67
+ } from './silent-end-scan.mjs'
62
68
 
63
69
  // MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
64
70
  // (this hook is a standalone .mjs and can't import the TS module).
@@ -77,6 +83,54 @@ function getStateDir() {
77
83
  return process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
78
84
  }
79
85
 
86
+ /**
87
+ * Build the state-file payload the gateway reads back: carries `turnKey` /
88
+ * `chatId` / `threadId` / per-turn `turnId` nonce and the Option-A
89
+ * `pendingText` bridge, with the given `retryCount`. Stale carryover values
90
+ * from the prior on-disk state (`base`) are explicitly dropped when THIS turn
91
+ * has no derivable nonce / no deliverable prose.
92
+ *
93
+ * @param {object} base Prior on-disk state (spread as a starting point).
94
+ * @param {ReturnType<import('./silent-end-scan.mjs').scanTurnForFinalReply>} decision
95
+ * @param {number} retryCount
96
+ */
97
+ function buildNextState(base, decision, retryCount) {
98
+ const next = { ...base, retryCount, timestamp: Date.now() }
99
+ if (decision.turnKey) {
100
+ next.turnKey = decision.turnKey
101
+ next.chatId = decision.chatId
102
+ if (decision.threadId != null) next.threadId = decision.threadId
103
+ if (decision.turnId) next.turnId = decision.turnId
104
+ else delete next.turnId
105
+ } else {
106
+ delete next.turnId
107
+ }
108
+ if (typeof decision.pendingText === 'string' && decision.pendingText.length > 0) {
109
+ next.pendingText = decision.pendingText
110
+ } else {
111
+ delete next.pendingText
112
+ }
113
+ return next
114
+ }
115
+
116
+ /**
117
+ * Persist the elected state file (single-writer election allow path).
118
+ * retryCount is left UNCHANGED (this is a hand-off to the gateway's delivery
119
+ * machine, not a re-prompt). Fail-open on write error — an allow never loops.
120
+ *
121
+ * @param {string} statePath
122
+ * @param {object} base
123
+ * @param {ReturnType<import('./silent-end-scan.mjs').scanTurnForFinalReply>} decision
124
+ */
125
+ function writeElectedState(statePath, base, decision) {
126
+ const retryCount = typeof base.retryCount === 'number' ? base.retryCount : 0
127
+ try {
128
+ writeFileSync(statePath, JSON.stringify(buildNextState(base, decision, retryCount)), 'utf8')
129
+ } catch (err) {
130
+ process.stderr.write(`[silent-end-interrupt] failed to write elected state file: ${err.message}\n`)
131
+ }
132
+ }
133
+
80
134
  function main() {
81
135
  const raw = readStdin().trim()
82
136
  if (!raw) process.exit(0)
@@ -136,6 +190,34 @@ function main() {
136
190
 
137
191
  const retryCount = typeof state.retryCount === 'number' ? state.retryCount : 0
138
192
 
193
+ // ── Single-writer election (duplicate-message fix) ────────────────
194
+ // On a would-BLOCK scan, ALLOW the stop (while still writing the state
195
+ // file so the gateway's delivery machines have their input) IFF a
196
+ // gateway delivery machine is PROVABLY going to deliver the trailing
197
+ // prose. Otherwise BLOCK exactly as today. See
198
+ // `decideStopHookDisposition` in silent-end-scan.mjs for the four
199
+ // never-drop gates. This eliminates the double-send: the gateway flush
200
+ // / captured-prose bridge is the single writer; the hook no longer
201
+ // re-prompts a reworded reply that defeats the exact-match dedup.
202
+ const disposition = decideStopHookDisposition({
203
+ scan: decision,
204
+ retryCount,
205
+ turnFlushSafetyEnabled: isTurnFlushSafetyEnabledEnv(process.env),
206
+ capturedProseDeliveryEnabled: isCapturedProseDeliveryEnabledEnv(process.env),
207
+ gatewayLive: isGatewayHeartbeatFresh(stateDir),
208
+ })
209
+ if (disposition.action === 'allow-elected') {
210
+ // Persist the state file (turnKey / turnId / pendingText) so the
211
+ // gateway's turn-end path delivers the answer — retryCount stays at 0
212
+ // (this is NOT a re-prompt, it's a hand-off to the single writer).
213
+ writeElectedState(statePath, state, decision)
214
+ process.stderr.write(
215
+ `[silent-end-interrupt] single-writer election ALLOWED stop ` +
216
+ `(scan=${decision.reason} elect=${disposition.reason}) — gateway will deliver\n`,
217
+ )
218
+ process.exit(0)
219
+ }
220
+
139
221
  if (retryCount >= MAX_RETRIES) {
140
222
  // Budget spent. Let the session end so the gateway's
141
223
  // `silent-end.ts:recordUndeliveredTurnEnd` path delivers the
@@ -161,41 +243,10 @@ function main() {
161
243
  // doubles the effective re-prompt budget vs. the design. With turnKey
162
244
  // present (same chatKey shape the gateway uses), the match succeeds
163
245
  // and the budget is honored.
164
- const nextState = {
165
- ...state,
166
- retryCount: retryCount + 1,
167
- timestamp: Date.now(),
168
- }
169
- if (decision.turnKey) {
170
- nextState.turnKey = decision.turnKey
171
- nextState.chatId = decision.chatId
172
- if (decision.threadId != null) {
173
- nextState.threadId = decision.threadId
174
- }
175
- // Per-turn nonce (Finding 3, #3228). The gateway requires this to match
176
- // the live turn's `turnId` before delivering `pendingText`, so a stale
177
- // record left over from a prior turn on the same chat/thread can never
178
- // deliver a previous turn's answer on a later one. Explicitly drop a
179
- // carried-over `turnId` from the spread `...state` when THIS turn has no
180
- // derivable nonce, so an old value never lingers.
181
- if (decision.turnId) nextState.turnId = decision.turnId
182
- else delete nextState.turnId
183
- } else {
184
- delete nextState.turnId
185
- }
186
- // Option A transcript-prose bridge: when the scan isolated a substantive
187
- // final answer the model wrote as plain text but never sent through the
188
- // reply tool, persist it so the gateway's turn-end path can deliver it
189
- // directly on the first silent-end (instead of relying on this hook's
190
- // re-prompt / the obligation represent to eventually recover it). The
191
- // gateway reads this field back out of the same state file. Explicitly
192
- // clear a stale carryover value from a prior turn's spread `...state` when
193
- // THIS turn has no deliverable prose, so an old answer is never re-sent.
194
- if (typeof decision.pendingText === 'string' && decision.pendingText.length > 0) {
195
- nextState.pendingText = decision.pendingText
196
- } else {
197
- delete nextState.pendingText
198
- }
246
+ //
247
+ // Per-turn nonce (Finding 3, #3228) and the Option-A `pendingText` bridge
248
+ // are plumbed by `buildNextState`.
249
+ const nextState = buildNextState(state, decision, retryCount + 1)
199
250
  try {
200
251
  writeFileSync(statePath, JSON.stringify(nextState), 'utf8')
201
252
  } catch (err) {
@@ -55,6 +55,9 @@
55
55
  // (`done:true`) call as the qualifying one regardless of how many
56
56
  // intermediate non-final `stream_reply` calls preceded it. No gap found;
57
57
  // re-verify only if a new outbound-delivery tool is added to bridge.ts.
58
+ import { statSync } from 'node:fs'
59
+ import { join } from 'node:path'
60
+
58
61
  const REPLY_TOOLS = new Set([
59
62
  'mcp__switchroom-telegram__reply',
60
63
  'mcp__switchroom-telegram__stream_reply',
@@ -97,6 +100,68 @@ export function endsWithSilentMarker(text) {
97
100
  return SILENT_MARKER_RE.test(lines[lines.length - 1])
98
101
  }
99
102
 
103
+ // ── Narration heuristics — ported from `turn-flush-safety.ts:198-274` ──
104
+ //
105
+ // Kept byte-parallel with `selectFlushDeliveryText` / `isNarrationBlock` so
106
+ // the JOINED multi-block prose the scan persists as `pendingText` (for the
107
+ // capture-divergence corner) matches what the gateway flush would itself have
108
+ // delivered. The scan has no structural `followedByToolUse` provenance, so it
109
+ // uses the opener/trailer heuristic fallback (the same branch the TS side
110
+ // takes when the flag is absent). MUST stay in sync with the TS source; a
111
+ // drift only affects the rare capture-empty corner, never the primary flush.
112
+ const NARRATION_OPENER =
113
+ /^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i
114
+ const NARRATION_TRAILER = /(?:\.{3}|…|:)\s*$/
115
+
116
+ function isTrailingNarrationLine(block) {
117
+ const t = block.trim()
118
+ if (t.length === 0 || t.length >= FINAL_ANSWER_MIN_CHARS) return false
119
+ if (t.includes('\n')) return false
120
+ return NARRATION_TRAILER.test(t)
121
+ }
122
+
123
+ function isNarrationBlock(block) {
124
+ return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block)
125
+ }
126
+
127
+ /**
128
+ * Choose the prose the captured-prose bridge should deliver from the trailing
129
+ * text blocks in the ZERO-reply case, mirroring `selectFlushDeliveryText`
130
+ * (`turn-flush-safety.ts:198-232`). `texts` are already trimmed non-empty.
131
+ *
132
+ * - 0 blocks → undefined (nothing to deliver).
133
+ * - 1 block → that block (the real single-block short-answer shape;
134
+ * persisted even below the substance floor so the lowered-
135
+ * floor capture-divergence corner can deliver it).
136
+ * - ≥2 blocks → strip leading narration exactly as the flush does: if EVERY
137
+ * preceding block is narration, deliver only the terminal
138
+ * block; otherwise JOIN all blocks with a paragraph break (a
139
+ * genuine multi-paragraph answer split across blocks). When
140
+ * the result is narration-only (terminal block is itself
141
+ * narration AND all preceding are narration) → undefined, so a
142
+ * pure narration run never masquerades as an answer (#3228
143
+ * Finding 2 parity).
144
+ *
145
+ * @param {string[]} texts
146
+ * @returns {string | undefined}
147
+ */
148
+ export function selectBridgePendingText(texts) {
149
+ const candidates = texts.map((t) => t.trim()).filter((t) => t.length > 0)
150
+ if (candidates.length === 0) return undefined
151
+ if (candidates.length === 1) return candidates[0]
152
+ const answer = candidates[candidates.length - 1]
153
+ const preceding = candidates.slice(0, -1)
154
+ const allPrecedingNarration = preceding.every((b) => isNarrationBlock(b))
155
+ if (allPrecedingNarration) {
156
+ // Deliver only the terminal block — unless it too is pure narration, in
157
+ // which case the whole run is narration and there is no answer to bridge.
158
+ return isNarrationBlock(answer) ? undefined : answer
159
+ }
160
+ // A preceding block carries real content → keep the whole answer joined so a
161
+ // multi-block answer is never truncated to its last paragraph.
162
+ return candidates.join('\n\n')
163
+ }
164
+
100
165
  /**
101
166
  * Predicate ported from `telegram-plugin/final-answer-detect.ts:78-83`.
102
167
  * Kept in this .mjs so the hook is fully self-contained (no TS import).
@@ -193,8 +258,16 @@ function buildTurnId(chatId, threadId, messageId) {
193
258
  * clears the substance floor, so the gateway never re-delivers a short
194
259
  * trailing pleasantry. Omitted entirely otherwise.
195
260
  */
196
- function buildBlockResult(envelope, reason, pendingText) {
261
+ function buildBlockResult(envelope, reason, pendingText, hasTrailingProse) {
197
262
  const block = { decided: 'block', reason }
263
+ // Single-writer election input (#duplicate-message fix): does ANY
264
+ // non-empty, non-silent trailing text block exist after the last
265
+ // delivery event? The zero-reply election allows only when this is
266
+ // true — `decideTurnFlush` has no length floor, so any non-empty
267
+ // non-silent captured text WILL flush; but a turn with NO trailing
268
+ // prose at all (tool calls only) has nothing for any delivery
269
+ // machine to send and must keep blocking.
270
+ if (hasTrailingProse === true) block.hasTrailingProse = true
198
271
  if (envelope.chatId) {
199
272
  block.chatId = envelope.chatId
200
273
  block.threadId = envelope.threadId
@@ -432,6 +505,32 @@ export function scanTurnForFinalReply(jsonl) {
432
505
  substantiveBlocks.length > 0
433
506
  ? substantiveBlocks[substantiveBlocks.length - 1].text
434
507
  : undefined
508
+ const trailingTextBlocks = undeliveredSlice.filter(
509
+ (b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0,
510
+ )
511
+ const hasTrailingProse = trailingTextBlocks.length > 0
512
+ // Capture-divergence bridge (#duplicate-message fix): in the ZERO-reply
513
+ // case, persist the last trailing block even when no single block clears
514
+ // the 200-char substance floor. The gateway's flush normally delivers any
515
+ // non-empty captured text, but when the gateway's own capture diverged
516
+ // (captured empty) the captured-prose bridge is the only delivery machine
517
+ // left, and it reads `pendingText` — the gateway lowers `minChars` for
518
+ // exactly this corner (`capturedProseMinCharsFor`, silent-end.ts). The
519
+ // interim-ack case (`trailing-text-after-reply`) keeps the substantive
520
+ // floor unchanged — a short closer after a real reply is not a dropped
521
+ // answer.
522
+ //
523
+ // Multi-block corner (review item 3): a real answer split across ≥2
524
+ // individually-sub-200 blocks (e.g. two ~150-char paragraphs) would
525
+ // otherwise yield NO pendingText — and in the capture-divergence-empty
526
+ // corner (gateway `capturedText` empty → flush skips 'empty-text') the
527
+ // bridge would then have nothing to deliver and the hook already allowed the
528
+ // stop: a DROPPED ANSWER. `selectBridgePendingText` mirrors the flush's own
529
+ // `selectFlushDeliveryText` narration-strip/join, so the bridge delivers the
530
+ // joined prose (with the lowered `minChars`) instead of dropping. The #3228
531
+ // Finding 2 guard is preserved: a pure narration run still yields undefined.
532
+ const zeroReplyPendingText =
533
+ pendingText ?? selectBridgePendingText(trailingTextBlocks.map((b) => b.text))
435
534
 
436
535
  if (lastAllowBlockIdx === -1) {
437
536
  // No qualifying delivery/silence event anywhere in the turn.
@@ -444,7 +543,7 @@ export function scanTurnForFinalReply(jsonl) {
444
543
  if (envelope.source === 'cron') {
445
544
  return { decided: 'allow', reason: 'cron-source' }
446
545
  }
447
- return buildBlockResult(envelope, 'no-final-reply', pendingText)
546
+ return buildBlockResult(envelope, 'no-final-reply', zeroReplyPendingText, hasTrailingProse)
448
547
  }
449
548
 
450
549
  if (sawUndeliveredTextAfterAllow) {
@@ -453,8 +552,169 @@ export function scanTurnForFinalReply(jsonl) {
453
552
  // sent through a delivery tool. This is the "at least once" bug:
454
553
  // an early ack (or any qualifying reply) must not amnesty
455
554
  // everything written afterward.
456
- return buildBlockResult(envelope, 'trailing-text-after-reply', pendingText)
555
+ return buildBlockResult(envelope, 'trailing-text-after-reply', pendingText, hasTrailingProse)
457
556
  }
458
557
 
459
558
  return { decided: 'allow', reason: lastAllowReason }
460
559
  }
560
+
561
+ // ── Single-writer election (duplicate-message fix) ───────────────────
562
+ //
563
+ // RCA: when a turn ends with its final answer as plain transcript text,
564
+ // TWO uncoordinated recovery paths both fire — (A) the gateway's
565
+ // deterministic turn-end flush (`decideTurnFlush` /
566
+ // `answer-ready-flush.ts`) delivers the captured transcript prose, AND
567
+ // (B) this Stop hook blocks and re-prompts the model, which regenerates
568
+ // a REWORDED reply that defeats the exact-match dedup
569
+ // (`flushed-turn-supersede.ts` `flushedAnswerMatchesReply`). The user
570
+ // gets two near-identical messages.
571
+ //
572
+ // Fix: the Stop hook is the single elector. On a would-BLOCK scan it
573
+ // ALLOWS the stop (still persisting the state file so the gateway's
574
+ // delivery machines have their input) IFF it can PROVE a gateway
575
+ // delivery machine will handle the trailing prose. Every gate below
576
+ // exists to prevent a DROP (worse than a duplicate):
577
+ //
578
+ // 1. deliverable trailing prose exists AND the right machine covers it
579
+ // - zero-reply (`no-final-reply`): ANY non-empty non-silent
580
+ // trailing prose — `decideTurnFlush` has no length floor and
581
+ // flushes any non-empty non-silent captured text. (No 200-char
582
+ // floor here.)
583
+ // - interim-ack (`trailing-text-after-reply`, replyCalled=true):
584
+ // ONLY the captured-prose bridge delivers there (the flush skips
585
+ // on reply-called), and its floor is 200 — so short trailing
586
+ // text after a real reply keeps BLOCKING (no duplicate exists in
587
+ // that case today).
588
+ // 2. the governing delivery flag is enabled (read by the hook from
589
+ // its own env): zero-reply needs the turn-flush-safety flag AND
590
+ // the captured-prose flag (the bridge is the capture-divergence
591
+ // backstop when the gateway captured empty); interim-ack needs the
592
+ // captured-prose flag. Flag off → the machine won't fire → BLOCK.
593
+ // 3. retryCount === 0 — a prior failed delivery must fall back to
594
+ // today's BLOCK, preserving the #3228 send-failure recovery net.
595
+ // 4. gateway liveness — the gateway heartbeat file must be FRESH.
596
+ // Allowing into a dead gateway is the one drop worse than a
597
+ // duplicate; when liveness can't be established, BLOCK.
598
+ //
599
+ // Pure and deterministic: the hook wrapper does the IO (env read, stat,
600
+ // state-file write) and maps this verdict to exit-0-allow vs
601
+ // decision:block.
602
+
603
+ /**
604
+ * @param {{
605
+ * scan: ReturnType<typeof scanTurnForFinalReply>,
606
+ * retryCount: number,
607
+ * turnFlushSafetyEnabled: boolean,
608
+ * capturedProseDeliveryEnabled: boolean,
609
+ * gatewayLive: boolean,
610
+ * }} input
611
+ * @returns {{ action: 'allow-scan' | 'allow-elected' | 'block', reason: string }}
612
+ */
613
+ export function decideStopHookDisposition(input) {
614
+ const {
615
+ scan,
616
+ retryCount,
617
+ turnFlushSafetyEnabled,
618
+ capturedProseDeliveryEnabled,
619
+ gatewayLive,
620
+ } = input
621
+ if (scan == null || scan.decided !== 'block') {
622
+ return { action: 'allow-scan', reason: `scan-${scan?.decided ?? 'unknown'}` }
623
+ }
624
+ // Gate 4 — never allow into a possibly-dead gateway.
625
+ if (gatewayLive !== true) {
626
+ return { action: 'block', reason: 'gateway-liveness-not-fresh' }
627
+ }
628
+ // Gate 3 — a retry ladder already in flight means a prior delivery
629
+ // attempt failed; today's BLOCK is the recovery net (#3228).
630
+ if (retryCount !== 0) {
631
+ return { action: 'block', reason: 'retry-ladder-in-flight' }
632
+ }
633
+ if (scan.reason === 'no-final-reply') {
634
+ // Gate 2 (zero-reply): the turn-end flush is the delivery machine;
635
+ // the captured-prose bridge is the capture-divergence backstop.
636
+ if (!turnFlushSafetyEnabled) {
637
+ return { action: 'block', reason: 'turn-flush-flag-disabled' }
638
+ }
639
+ if (!capturedProseDeliveryEnabled) {
640
+ return { action: 'block', reason: 'captured-prose-flag-disabled' }
641
+ }
642
+ // Gate 1 (zero-reply): any non-empty non-silent trailing prose.
643
+ if (scan.hasTrailingProse !== true) {
644
+ return { action: 'block', reason: 'no-trailing-prose' }
645
+ }
646
+ return { action: 'allow-elected', reason: 'flush-will-deliver' }
647
+ }
648
+ if (scan.reason === 'trailing-text-after-reply') {
649
+ // Gate 2 (interim-ack): only the captured-prose bridge delivers here.
650
+ if (!capturedProseDeliveryEnabled) {
651
+ return { action: 'block', reason: 'captured-prose-flag-disabled' }
652
+ }
653
+ // Gate 1 (interim-ack): the bridge's substance floor is 200 chars
654
+ // (CAPTURED_PROSE_MIN_CHARS, silent-end.ts). Below it the bridge
655
+ // will NOT deliver — keep blocking (matches today: no duplicate
656
+ // exists for short trailing text after a real reply).
657
+ if (
658
+ typeof scan.pendingText !== 'string' ||
659
+ scan.pendingText.trim().length < FINAL_ANSWER_MIN_CHARS
660
+ ) {
661
+ return { action: 'block', reason: 'short-trailing-after-reply' }
662
+ }
663
+ return { action: 'allow-elected', reason: 'bridge-will-deliver' }
664
+ }
665
+ // Unknown block reason — conservatively keep today's behaviour.
666
+ return { action: 'block', reason: `unrecognized-scan-reason-${scan.reason}` }
667
+ }
668
+
669
+ /**
670
+ * Mirror of `isTurnFlushSafetyEnabled` (`turn-flush-safety.ts:392-400`).
671
+ * Kept in this .mjs so the hook is self-contained (no TS import); MUST
672
+ * stay in sync — default ON, disabled by `0` / `false` / `off` / `no`.
673
+ *
674
+ * @param {Record<string, string | undefined>} env
675
+ * @returns {boolean}
676
+ */
677
+ export function isTurnFlushSafetyEnabledEnv(env) {
678
+ const raw = env.SWITCHROOM_TG_TURN_FLUSH_SAFETY
679
+ if (raw == null) return true
680
+ const v = raw.trim().toLowerCase()
681
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
682
+ }
683
+
684
+ /**
685
+ * Mirror of `CAPTURED_PROSE_DELIVERY_ENABLED` (`gateway/gateway.ts` —
686
+ * `SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY !== '0'`). MUST stay in sync.
687
+ *
688
+ * @param {Record<string, string | undefined>} env
689
+ * @returns {boolean}
690
+ */
691
+ export function isCapturedProseDeliveryEnabledEnv(env) {
692
+ return env.SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY !== '0'
693
+ }
694
+
695
+ // MUST stay in sync with gateway/gateway-heartbeat.ts (the hook is a
696
+ // standalone .mjs and can't import the TS module). The gateway touches
697
+ // the file every GATEWAY_HEARTBEAT_INTERVAL_MS (15s); freshness bound is
698
+ // 4 intervals — conservative against scheduler jitter, small enough that
699
+ // a dead gateway is detected before its stale heartbeat can swallow more
700
+ // than one turn's election.
701
+ export const GATEWAY_HEARTBEAT_FILE = 'gateway-heartbeat'
702
+ export const GATEWAY_HEARTBEAT_FRESH_MS = 60_000
703
+
704
+ /**
705
+ * Gate 4 IO helper: is the gateway heartbeat file fresh? Missing,
706
+ * unstattable, or stale ⇒ false (BLOCK — never allow into a possibly-
707
+ * dead gateway).
708
+ *
709
+ * @param {string} stateDir
710
+ * @param {number} [now]
711
+ * @returns {boolean}
712
+ */
713
+ export function isGatewayHeartbeatFresh(stateDir, now = Date.now()) {
714
+ try {
715
+ const st = statSync(join(stateDir, GATEWAY_HEARTBEAT_FILE))
716
+ return now - st.mtimeMs <= GATEWAY_HEARTBEAT_FRESH_MS
717
+ } catch {
718
+ return false
719
+ }
720
+ }