switchroom 0.18.19 → 0.18.21

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 (57) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +59 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  6. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  7. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  8. package/profiles/coding/CLAUDE.md.hbs +1 -1
  9. package/telegram-plugin/answer-ready-flush.ts +187 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +1114 -184
  11. package/telegram-plugin/format.ts +179 -20
  12. package/telegram-plugin/gateway/cron-session.ts +32 -0
  13. package/telegram-plugin/gateway/gateway.ts +794 -106
  14. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  15. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  16. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  17. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  18. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  19. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  20. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  21. package/telegram-plugin/narrative-flush.ts +181 -0
  22. package/telegram-plugin/pending-work-progress.ts +65 -1
  23. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  24. package/telegram-plugin/session-tail.ts +6 -1
  25. package/telegram-plugin/silent-end.ts +182 -0
  26. package/telegram-plugin/stream-reply-handler.ts +14 -5
  27. package/telegram-plugin/subagent-watcher.ts +330 -82
  28. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  29. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  30. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  31. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  32. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  33. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  34. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  35. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  36. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  37. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  38. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  39. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  40. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  41. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  42. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  43. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  44. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  45. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  46. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  47. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
  48. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  49. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  51. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  52. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  53. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  54. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
  55. package/telegram-plugin/tool-activity-summary.ts +78 -16
  56. package/telegram-plugin/turn-flush-safety.ts +4 -4
  57. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -67,6 +67,20 @@ export interface IdleClearState {
67
67
  alreadyCleared: boolean;
68
68
  /** A turn is in flight — never clear mid-turn. */
69
69
  turnInFlight: boolean;
70
+ /**
71
+ * A background sub-agent (Agent/Task dispatched, turn ended before it
72
+ * returned) is still in flight AND within its suppression TTL (#3117). The
73
+ * main-turn gate (`turnInFlight`) is blind to detached background work: a
74
+ * worker that is alive-but-silent for a full idle window (one long tool call,
75
+ * no stream events to re-stamp the activity clock) would otherwise be cleared,
76
+ * destroying the context its handback needs. Suppress the clear while this is
77
+ * true. It is TTL-bounded by the caller so a leaked/never-cleared pending flag
78
+ * cannot disable idle-clear forever — past the TTL the caller passes false and
79
+ * self-healing resumes. Optional/undefined ⇒ treated as false (no background
80
+ * work), which preserves the pre-#3117 behaviour for callers that don't wire
81
+ * it (e.g. the pure-decider unit tests that exercise only the wall clock).
82
+ */
83
+ backgroundWorkInFlight?: boolean;
70
84
  }
71
85
 
72
86
  export interface IdleClearDecision {
@@ -91,6 +105,14 @@ export function decideIdleClear(
91
105
  ): IdleClearDecision {
92
106
  if (state.idleClearMs <= 0) return { clear: false }; // disabled
93
107
  if (state.turnInFlight) return { clear: false }; // never mid-turn
108
+ // #3117 — a detached background sub-agent within its suppression TTL keeps the
109
+ // session alive even when the main-turn gate is open and the activity clock
110
+ // has gone cold (a silent long-running worker emits no stream events). The
111
+ // caller (gateway) bounds this by a TTL so a stuck pending flag can't disable
112
+ // idle-clear forever; here we simply honour it. Because #3116's write-time
113
+ // re-eval re-runs this whole function against the live state, this suppression
114
+ // is enforced at BOTH decision time and /clear write time for free.
115
+ if (state.backgroundWorkInFlight) return { clear: false };
94
116
  if (state.alreadyCleared) return { clear: false }; // once per idle period
95
117
  // Measured from the LAST thing that happened — activity or a turn ending —
96
118
  // never from a turn's start. A turn that ran longer than the window ends with
@@ -133,6 +155,154 @@ export function classifyIdleEvent(
133
155
  return { activity: !synthetic, turnEnded };
134
156
  }
135
157
 
158
+ /**
159
+ * Inputs the gateway feeds into a decision that live OUTSIDE the idle clocks:
160
+ * the resolved window, the main-turn gate, and the TTL-bounded background-work
161
+ * suppressor. Kept separate from `IdleClearState` (the clock state the tracker
162
+ * owns) so the tracker's decide surface is exactly "here is the world right
163
+ * now" and the tracker supplies its own clocks + latch.
164
+ */
165
+ export interface IdleDecisionInputs {
166
+ /** Resolved idle window in ms (env → per-agent config → 3h default). <=0 disables. */
167
+ idleClearMs: number;
168
+ /** A turn is in flight (the same gate proactive-compact uses). Never clear mid-turn. */
169
+ turnInFlight: boolean;
170
+ /**
171
+ * A detached background sub-agent is in flight AND within its suppression TTL
172
+ * (#3117). Optional; undefined ⇒ false (pre-#3117 behaviour). The caller bounds
173
+ * this by a TTL so a leaked pending flag can't disable idle-clear forever.
174
+ */
175
+ backgroundWorkInFlight?: boolean;
176
+ }
177
+
178
+ /**
179
+ * The gateway's idle bookkeeping as ONE stateful object (#3115).
180
+ *
181
+ * Before #3115 this lived as four bare module-level `let`s in gateway.ts
182
+ * (`lastIdleActivityAt`, `lastIdleTurnEndAt`, `idleAutoCleared`,
183
+ * `idleClearDispatching`) plus the inline `markIdleActivity` / `markIdleTurnEnd`
184
+ * / `maybeIdleClear` stamp+decide logic scattered across `handleSessionEvent`,
185
+ * `onInjectInbound`, and `handleInbound`. Nothing imported gateway.ts (~30k
186
+ * lines, import-time side effects), so the WIRING — that every session event
187
+ * actually stamps the clocks — was untestable and the test mirrored the logic
188
+ * in a local `IdleModel` instead of exercising it. Deleting the real stamp
189
+ * block left every test green while re-introducing the #3113 "productive work
190
+ * gets wiped" bug.
191
+ *
192
+ * Extracting the state here makes the real object importable and drivable: a
193
+ * deleted stamp call now fails a test because the test holds the SAME object
194
+ * the gateway holds. The gateway keeps ownership of its environment concerns
195
+ * (resolving the window, `turnInFlightForGate()`, the pending-dispatch probe)
196
+ * and feeds them in via `IdleDecisionInputs`; the tracker owns only the clocks
197
+ * and the fire-once / re-entrancy latches.
198
+ *
199
+ * The clock is injected per-call (`now`) exactly like the pure `decideIdleClear`
200
+ * / `classifyIdleEvent` it delegates to — the gateway passes `Date.now()`, the
201
+ * tests pass a deterministic virtual clock.
202
+ */
203
+ export class IdleTracker {
204
+ /** Epoch ms of the last activity (inbound, cron fire, or any genuine session event). */
205
+ private lastActivityAt: number;
206
+ /** Epoch ms a turn last ended (null = none this process). */
207
+ private lastTurnEndedAt: number | null = null;
208
+ /** Already auto-cleared since the last activity? Fire-once-per-idle-period latch. */
209
+ private alreadyCleared = false;
210
+ /** A /clear dispatch is in flight — re-entrancy guard so a tick can't double-fire. */
211
+ private dispatching = false;
212
+
213
+ constructor(startedAt: number) {
214
+ this.lastActivityAt = startedAt;
215
+ }
216
+
217
+ /** Epoch ms of the last activity — for wiring assertions (was `lastIdleActivityAt`). */
218
+ get activityAt(): number {
219
+ return this.lastActivityAt;
220
+ }
221
+ /** Epoch ms a turn last ended, or null — for wiring assertions. */
222
+ get turnEndedAt(): number | null {
223
+ return this.lastTurnEndedAt;
224
+ }
225
+ /** The fire-once latch — true once cleared this idle period, until re-armed. */
226
+ get cleared(): boolean {
227
+ return this.alreadyCleared;
228
+ }
229
+ /** A /clear dispatch is currently in flight. */
230
+ get isDispatching(): boolean {
231
+ return this.dispatching;
232
+ }
233
+
234
+ /**
235
+ * Reset the idle timer + re-arm auto-clear. Call on ANY activity — inbound,
236
+ * a genuine cron fire, or (via `noteEvent`) any claude session-stream event.
237
+ * Was the gateway's `markIdleActivity()`.
238
+ */
239
+ noteInbound(now: number): void {
240
+ this.lastActivityAt = now;
241
+ this.alreadyCleared = false;
242
+ }
243
+
244
+ /**
245
+ * Feed a claude session-stream event through the classifier and stamp the
246
+ * clocks. Genuine activity re-arms the auto-clear; a turn ending additionally
247
+ * stamps the turn-end clock. Was the gateway's inline
248
+ * `classifyIdleEvent` + `markIdleActivity`/`markIdleTurnEnd` block in
249
+ * `handleSessionEvent` — the load-bearing wiring #3115 makes testable.
250
+ */
251
+ noteEvent(kind: string, now: number, durationMs?: number): void {
252
+ const signal = classifyIdleEvent(kind, durationMs);
253
+ if (signal.activity) this.noteInbound(now);
254
+ if (signal.turnEnded) this.lastTurnEndedAt = now;
255
+ }
256
+
257
+ /** Snapshot the tracker's clocks into an `IdleClearState` for a decision. */
258
+ private stateFor(inputs: IdleDecisionInputs, alreadyCleared: boolean): IdleClearState {
259
+ return {
260
+ lastActivityAt: this.lastActivityAt,
261
+ lastTurnEndedAt: this.lastTurnEndedAt,
262
+ idleClearMs: inputs.idleClearMs,
263
+ alreadyCleared,
264
+ turnInFlight: inputs.turnInFlight,
265
+ backgroundWorkInFlight: inputs.backgroundWorkInFlight,
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Decide whether to auto-clear right now, honouring the fire-once latch.
271
+ * Delegates to the pure `decideIdleClear` against the live clocks + inputs.
272
+ */
273
+ decide(now: number, inputs: IdleDecisionInputs): IdleClearDecision {
274
+ return decideIdleClear(this.stateFor(inputs, this.alreadyCleared), now);
275
+ }
276
+
277
+ /**
278
+ * Re-evaluate idleness at /clear WRITE time (#3116 precondition), IGNORING the
279
+ * fire-once latch. `maybeIdleClear` latches `alreadyCleared=true` before the
280
+ * async inject for re-entrancy, so the write-time re-eval must judge idleness
281
+ * on the live clocks + turn/background gates only — any activity that arrived
282
+ * in the check-to-send gap must still suppress the buffered /clear.
283
+ */
284
+ decideIgnoringLatch(now: number, inputs: IdleDecisionInputs): IdleClearDecision {
285
+ return decideIdleClear(this.stateFor(inputs, false), now);
286
+ }
287
+
288
+ /** Latch the fire-once guard: a /clear has been decided for this idle period. */
289
+ markClearFired(): void {
290
+ this.alreadyCleared = true;
291
+ }
292
+ /** Re-arm after a suppressed dispatch so the next idle period can clear again. */
293
+ reArm(): void {
294
+ this.alreadyCleared = false;
295
+ }
296
+ /** Mark a /clear dispatch as begun (re-entrancy latch). */
297
+ beginDispatch(): void {
298
+ this.dispatching = true;
299
+ }
300
+ /** Mark a /clear dispatch as finished (re-entrancy latch released). */
301
+ endDispatch(): void {
302
+ this.dispatching = false;
303
+ }
304
+ }
305
+
136
306
  /**
137
307
  * Parse a `^\d+[smh]$` duration (the SessionSchema format, e.g. "3h", "30m",
138
308
  * "7200s") to ms. Returns null on a malformed string so the caller can fall
@@ -86,6 +86,17 @@ function shapeReply(
86
86
  return { body: verbHtml, accent: 'done' }
87
87
  }
88
88
 
89
+ // outcome === 'skipped' (#3116): an opt-in write-time precondition aborted
90
+ // the send before any keys were sent. No inject-map caller opts in today, so
91
+ // this is unreachable via this surface — but handle it explicitly so a future
92
+ // precondition caller gets an honest "skipped" ack instead of a failure card.
93
+ if (result.outcome === 'skipped') {
94
+ return {
95
+ body: `${verbHtml} — skipped (precondition not met at send time)`,
96
+ accent: 'issue',
97
+ }
98
+ }
99
+
89
100
  // outcome === 'failed'
90
101
  const code = result.errorCode ?? 'tmux_failed'
91
102
  const msg = result.errorMessage ?? 'unknown error'
@@ -27,6 +27,7 @@ import {
27
27
  normalizeParagraphBreaks,
28
28
  normalizePunctuation,
29
29
  stripExcessBold,
30
+ addParagraphSpacers,
30
31
  splitMarkdownChunks,
31
32
  hardSliceToCap,
32
33
  RICH_MESSAGE_MAX_CHARS,
@@ -87,16 +88,15 @@ export function normalizeOutboundBody(
87
88
  }
88
89
 
89
90
  /**
90
- * Effective-text stage. Historically this injected an NBSP paragraph spacer on
91
- * the rich path (#2669) to force a visible gap; that pass was removed in the
92
- * #2669 follow-up because the live Bot API 10.1 GFM renderer already renders a
93
- * `\n\n` gap as one normal blank line — the spacer was double-gapping every
94
- * paragraph. Both paths now pass the text through byte-identically; the stage
95
- * is retained as the named pipeline seam (and the literal/rich distinction is
96
- * kept for callers) so a future rich-only transform has a home. Pure.
91
+ * Effective-text spacing (#2669 rich-message regression fix, restored after the
92
+ * #3208 F1 misfire). The Bot API 10.1 rich GFM renderer (and the in-repo IR
93
+ * renderer that feeds it) renders a prose `\n\n` gap TIGHT, so paragraphs render
94
+ * jammed together. Inject a visible U+00A0 blank-line spacer into each block
95
+ * gap on the rich path only (idempotent see addParagraphSpacers); the literal
96
+ * (`format:'text'`) path stays byte-exact. Pure.
97
97
  */
98
- export function computeEffectiveText(text: string, _literalText: boolean): string {
99
- return text
98
+ export function computeEffectiveText(text: string, literalText: boolean): string {
99
+ return literalText ? text : addParagraphSpacers(text)
100
100
  }
101
101
 
102
102
  /**
@@ -176,12 +176,20 @@ export interface SubagentProgressDecisionInput {
176
176
  * passes it in; the decision returns the new bucket idx on
177
177
  * `deliver: true` so the caller can update its tracker. */
178
178
  lastBucketIdx: number | null
179
+ /** #3233: true for a growth-independent SKELETON liveness cue (empty
180
+ * `latestSummary`, no step content). It exists ONLY to first-paint /
181
+ * keep-alive the in-message worker-feed row; the legacy bucket relay would
182
+ * turn it into a synthesized "still working" inbound with no content — a
183
+ * blank card. Suppressed deterministically here so the worker-feed-DISABLED
184
+ * path degrades to a no-op rather than a blank envelope. */
185
+ skeleton?: boolean
179
186
  /** Deterministic clock for tests. */
180
187
  nowMs?: number
181
188
  }
182
189
 
183
190
  export type SubagentProgressSkipReason =
184
191
  | 'env-disabled'
192
+ | 'skeleton-liveness'
185
193
  | 'foreground'
186
194
  | 'no-chat'
187
195
  | 'bucket-already-fired'
@@ -199,6 +207,8 @@ export type SubagentProgressDecision =
199
207
  *
200
208
  * Gates, in order:
201
209
  * 1. kill-switch — `SWITCHROOM_DISABLE_SUBAGENT_PROGRESS=1` disables.
210
+ * 1b. skeleton-liveness (#3233) — a contentless skeleton cue is never
211
+ * relayed as a synthesized inbound (worker-feed row only).
202
212
  * 2. foreground — foreground sub-agents stream natively.
203
213
  * 3. no-chat — nowhere to deliver.
204
214
  * 4. missing-jsonl-id — the dedup key. Without it we'd lose
@@ -233,6 +243,13 @@ export function decideSubagentProgress(
233
243
  if (isEnvFlagOn(input.disableEnvValue)) {
234
244
  return { deliver: false, reason: 'env-disabled' }
235
245
  }
246
+ // #3233: a skeleton liveness cue carries no step content — never relay it as
247
+ // a synthesized progress inbound (that would be a blank card). Its whole job
248
+ // is the in-message worker-feed row; when that surface is off, degrade to a
249
+ // no-op. Checked before bucketing so it can never advance the bucket tracker.
250
+ if (input.skeleton === true) {
251
+ return { deliver: false, reason: 'skeleton-liveness' }
252
+ }
236
253
  if (!input.isBackground) {
237
254
  return { deliver: false, reason: 'foreground' }
238
255
  }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Honest turn-record status derivation (PR B — "send-honesty" fix).
3
+ *
4
+ * `emitTurnRecord` historically wrote `status: finalAnswerDelivered ? 'complete'
5
+ * : 'no_reply'`. On the turn-flush / backstop paths `finalAnswerDelivered` is set
6
+ * BEFORE the async send actually runs, so a send that then throws (e.g.
7
+ * `FLOOD_WAIT_ACTIVE` during a flood ban) was still recorded `complete` — a turn
8
+ * where the user received NOTHING logged as delivered.
9
+ *
10
+ * The fix threads a per-turn `deliveryOutcome`, set only AFTER the send resolves,
11
+ * and derives the recorded status from that real outcome. These helpers are pure
12
+ * and injectable so the outcome ↔ status mapping is unit-testable without the
13
+ * 30k-line gateway module (mirrors `turns-jsonl-rotate.ts`).
14
+ */
15
+
16
+ /**
17
+ * The resolved fate of a backstop (turn-flush) send, stamped on the turn only
18
+ * once the send has actually resolved:
19
+ * - `delivered` — every chunk reached Telegram.
20
+ * - `failed` — the send threw, OR a partial multi-chunk delivery (chunk 1 ok,
21
+ * a later chunk failed): the answer did not fully reach the user.
22
+ * - `suppressed` — the flush short-circuited because the reply tool already
23
+ * delivered this turn's answer (the 2s recent-outbound guard).
24
+ */
25
+ export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
26
+
27
+ /** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
28
+ export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
29
+
30
+ /**
31
+ * Derive the recorded turn status from the turn's flags.
32
+ *
33
+ * `deliveryOutcome` (when present) is authoritative — it reflects the REAL send
34
+ * resolution on the backstop paths. When it is absent (the synchronous
35
+ * reply-tool tail, silent-marker, and genuine no-reply paths) we fall back to the
36
+ * legacy `finalAnswerDelivered` reading, preserving existing semantics exactly.
37
+ *
38
+ * delivered → complete
39
+ * failed / partial → send_failed (NOT complete, NOT silently no_reply)
40
+ * suppressed → complete iff the reply path delivered, else no_reply
41
+ * undefined (legacy) → complete iff finalAnswerDelivered, else no_reply
42
+ * (fail-safe: an IIFE that dies before stamping an
43
+ * outcome never fabricates `complete`)
44
+ */
45
+ export function computeTurnStatus(turn: {
46
+ finalAnswerDelivered: boolean
47
+ deliveryOutcome?: DeliveryOutcome
48
+ }): TurnStatus {
49
+ switch (turn.deliveryOutcome) {
50
+ case 'failed':
51
+ return 'send_failed'
52
+ case 'delivered':
53
+ return 'complete'
54
+ case 'suppressed':
55
+ return turn.finalAnswerDelivered ? 'complete' : 'no_reply'
56
+ default:
57
+ return turn.finalAnswerDelivered ? 'complete' : 'no_reply'
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve a backstop send's outcome from what actually happened on the wire.
63
+ * A throw is a failure; a no-throw send that delivered fewer chunks than it
64
+ * split into is a partial delivery and is treated as `failed` (the user did not
65
+ * receive the whole answer) — never silently `delivered`/`complete`.
66
+ */
67
+ export function backstopSendOutcome(args: {
68
+ threw: boolean
69
+ sentCount: number
70
+ chunkCount: number
71
+ }): DeliveryOutcome {
72
+ if (args.threw) return 'failed'
73
+ // Defense (Fix 5): a "send" that split into zero chunks delivered nothing —
74
+ // do NOT let sentCount(0) < chunkCount(0) === false slip through to
75
+ // 'delivered'/'complete' (which would trip the silent-no-op-candidate
76
+ // detector on a tools:0 turn). Nothing sent → failed.
77
+ if (args.chunkCount === 0) return 'failed'
78
+ if (args.sentCount < args.chunkCount) return 'failed'
79
+ return 'delivered'
80
+ }
81
+
82
+ /**
83
+ * Stamp a turn's `deliveryOutcome` from a resolved backstop send. This is the
84
+ * exact accounting the turn-flush IIFE's `finally` performs — factored here so
85
+ * the gateway and the tests run the SAME mapping (a real send that throws or
86
+ * partially delivers must produce `send_failed`, never `complete`). Mutates and
87
+ * returns the resolved outcome.
88
+ */
89
+ export function finalizeBackstopSend(
90
+ turn: { deliveryOutcome?: DeliveryOutcome },
91
+ send: { threw: boolean; sentCount: number; chunkCount: number },
92
+ ): DeliveryOutcome {
93
+ const outcome = backstopSendOutcome(send)
94
+ turn.deliveryOutcome = outcome
95
+ return outcome
96
+ }
97
+
98
+ /** The parsed shape of one turns.jsonl row. */
99
+ export interface TurnRecordRow {
100
+ ts: number
101
+ agent: string
102
+ duration_ms: number
103
+ tools: number
104
+ status: TurnStatus
105
+ turn_id: string
106
+ }
107
+
108
+ /**
109
+ * Build the turns.jsonl row for a turn. The single source of truth for the
110
+ * `status` field — `emitTurnRecord` serializes exactly this, so a test that
111
+ * asserts on `buildTurnRecord(...).status` is asserting the value the gateway
112
+ * actually writes (proving `emitTurnRecord` derives status from the resolved
113
+ * `deliveryOutcome` via `computeTurnStatus`, not the speculative flag).
114
+ */
115
+ export function buildTurnRecord(
116
+ turn: {
117
+ agent: string
118
+ startedAt: number
119
+ toolCallCount: number
120
+ turnId: string
121
+ finalAnswerDelivered: boolean
122
+ deliveryOutcome?: DeliveryOutcome
123
+ },
124
+ endedAt: number,
125
+ ): TurnRecordRow {
126
+ return {
127
+ ts: Math.floor(endedAt / 1000),
128
+ agent: turn.agent,
129
+ duration_ms: turn.startedAt > 0 ? endedAt - turn.startedAt : 0,
130
+ tools: turn.toolCallCount ?? 0,
131
+ status: computeTurnStatus(turn),
132
+ turn_id: turn.turnId,
133
+ }
134
+ }
@@ -172,6 +172,29 @@ function main() {
172
172
  if (decision.threadId != null) {
173
173
  nextState.threadId = decision.threadId
174
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
175
198
  }
176
199
  try {
177
200
  writeFileSync(statePath, JSON.stringify(nextState), 'utf8')
@@ -123,14 +123,25 @@ export function isFinalAnswerReply({ text, disableNotification, done }) {
123
123
  * @returns {{ chatId: string | null, threadId: number | null }}
124
124
  */
125
125
  function parseChannelEnvelope(content) {
126
- if (typeof content !== 'string') return { chatId: null, threadId: null, source: null }
126
+ if (typeof content !== 'string') {
127
+ return { chatId: null, threadId: null, messageId: null, source: null }
128
+ }
127
129
  const chatMatch = content.match(/chat_id="([^"]+)"/)
128
130
  const threadMatch = content.match(/message_thread_id="([^"]+)"/)
131
+ // LEFT-ANCHOR the message_id match on an attribute boundary (start-of-string
132
+ // or a whitespace/quote before the name) so it matches ONLY the real
133
+ // `message_id` attribute — never a same-suffix sibling like
134
+ // `target_message_id`, `reply_to_message_id`, or `original_message_id`.
135
+ // Byte-identical to session-tail.ts `parseChannelMeta`'s `grab('message_id')`
136
+ // so the turnId the hook derives here matches the gateway's `deriveTurnId`
137
+ // exactly (Finding 3, #3228).
138
+ const msgMatch = content.match(/(?:^|[\s"'])message_id="([^"]+)"/)
129
139
  const sourceMatch = content.match(/<channel[^>]*\bsource="([^"]+)"/)
130
140
  const threadRaw = threadMatch ? Number(threadMatch[1]) : NaN
131
141
  return {
132
142
  chatId: chatMatch ? chatMatch[1] : null,
133
143
  threadId: Number.isFinite(threadRaw) && threadRaw !== 0 ? threadRaw : null,
144
+ messageId: msgMatch ? msgMatch[1] : null,
134
145
  source: sourceMatch ? sourceMatch[1] : null,
135
146
  }
136
147
  }
@@ -148,6 +159,27 @@ function buildTurnKey(chatId, threadId) {
148
159
  return `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
149
160
  }
150
161
 
162
+ /**
163
+ * Build the per-turn nonce the gateway stamps as `CurrentTurn.turnId`
164
+ * (`gateway.ts deriveTurnId` → `${chatKey(chatId, threadId)}#${messageId}`).
165
+ * Unlike `buildTurnKey` (the STABLE `chatId:threadId` statusKey, shared by
166
+ * every turn on the same chat/thread), this is unique per inbound message —
167
+ * so a stale captured-prose record from a PRIOR turn on the same chat can
168
+ * never be misdelivered on a LATER turn (Finding 3, #3228). Returns null when
169
+ * the enqueue envelope carries no usable message_id (synthetic inbounds); the
170
+ * gateway then falls back to the turnKey-only match, unchanged.
171
+ *
172
+ * @param {string | null} chatId
173
+ * @param {number | null} threadId
174
+ * @param {string | null} messageId
175
+ * @returns {string | null}
176
+ */
177
+ function buildTurnId(chatId, threadId, messageId) {
178
+ if (chatId == null) return null
179
+ if (messageId == null || messageId === '' || String(messageId) === '0') return null
180
+ return `${buildTurnKey(chatId, threadId)}#${messageId}`
181
+ }
182
+
151
183
  /**
152
184
  * Build the `{ decided: 'block', ... }` result shape, populating
153
185
  * `turnKey`/`chatId`/`threadId` from the enqueue envelope when
@@ -155,13 +187,27 @@ function buildTurnKey(chatId, threadId) {
155
187
  *
156
188
  * @param {ReturnType<typeof parseChannelEnvelope>} envelope
157
189
  * @param {string} reason
190
+ * @param {string} [pendingText] The substantive undelivered final-answer
191
+ * prose the model wrote as plain transcript text but never sent through a
192
+ * reply tool (Option A transcript-prose bridge). Only populated when it
193
+ * clears the substance floor, so the gateway never re-delivers a short
194
+ * trailing pleasantry. Omitted entirely otherwise.
158
195
  */
159
- function buildBlockResult(envelope, reason) {
196
+ function buildBlockResult(envelope, reason, pendingText) {
160
197
  const block = { decided: 'block', reason }
161
198
  if (envelope.chatId) {
162
199
  block.chatId = envelope.chatId
163
200
  block.threadId = envelope.threadId
164
201
  block.turnKey = buildTurnKey(envelope.chatId, envelope.threadId)
202
+ // Per-turn nonce (Finding 3, #3228). Populated only when the enqueue
203
+ // envelope carried a real message_id — the gateway requires it to match
204
+ // this turn's `turnId` before delivering `pendingText`, so a stale record
205
+ // carried over from a prior turn on the same chat/thread is rejected.
206
+ const turnId = buildTurnId(envelope.chatId, envelope.threadId, envelope.messageId)
207
+ if (turnId != null) block.turnId = turnId
208
+ }
209
+ if (typeof pendingText === 'string' && pendingText.length > 0) {
210
+ block.pendingText = pendingText
165
211
  }
166
212
  return block
167
213
  }
@@ -191,6 +237,15 @@ function buildBlockResult(envelope, reason) {
191
237
  * the hook's state.
192
238
  * { decided: 'unknown', reason } — couldn't locate turn-start; caller fail-open
193
239
  *
240
+ * On a 'block' decision the result MAY carry `pendingText`: the substantive
241
+ * final-answer prose the model wrote as plain transcript text after the last
242
+ * delivery event (or the whole turn's prose when nothing was ever delivered),
243
+ * joined and trimmed. This is the Option A transcript-prose bridge — the
244
+ * gateway reads it back out of the state file and delivers it directly on the
245
+ * first silent-end instead of waiting for the model re-prompt / obligation
246
+ * represent to eventually recover it. Only surfaced when it clears the same
247
+ * FINAL_ANSWER_MIN_CHARS substance floor the block decision uses.
248
+ *
194
249
  * Turn-start anchor: the most recent `queue-operation`/`enqueue` line
195
250
  * (the inbound message the gateway pushed onto the session). For
196
251
  * queued mid-turn messages (multiple `enqueue` lines per "turn"), we
@@ -232,7 +287,7 @@ function buildBlockResult(envelope, reason) {
232
287
  * false-positive that burned retry budget on healthy turns.
233
288
  *
234
289
  * @param {string} jsonl
235
- * @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, chatId?: string, threadId?: number | null }}
290
+ * @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, turnId?: string, chatId?: string, threadId?: number | null, pendingText?: string }}
236
291
  */
237
292
  export function scanTurnForFinalReply(jsonl) {
238
293
  const lines = jsonl.split('\n')
@@ -294,7 +349,16 @@ export function scanTurnForFinalReply(jsonl) {
294
349
  // the same bar `isFinalAnswerReply` uses to recognise a real
295
350
  // answer — counts as "undelivered content the user was waiting
296
351
  // on". #2956 review finding.
297
- blocks.push({ kind: 'text', chars: String(c.text ?? '').trim().length })
352
+ //
353
+ // Carry the trimmed text itself too (Option A transcript-prose
354
+ // bridge): when this turn ends up blocked, the joined undelivered
355
+ // text becomes `pendingText` so the gateway can deliver the model's
356
+ // real answer directly. Trimmed per-block; joined below.
357
+ blocks.push({
358
+ kind: 'text',
359
+ chars: String(c.text ?? '').trim().length,
360
+ text: String(c.text ?? '').trim(),
361
+ })
298
362
  }
299
363
  continue
300
364
  }
@@ -339,10 +403,36 @@ export function scanTurnForFinalReply(jsonl) {
339
403
  lastAllowReason = blocks[i].reason
340
404
  }
341
405
  }
342
- const sawUndeliveredTextAfterAllow = blocks
343
- .slice(lastAllowBlockIdx + 1)
406
+ const undeliveredSlice = blocks.slice(lastAllowBlockIdx + 1)
407
+ const sawUndeliveredTextAfterAllow = undeliveredSlice
344
408
  .some((b) => b.kind === 'text' && (b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS)
345
409
 
410
+ // Option A transcript-prose bridge: isolate the undelivered final-answer
411
+ // prose so the gateway can deliver it directly.
412
+ //
413
+ // Finding 2 (#3228): a real dropped answer is a SINGLE substantive block —
414
+ // NOT concatenated inter-tool narration ("Let me check…", "Still querying…")
415
+ // that only crosses the floor once joined. The old code joined ALL post-
416
+ // delivery text blocks and surfaced the join whenever the COMBINED length
417
+ // hit the floor, so a run of short narration masqueraded as a final answer
418
+ // (and in the zero-delivery case that join was every text block in the
419
+ // turn). Instead, deliver only the LAST block that CLEARS the substance
420
+ // floor ON ITS OWN. This mirrors the block decision itself
421
+ // (`sawUndeliveredTextAfterAllow`, which requires a single ≥floor block) and
422
+ // handles the "big answer then short closer" shape by delivering the answer,
423
+ // not the closer. When no single block clears the floor, `pendingText` stays
424
+ // undefined and the gateway falls through to the re-prompt / represent nets.
425
+ const substantiveBlocks = undeliveredSlice.filter(
426
+ (b) =>
427
+ b.kind === 'text' &&
428
+ typeof b.text === 'string' &&
429
+ (b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS,
430
+ )
431
+ const pendingText =
432
+ substantiveBlocks.length > 0
433
+ ? substantiveBlocks[substantiveBlocks.length - 1].text
434
+ : undefined
435
+
346
436
  if (lastAllowBlockIdx === -1) {
347
437
  // No qualifying delivery/silence event anywhere in the turn.
348
438
  // Cron-fired turns (#2053): a scheduled turn that produced no
@@ -354,7 +444,7 @@ export function scanTurnForFinalReply(jsonl) {
354
444
  if (envelope.source === 'cron') {
355
445
  return { decided: 'allow', reason: 'cron-source' }
356
446
  }
357
- return buildBlockResult(envelope, 'no-final-reply')
447
+ return buildBlockResult(envelope, 'no-final-reply', pendingText)
358
448
  }
359
449
 
360
450
  if (sawUndeliveredTextAfterAllow) {
@@ -363,7 +453,7 @@ export function scanTurnForFinalReply(jsonl) {
363
453
  // sent through a delivery tool. This is the "at least once" bug:
364
454
  // an early ack (or any qualifying reply) must not amnesty
365
455
  // everything written afterward.
366
- return buildBlockResult(envelope, 'trailing-text-after-reply')
456
+ return buildBlockResult(envelope, 'trailing-text-after-reply', pendingText)
367
457
  }
368
458
 
369
459
  return { decided: 'allow', reason: lastAllowReason }