switchroom 0.18.18 → 0.18.20

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 (45) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +36 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/answer-ready-flush.ts +187 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1131 -285
  7. package/telegram-plugin/dist/server.js +6 -0
  8. package/telegram-plugin/format.ts +208 -125
  9. package/telegram-plugin/gateway/cron-session.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +800 -107
  11. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  12. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  13. package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
  14. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  15. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  16. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  17. package/telegram-plugin/llm-error-present.ts +68 -30
  18. package/telegram-plugin/narrative-flush.ts +181 -0
  19. package/telegram-plugin/pending-work-progress.ts +65 -1
  20. package/telegram-plugin/session-tail.ts +6 -1
  21. package/telegram-plugin/silent-end.ts +182 -0
  22. package/telegram-plugin/subagent-watcher.ts +244 -81
  23. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  24. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  25. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  26. package/telegram-plugin/tests/format-consistency.test.ts +39 -4
  27. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
  28. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  29. package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
  30. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  31. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  32. package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
  33. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  34. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  35. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  36. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  37. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
  38. package/telegram-plugin/tests/telegram-format.test.ts +72 -4
  39. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  40. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  41. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  42. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
  43. package/telegram-plugin/tool-activity-summary.ts +78 -16
  44. package/telegram-plugin/turn-flush-safety.ts +2 -1
  45. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -192,7 +192,8 @@ import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, forma
192
192
  import { formatModelLabel } from '../model-label.js'
193
193
  import { createSessionModelSource } from './session-model-source.js'
194
194
  import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
195
- import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
195
+ import { REPLY_TOOLS } from '../narrative-dedup.js'
196
+ import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
196
197
  import { toolLabel } from '../tool-labels.js'
197
198
  import { createTypingWrapper } from '../typing-wrap.js'
198
199
  import { createTurnTypingLoop } from './turn-typing-loop.js'
@@ -236,7 +237,7 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
236
237
  import { classifyInbound } from '../inbound-classifier.js'
237
238
  import * as silencePoke from '../silence-poke.js'
238
239
  import * as pendingProgress from '../pending-work-progress.js'
239
- import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd, silentEndFallbackText, type SilentEndDeps } from '../silent-end.js'
240
+ import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd, silentEndFallbackText, decideCapturedProseDelivery, settleCapturedProseDelivery, CAPTURED_PROSE_MIN_CHARS, type SilentEndDeps, type CapturedProseSendOutcome } from '../silent-end.js'
240
241
  import { isFinalAnswerReply, isSubstantiveFinalReply, FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
241
242
  import { deriveTurnRole, decideTerminalReason, parsePostAnswerLivenessMs, evaluatePostAnswerLiveness, type LoopRole } from '../turn-liveness-floor.js'
242
243
  import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
@@ -317,7 +318,7 @@ import {
317
318
  import { recordOperatorEvent } from '../operator-events-history.js'
318
319
  import {
319
320
  parseLlmError,
320
- renderLlmError,
321
+ renderLlmErrorSafe,
321
322
  decideErrorSurface,
322
323
  } from '../llm-error-present.js'
323
324
  import {
@@ -399,6 +400,11 @@ import {
399
400
  decideTurnFlush,
400
401
  isTurnFlushSafetyEnabled,
401
402
  } from '../turn-flush-safety.js'
403
+ // PR A — deterministic answer-ready quiescence flush (late-delivery fix).
404
+ import {
405
+ AnswerReadyFlushController,
406
+ resolveAnswerReadyFlushMs,
407
+ } from '../answer-ready-flush.js'
402
408
  // #1667 — pure decision core for the turn_end answer-delivery gate (#1664).
403
409
  import { decideTurnEndGate } from './turn-end-gate.js'
404
410
  // #1122 PR3: turn-flush-prose-recovery removed with the progress card.
@@ -514,7 +520,7 @@ import { resolveOutboundTopic as resolveOutboundTopicHelper, topicForRecipient,
514
520
  import { readTurnUsages } from '../../src/agents/perf.js'
515
521
  import { buildContextOccupancy, writeContextOccupancySnapshot } from './context-occupancy.js'
516
522
  import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
517
- import { decideIdleClear, classifyIdleEvent, idleDurationToMs, DEFAULT_IDLE_CLEAR_MS } from './idle-clear.js'
523
+ import { IdleTracker, idleDurationToMs, DEFAULT_IDLE_CLEAR_MS } from './idle-clear.js'
518
524
  import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
519
525
  import {
520
526
  tryHostdDispatch,
@@ -545,7 +551,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
545
551
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
546
552
  import { buildDiffPreviewCard } from './diff-preview-card.js'
547
553
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
548
- import { isCronIdentity, deliverInjectWithFallback } from './cron-session.js'
554
+ import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback } from './cron-session.js'
549
555
  import {
550
556
  ObligationLedger,
551
557
  buildObligationRepresentInbound,
@@ -608,6 +614,11 @@ import { resolveAnswerThreadId } from './answer-thread-resolve.js'
608
614
  import { resolveChatIdFallback } from './chat-id-fallback.js'
609
615
  import { decideObligationTurnEnd } from './obligation-turn-end.js'
610
616
  import { maybeRotate } from './turns-jsonl-rotate.js'
617
+ import {
618
+ buildTurnRecord,
619
+ finalizeBackstopSend,
620
+ type DeliveryOutcome,
621
+ } from './turn-record-status.js'
611
622
  import {
612
623
  createDeliveryQueue,
613
624
  trackDelivery,
@@ -713,6 +724,7 @@ import { redact } from '../secret-detect/redact.js'
713
724
  import { classifyAdminGate } from '../admin-commands/index.js'
714
725
  import {
715
726
  startSubagentWatcher,
727
+ resolveInflightTerminalCapMs,
716
728
  type SubagentWatcherHandle,
717
729
  } from '../subagent-watcher.js'
718
730
  import { listRecords as listWorktreeRecords, touchHeartbeat as touchWorktreeHeartbeat } from '../../src/worktree/registry.js'
@@ -2015,6 +2027,14 @@ function resolveSubagentOriginChat(
2015
2027
  * hits the fallback). A gateway restart clears it.
2016
2028
  */
2017
2029
  const WORKER_FEED_FALLBACK_LOG_CAP = 256
2030
+ /**
2031
+ * Margin added to the watcher's in-flight terminal cap to form the worker-feed
2032
+ * backstop TTL (`staleWorkerTtlMs`). The feed reaps a row only once it has been
2033
+ * silent for cap + this margin — comfortably past the watcher's own terminal-
2034
+ * transition latency, so the backstop can only ever bite a genuinely ghosted
2035
+ * slot, never a live-but-quiet worker. 5 min.
2036
+ */
2037
+ const WORKER_FEED_STALE_TTL_MARGIN_MS = 5 * 60_000
2018
2038
  const workerFeedOwnerDmFallbackLogged = new Set<string>()
2019
2039
 
2020
2040
  /**
@@ -3039,6 +3059,16 @@ type CurrentTurn = {
3039
3059
  // even though `replyCalled` is true — the #1664 case where the real answer
3040
3060
  // ended up as plain transcript text rendered into an ephemeral draft.
3041
3061
  finalAnswerDelivered: boolean
3062
+ // PR B (send-honesty). The REAL fate of a backstop (turn-flush) send,
3063
+ // stamped only AFTER the async send resolves inside the IIFE — success →
3064
+ // 'delivered', throw/partial → 'failed', reply-tool-already-sent
3065
+ // short-circuit → 'suppressed'. `emitTurnRecord` derives the recorded
3066
+ // status from THIS (via `computeTurnStatus`) rather than the speculative
3067
+ // `finalAnswerDelivered` flag, so a flood-dropped answer is logged
3068
+ // `send_failed` instead of a false `complete`. Undefined on the synchronous
3069
+ // turn-end paths (reply-tool tail, silent-marker, genuine no-reply), where
3070
+ // the legacy `finalAnswerDelivered` reading still applies unchanged.
3071
+ deliveryOutcome?: DeliveryOutcome
3042
3072
  // Feed-reopen-after-ack refinement — whether the reply that set
3043
3073
  // `finalAnswerDelivered` was a *substantive* final answer (stream
3044
3074
  // `done`, or ≥200 chars) as opposed to a short pinging interim ACK.
@@ -3100,6 +3130,14 @@ type CurrentTurn = {
3100
3130
  silentAnchorText: string
3101
3131
  capturedText: string[]
3102
3132
  orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
3133
+ // PR A — answer-ready quiescence flush timer. Armed in `case 'text'` once the
3134
+ // turn has a genuine composed terminal answer and is quiescent; fires a
3135
+ // positive `answer-ready-quiescence` synthetic turn_end after the ~1 s
3136
+ // debounce so the answer is delivered deterministically instead of waiting on
3137
+ // the unreliable turn_duration signal or the ~150 s orphaned-reply backstop.
3138
+ // Cleared on any tool activity, on `endCurrentTurnAtomic`, and re-armed on
3139
+ // each subsequent text chunk (the debounce).
3140
+ answerReadyFlushTimeoutId: ReturnType<typeof setTimeout> | null
3103
3141
  // Per-turn liveness tracker for the orphaned-reply backstop. Owns
3104
3142
  // `lastStreamEventAt` (stamped on ANY genuine stream event so a model
3105
3143
  // reasoning pause keeps the turn "recently streaming" and re-arms the fuse
@@ -3212,17 +3250,19 @@ type CurrentTurn = {
3212
3250
  // (via `renderActivityFeed`) as a capped chronological list into the
3213
3251
  // in-place edited activity message and clears on reply. Reset per turn.
3214
3252
  mirrorLines: string[]
3215
- // Narrative-dedup gate state (JSONL-text-narrative primitive). A `text`
3216
- // block is held here for ONE lookahead step so the next event (a tool_use
3217
- // or turn_end) can decide draft-then-send (SUPPRESS, it duplicates the
3218
- // reply) vs working-narration (SHOW it as a transient mirrorLines step).
3219
- // Null when nothing is pending. The pure decision lives in
3220
- // narrative-dedup.ts; this slot is the per-turn cursor. Reset per turn.
3221
- // Invariant `chat-is-the-single-source-of-truth`: a SHOWN narrative is
3222
- // rendered through the SAME appendActivityLabel→renderStepFeed path as a
3223
- // tool step a transient, clipped, rolling-window line replaced by the
3224
- // next event, never a persisted parallel mirror.
3225
- pendingNarrative: { text: string } | null
3253
+ // Narrative-dedup gate state (JSONL-text-narrative primitive). A `text` block
3254
+ // is parked for ONE lookahead step so the next event (a tool_use or turn_end)
3255
+ // can decide draft-then-send (SUPPRESS, it duplicates the reply) vs
3256
+ // working-narration (SHOW it as a transient mirrorLines step). The pure park /
3257
+ // timer / retract state machine lives in `narrative-flush.ts`; this controller
3258
+ // is the per-turn instance, wired with the SHOW effect (`showNarrativeStep`),
3259
+ // the RETRACT effect (splice the timer-painted line out of `mirrorLines`), and
3260
+ // a real `unref`'d `setTimeout` scheduler for the ~`PENDING_NARRATIVE_FLUSH_MS`
3261
+ // early paint. Reset per turn. Invariant `chat-is-the-single-source-of-truth`:
3262
+ // a SHOWN narrative renders through the SAME appendActivityLabel→renderStepFeed
3263
+ // path as a tool step — transient, clipped, rolling-window, never a persisted
3264
+ // parallel mirror.
3265
+ narrativeGate: NarrativeFlushController
3226
3266
  // Most-recently-seen reply/stream_reply `input.text` for this turn — the
3227
3267
  // ACTUAL delivered answer surface. Set wherever a REPLY_TOOL tool_use is
3228
3268
  // handled in the reducer. `flushPendingNarrativeAtTurnEnd` compares a
@@ -4738,14 +4778,19 @@ function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
4738
4778
  function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
4739
4779
  try {
4740
4780
  const rec =
4741
- JSON.stringify({
4742
- ts: Math.floor(endedAt / 1000),
4743
- agent: process.env.SWITCHROOM_AGENT_NAME ?? 'unknown',
4744
- duration_ms: turn.startedAt > 0 ? endedAt - turn.startedAt : 0,
4745
- tools: turn.toolCallCount ?? 0,
4746
- status: turn.finalAnswerDelivered ? 'complete' : 'no_reply',
4747
- turn_id: turn.turnId,
4748
- }) + '\n'
4781
+ JSON.stringify(
4782
+ buildTurnRecord(
4783
+ {
4784
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? 'unknown',
4785
+ startedAt: turn.startedAt,
4786
+ toolCallCount: turn.toolCallCount ?? 0,
4787
+ turnId: turn.turnId,
4788
+ finalAnswerDelivered: turn.finalAnswerDelivered,
4789
+ deliveryOutcome: turn.deliveryOutcome,
4790
+ },
4791
+ endedAt,
4792
+ ),
4793
+ ) + '\n'
4749
4794
  const turnsPath = '/state/agent/turns.jsonl'
4750
4795
  // Size-cap rotation: keep at most one rotated generation so the file can't
4751
4796
  // grow unbounded on a long-lived agent. Best-effort (never throws).
@@ -4765,7 +4810,10 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
4765
4810
  }
4766
4811
  }
4767
4812
 
4768
- function endCurrentTurnAtomic(turn: CurrentTurn): void {
4813
+ function endCurrentTurnAtomic(
4814
+ turn: CurrentTurn,
4815
+ opts?: { deferRecord?: boolean },
4816
+ ): number | null {
4769
4817
  // PR-4e — keyed liveness + keyed clear (leak-close-at-origin). Flag-OFF: the
4770
4818
  // guard is `currentTurn === turn` and the clear nulls the singleton, verbatim.
4771
4819
  // Flag-ON: the guard becomes `byKey.get(turn'sKey) === turn` (so a flip to
@@ -4774,7 +4822,12 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
4774
4822
  // `turn`. `endCurrentTurnForKey` returns false (no delete) when the entry no
4775
4823
  // longer matches — the same early-return semantics as the old `!== turn` guard.
4776
4824
  const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
4777
- if (!turnLiveForItsTopic(turn)) return
4825
+ if (!turnLiveForItsTopic(turn)) return null
4826
+ // PR A — the turn is ending (this is the ONE place every turn-end path funnels
4827
+ // through, incl. the answer-ready flush's own synthetic turn_end). Clear the
4828
+ // quiescence timer so a real turn_end that lands first cancels a pending flush,
4829
+ // guaranteeing exactly-once delivery.
4830
+ clearAnswerReadyFlushTimeout(turn)
4778
4831
  endCurrentTurnForKey(turn, key) // currentTurnByKey.delete(key) + mirror clear
4779
4832
  // Status-surface observability: one line at every turn CLEAR (with how far
4780
4833
  // the turn got), plus a DEGRADED warning when the turn did tool work but the
@@ -4783,7 +4836,15 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
4783
4836
  process.stderr.write(
4784
4837
  `telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
4785
4838
  )
4786
- emitTurnRecord(turn, turnEndedAt)
4839
+ // PR B — the turn-flush backstop defers the record write to its async send
4840
+ // IIFE (passing `{ deferRecord: true }`) so the recorded `status` reflects the
4841
+ // REAL send outcome (`turn.deliveryOutcome`) rather than the speculative
4842
+ // `finalAnswerDelivered` flag set before the send ran. All synchronous
4843
+ // turn-end paths still emit here, unchanged. `turnEndedAt` is returned so the
4844
+ // deferred caller stamps the same ended-at (stable `duration_ms`).
4845
+ if (opts?.deferRecord !== true) {
4846
+ emitTurnRecord(turn, turnEndedAt)
4847
+ }
4787
4848
  const degraded = detectStatusSurfaceDegraded(turn)
4788
4849
  if (degraded != null) {
4789
4850
  process.stderr.write(
@@ -4831,6 +4892,11 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
4831
4892
  clearTimeout(turn.noReplyDrainTimer)
4832
4893
  turn.noReplyDrainTimer = null
4833
4894
  }
4895
+ // Teardown the narrative gate's early-paint timer so it can neither leak past
4896
+ // the turn nor fire against a torn-down turn. (Idempotent — no-op when never
4897
+ // armed / already fired. `flushPendingNarrativeAtTurnEnd` on the turn_end event
4898
+ // normally disarms it first; this is the belt-and-braces teardown net.)
4899
+ turn.narrativeGate?.teardown()
4834
4900
  // Pass `turn` so purgeReactionTracking sees the authoritative
4835
4901
  // replyCalled flag even though we just nulled module-scope
4836
4902
  // currentTurn. Without this, the shadow trace's outboundEmitted
@@ -4846,6 +4912,7 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
4846
4912
  // wedging forever. No-op when this turn delivered, when nothing is
4847
4913
  // buffered, or when the serialize feature is off.
4848
4914
  armNoReplyDrainTimer(turn)
4915
+ return turnEndedAt
4849
4916
  }
4850
4917
 
4851
4918
  /**
@@ -4961,31 +5028,25 @@ function maybeProactiveCompact(): void {
4961
5028
  // turn, so this runs on its own interval. "Idle" means NOTHING HAS HAPPENED
4962
5029
  // since the last thing happened — inbound, cron fire, or ANY claude session
4963
5030
  // event (turn start, tool call, tool result, text, sub-agent event, turn end)
4964
- // resets the timer via markIdleActivity(); a turn ending additionally stamps
4965
- // markIdleTurnEnd(). Fires once per idle period; never mid-turn
5031
+ // resets the timer via idleTracker.noteInbound/noteEvent; a turn ending
5032
+ // additionally stamps the turn-end clock. Fires once per idle period; never mid-turn
4966
5033
  // (turnInFlightForGate, the same gate compaction uses).
4967
5034
  //
4968
5035
  // It is emphatically NOT "no turn has *started* recently" — that reading wiped
4969
5036
  // overlord's 3h of working context on 2026-07-11 for the crime of being busy.
4970
5037
  // See the idle-clear.ts header.
4971
- let lastIdleActivityAt = Date.now();
4972
- let lastIdleTurnEndAt: number | null = null;
4973
- let idleAutoCleared = false;
4974
- let idleClearDispatching = false;
5038
+ // #3115 idle bookkeeping is ONE stateful object the gateway holds, not four
5039
+ // bare `let`s + scattered inline stamp/decide logic. The tracker owns the
5040
+ // clocks (lastActivity / lastTurnEnd) and the fire-once + re-entrancy latches;
5041
+ // the gateway feeds it environment inputs (window, turn gate, background-work
5042
+ // suppressor) at decision time. Extracting it makes the wiring importable and
5043
+ // testable — a deleted stamp call now fails a test instead of silently
5044
+ // re-introducing the #3113 "productive work gets wiped" bug. See idle-clear.ts.
5045
+ const idleTracker = new IdleTracker(Date.now());
4975
5046
 
4976
5047
  /** Reset the idle timer + re-arm auto-clear. Call on ANY activity. */
4977
5048
  function markIdleActivity(): void {
4978
- lastIdleActivityAt = Date.now();
4979
- idleAutoCleared = false;
4980
- }
4981
-
4982
- /**
4983
- * Stamp "a turn just ended". The idle window is measured from
4984
- * max(lastActivityAt, lastTurnEndedAt), so a turn that ran LONGER than the
4985
- * window can't be cleared on the first tick after `turnInFlight` goes false.
4986
- */
4987
- function markIdleTurnEnd(): void {
4988
- lastIdleTurnEndAt = Date.now();
5049
+ idleTracker.noteInbound(Date.now());
4989
5050
  }
4990
5051
 
4991
5052
  /** Idle window in ms: env override → per-agent config → 3h default. 0 disables. */
@@ -5012,40 +5073,77 @@ function resolveIdleClearMs(): number {
5012
5073
 
5013
5074
  /** Evaluate idle auto-clear (runs on IDLE_CLEAR_CHECK_MS interval). */
5014
5075
  function maybeIdleClear(): void {
5015
- if (idleClearDispatching) return;
5076
+ if (idleTracker.isDispatching) return;
5016
5077
  const agentName = process.env.SWITCHROOM_AGENT_NAME;
5017
5078
  if (!agentName) return;
5018
5079
  const idleClearMs = resolveIdleClearMs();
5019
- const decision = decideIdleClear(
5020
- {
5021
- lastActivityAt: lastIdleActivityAt,
5022
- lastTurnEndedAt: lastIdleTurnEndAt,
5023
- idleClearMs,
5024
- alreadyCleared: idleAutoCleared,
5025
- turnInFlight: turnInFlightForGate(),
5026
- },
5027
- Date.now(),
5028
- );
5080
+ const decision = idleTracker.decide(Date.now(), {
5081
+ idleClearMs,
5082
+ turnInFlight: turnInFlightForGate(),
5083
+ // #3117 — TTL-bounded background-work suppressor. A detached sub-agent
5084
+ // (Agent/Task dispatched, main turn ended before it returned) is invisible
5085
+ // to turnInFlightForGate(); consult the pending-dispatch flag directly so a
5086
+ // silent long-running worker isn't /clear'ed out from under its handback.
5087
+ // The TTL keeps a leaked flag from disabling idle-clear forever.
5088
+ backgroundWorkInFlight: pendingProgress.anyPendingAsyncDispatchWithin(
5089
+ pendingProgress.BACKGROUND_WORK_SUPPRESS_TTL_MS,
5090
+ ),
5091
+ });
5029
5092
  if (!decision.clear) return;
5030
5093
  // Fire once per idle period — set BEFORE the await so the next tick can't
5031
5094
  // double-dispatch. markIdleActivity() re-arms on the next real activity.
5032
- idleAutoCleared = true;
5033
- idleClearDispatching = true;
5095
+ idleTracker.markClearFired();
5096
+ idleTracker.beginDispatch();
5034
5097
  process.stderr.write(
5035
5098
  `telegram gateway: idle auto-/clear for ${agentName} ` +
5036
5099
  `(idle >= ${Math.round(idleClearMs / 60_000)}m)\n`,
5037
5100
  );
5038
- // Accepted check-to-send race (same as maybeProactiveCompact): a new inbound
5039
- // could arrive between the gate check and the tmux send; /clear then lands in
5040
- // claude's prompt buffer and runs at the next idle prompt (inject.ts FUTURE-GAP).
5041
- void injectSlashCommandImpl(agentName, '/clear')
5101
+ // #3116 — write-time precondition closes (most of) the check-to-send race.
5102
+ // A new inbound could arrive between the gate decision above and the tmux
5103
+ // send; without a guard /clear lands in claude's prompt buffer and runs at
5104
+ // the next idle prompt (inject.ts FUTURE-GAP), clobbering that inbound's
5105
+ // session. The precondition below is re-evaluated INSIDE the pane lock,
5106
+ // immediately before send-keys, and RE-RUNS THE FULL idle decision against
5107
+ // the live clocks + turnInFlightForGate() — not a bespoke "activity
5108
+ // unchanged" check — so any new activity in the gap suppresses the /clear
5109
+ // (and so a future background-work input to the idle decision is honoured at
5110
+ // write time for free). Residual: an inbound landing AFTER send-keys but
5111
+ // before claude submits the buffered /clear is still clobbered — full
5112
+ // closure needs buffer-cancel (tracked as a follow-up issue).
5113
+ // alreadyCleared is latched true above for re-entrancy; decideIgnoringLatch
5114
+ // judges idleness on the live clocks + turn/background gates only, so any
5115
+ // activity that arrived in the check-to-send gap still suppresses the /clear.
5116
+ // #3117 — the background-work suppressor is re-sampled at write time too, so a
5117
+ // sub-agent dispatched in the gap (or still fresh within its TTL) suppresses
5118
+ // the buffered /clear. This composes with #3116's write-time re-eval
5119
+ // automatically — same tracker/decider, live inputs.
5120
+ const stillIdleAtWrite = (): boolean =>
5121
+ idleTracker.decideIgnoringLatch(Date.now(), {
5122
+ idleClearMs: resolveIdleClearMs(),
5123
+ turnInFlight: turnInFlightForGate(),
5124
+ backgroundWorkInFlight: pendingProgress.anyPendingAsyncDispatchWithin(
5125
+ pendingProgress.BACKGROUND_WORK_SUPPRESS_TTL_MS,
5126
+ ),
5127
+ }).clear;
5128
+ void injectSlashCommandImpl(agentName, '/clear', { precondition: stillIdleAtWrite })
5129
+ .then((result) => {
5130
+ if (result.outcome === 'skipped') {
5131
+ // Activity arrived in the check-to-send gap; re-arm so the next idle
5132
+ // period can clear again rather than staying latched.
5133
+ idleTracker.reArm();
5134
+ process.stderr.write(
5135
+ `telegram gateway: idle /clear suppressed for ${agentName} ` +
5136
+ `(activity in check-to-send gap)\n`,
5137
+ );
5138
+ }
5139
+ })
5042
5140
  .catch((err: unknown) => {
5043
5141
  process.stderr.write(
5044
5142
  `telegram gateway: idle /clear inject failed for ` +
5045
5143
  `${agentName}: ${err instanceof Error ? err.message : String(err)}\n`,
5046
5144
  );
5047
5145
  })
5048
- .finally(() => { idleClearDispatching = false; });
5146
+ .finally(() => { idleTracker.endDispatch(); });
5049
5147
  }
5050
5148
 
5051
5149
  /**
@@ -7690,6 +7788,21 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
7690
7788
  function emitGatewayOperatorEvent(event: OperatorEvent): void {
7691
7789
  const { agent, kind } = event
7692
7790
 
7791
+ // #llm-error-surfacing FIX 2 (secret leak): the operator-event cards are sent
7792
+ // via a raw bot.api.sendRichMessage that BYPASSES the normal outbound redact
7793
+ // chokepoint (normalizeOutboundBody → redact, outbound-send-path.ts). The only
7794
+ // scrub the renderers apply is stripRawErrorBytes — a JSON-SHAPE scrub, NOT a
7795
+ // secret scrubber — so a bearer token / `sk-…` key / url-embedded credential
7796
+ // smuggled in an error `detail` would reach the operator card verbatim on the
7797
+ // credentials-expired / credit-exhausted / unknown-4xx paths. Redact the detail
7798
+ // ONCE here, up front, through the SAME redact() the reply path uses — and
7799
+ // crucially BEFORE renderOperatorEvent runs escapeMarkdown on it (redacting the
7800
+ // already-escaped text would let url-query-param secrets slip past url-redact,
7801
+ // exactly the order the outbound pipeline documents: redact before markdown).
7802
+ // Doing it at the top also scrubs the recorded operator-event history and the
7803
+ // 429 metrics — defense in depth, no secret survives in ANY downstream sink.
7804
+ event = { ...event, detail: redactOutboundText(event.detail, 'operator_event') }
7805
+
7693
7806
  // ── 429 throttle tier (operator spec: "retry in place under 5 min, else
7694
7807
  // mark + failover, honest reset messaging") ────────────────────────────
7695
7808
  // A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
@@ -7957,9 +8070,16 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7957
8070
  return
7958
8071
  }
7959
8072
  const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
7960
- const r = renderLlmError(parsed, agent, tz, new Date(now))
8073
+ // #llm-error-surfacing FIX 3 (crash guard): renderLlmErrorSafe wraps the
8074
+ // tz-formatting render — an invalid IANA `SWITCHROOM_TIMEZONE`/`TZ` throws a
8075
+ // RangeError out of Intl.DateTimeFormat (local-time.ts's "never throws" claim
8076
+ // does NOT hold for construction-time zone validation). Pre-fix this branch
8077
+ // had no guard, so a bad tz crashed the whole operator-event turn; now it
8078
+ // degrades to a minimal tz-free line. There are no action buttons on this
8079
+ // card (FIX 1) — the humanized text carries any recommendation inline.
8080
+ const r = renderLlmErrorSafe(parsed, agent, tz, new Date(now))
7961
8081
  renderedText = r.text
7962
- renderedKeyboard = r.keyboard
8082
+ renderedKeyboard = undefined
7963
8083
  } else {
7964
8084
  try {
7965
8085
  const r = renderOperatorEvent(event)
@@ -8103,6 +8223,10 @@ const STREAM_THROTTLE_MS_OVERRIDE: number | undefined = (() => {
8103
8223
  })()
8104
8224
  const TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled()
8105
8225
 
8226
+ // PR A — answer-ready quiescence flush debounce (ms). 0 = kill-switch (never
8227
+ // arm). Resolved once at boot; env-tunable via SWITCHROOM_ANSWER_READY_FLUSH_MS.
8228
+ const ANSWER_READY_FLUSH_MS = resolveAnswerReadyFlushMs(process.env)
8229
+
8106
8230
  // When SET, the answer-lane stream (telegram-plugin/answer-stream.ts) renders
8107
8231
  // the model's transcript text as a USER-VISIBLE edit-in-place message. Default
8108
8232
  // OFF: the lane stays dormant and the reply tool is the single canonical
@@ -9155,6 +9279,17 @@ const SILENCE_FLOOR_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FLOOR_MS', 45_00
9155
9279
  // #2527 — role-aware terminal reaction honesty (the "thumbs-up false done"
9156
9280
  // fix). Default ON; SWITCHROOM_TG_TERMINAL_HONESTY=0 reverts to always-👍.
9157
9281
  const LIVENESS_TERMINAL_HONESTY = process.env.SWITCHROOM_TG_TERMINAL_HONESTY !== '0'
9282
+ // Option A transcript-prose delivery bridge. When a user turn ends without a
9283
+ // final answer, the Stop hook scans transcript_path and — if it isolated a
9284
+ // substantive answer the model wrote as plain text but never sent through the
9285
+ // reply tool — persists it in silent-end-pending.json as `pendingText`. On the
9286
+ // FIRST silent-end the gateway reads it back and delivers it directly via the
9287
+ // normal send path, instead of relying on the (unreliable) Stop-hook re-prompt
9288
+ // or waiting ~2-5 min for the obligation represent to recover it. Default ON;
9289
+ // SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY=0 reverts to the pre-bridge behaviour
9290
+ // (re-prompt + represent only).
9291
+ const CAPTURED_PROSE_DELIVERY_ENABLED =
9292
+ process.env.SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY !== '0'
9158
9293
  // SILENCE_DEFER_INFLIGHT_TOOLS: previously an opt-in (=1). The new
9159
9294
  // isLegitimatelyWorking callback supersedes this — defer is now the DEFAULT
9160
9295
  // when the callback is wired. The legacy flag is kept so `=0` still lets
@@ -9967,6 +10102,250 @@ function agentHasInFlightBackgroundWork(now: number): boolean {
9967
10102
  // Throttle for the background-work defer diagnostic (the 5s sweep would otherwise
9968
10103
  // log every tick across a multi-minute research window).
9969
10104
  let lastBgWorkDeferLogMs = 0
10105
+ /**
10106
+ * Option A transcript-prose delivery. Deliver the model's real final answer —
10107
+ * isolated by the Stop hook's transcript scan and persisted as `pendingText`
10108
+ * — directly via the normal send path on the first silent-end, then settle the
10109
+ * bookkeeping so neither the obligation represent nor the exhausted fallback
10110
+ * re-fires for the same answer.
10111
+ *
10112
+ * Double-send guard — content + turnKey dedup, NOT unconditional (Finding 4,
10113
+ * #3228: the earlier "airtight by construction" claim overstated it):
10114
+ * - Before sending, `outboundDedup.check` is consulted. If the EXACT same
10115
+ * content already went out under the SAME `registryKey` (a prior turn_end
10116
+ * delivered it, or the re-prompted model's own reply landed), we SKIP the
10117
+ * send and only settle bookkeeping.
10118
+ * - After a successful send, `outboundDedup.record` is written so a late
10119
+ * reply-tool retry with the same content is suppressed at its send site
10120
+ * (executeReply / executeStreamReply already consult the same cache).
10121
+ * - The obligation is CLOSED and the silent-end state CLEARED, so
10122
+ * obligationSweep's "outbound delivered since open" guard and the represent
10123
+ * never re-fire for this origin.
10124
+ *
10125
+ * Divergence boundary (honest scope): the dedup key is
10126
+ * `(chat, thread, normalized-content, registryKey)`. It suppresses the common
10127
+ * double-send — the SAME answer re-sent within the TTL under the same
10128
+ * registryKey. It does NOT catch a re-present that lands under a DIFFERENT
10129
+ * registryKey with REWORDED content (different hash AND different key → the
10130
+ * cross-turn carve-out in recent-outbound-dedup.ts:150-156 treats two non-null
10131
+ * differing turnKeys as a miss): that is a genuinely different outbound and is
10132
+ * out of scope for content-hash dedup. The obligation close + state clear are
10133
+ * what make captured-prose and the represent mutually exclusive for the
10134
+ * common case; the content dedup is a second, best-effort layer.
10135
+ *
10136
+ * Failure posture (Finding 1 + exhaustion-boundary gap, #3228): if the send
10137
+ * throws, the catch below arms the deterministic Stop-hook re-prompt via
10138
+ * recordUndeliveredTurnEnd (the shared teardown may have already closed the
10139
+ * obligation when replyCalled was true, so "leaving the obligation open" is NOT
10140
+ * a sufficient net on its own). Two sub-cases, honored via the `{exhausted}`
10141
+ * verdict threaded out of settleCapturedProseDelivery:
10142
+ * - budget REMAINING (`exhausted:false`) → the silent-end state is (re)written
10143
+ * and the Stop-hook re-prompt will recover the answer; nothing else to do.
10144
+ * - budget SPENT (`exhausted:true`) → recordUndeliveredTurnEnd has CLEARED the
10145
+ * state (the re-prompt can no longer fire), and the obligation was already
10146
+ * closed by the interim-ack teardown, so the user would get NEITHER the
10147
+ * answer NOR the apology. We therefore deliver a user-facing fallback: first
10148
+ * retry the captured prose as PLAIN TEXT (a non-rich send often survives the
10149
+ * markdown/parse error a rich send rejected — the user gets the real
10150
+ * answer), and only if THAT also fails post the generic silentEndFallbackText
10151
+ * apology. This mirrors the non-captured exhausted path (silent-end.ts /
10152
+ * gateway turn_end #1161) so the captured path is never worse than main.
10153
+ */
10154
+ async function deliverCapturedProse(args: {
10155
+ chatId: string
10156
+ threadId: number | undefined
10157
+ statusKeyStr: string
10158
+ registryKey: string | null
10159
+ originTurnId: string
10160
+ text: string
10161
+ /** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
10162
+ turnDurationMs?: number
10163
+ }): Promise<void> {
10164
+ const { chatId, threadId, statusKeyStr, registryKey, originTurnId, text, turnDurationMs } = args
10165
+ const now = Date.now()
10166
+ // #3228 Finding 1 — the three settlement points (sent / skipped-dedup /
10167
+ // failed) all funnel through the pure `settleCapturedProseDelivery` core so
10168
+ // the failure posture is deterministic and unit-tested. `outcome` is set on
10169
+ // each branch and applied ONCE at the bottom.
10170
+ let outcome: CapturedProseSendOutcome
10171
+ const already = outboundDedup.check(chatId, threadId, text, now, registryKey)
10172
+ if (already == null) {
10173
+ let out = normalizeParagraphBreaks(repairEscapedWhitespace(text))
10174
+ out = redactOutboundText(out, 'captured_prose')
10175
+ const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
10176
+ const sentIds: number[] = []
10177
+ try {
10178
+ let liveThreadId: number | undefined = threadId
10179
+ for (const c of chunks) {
10180
+ const sent = await retryWithThreadFallback(
10181
+ robustApiCall,
10182
+ (tid) => {
10183
+ // Built as a variable (not an inline literal) so excess-property
10184
+ // checks don't reject `link_preview_options` on sendRichMessage's
10185
+ // narrow Other<> type — mirrors the turn-flush send site.
10186
+ const opts = {
10187
+ link_preview_options: { is_disabled: true },
10188
+ ...(tid != null ? { message_thread_id: tid } : {}),
10189
+ }
10190
+ return bot.api.sendRichMessage(chatId, richMessage(c), opts)
10191
+ },
10192
+ { threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose.sendMessage' },
10193
+ )
10194
+ if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
10195
+ liveThreadId = undefined
10196
+ }
10197
+ sentIds.push(sent.message_id)
10198
+ }
10199
+ if (HISTORY_ENABLED && sentIds.length > 0) {
10200
+ try {
10201
+ recordOutbound({
10202
+ chat_id: chatId,
10203
+ thread_id: threadId ?? null,
10204
+ message_ids: sentIds,
10205
+ texts: chunks,
10206
+ })
10207
+ } catch {}
10208
+ }
10209
+ // Record what we just sent so a late reply / stream_reply retry with the
10210
+ // same content is deduped at its send site (the #546 dedup cache).
10211
+ outboundDedup.record(chatId, threadId, text, now, registryKey)
10212
+ process.stderr.write(
10213
+ `telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
10214
+ `transcript scan (chat=${chatId} origin=${originTurnId})\n`,
10215
+ )
10216
+ outcome = 'sent'
10217
+ } catch (err) {
10218
+ // #3228 Finding 1 — the send threw, so the answer did NOT reach the user.
10219
+ // The `failed` outcome routes to `settleCapturedProseDelivery`'s recovery
10220
+ // path (recordUndelivered), NOT the close/clear path. This is load-bearing:
10221
+ // the shared turn-end teardown (endCurrentTurnAtomic →
10222
+ // decideObligationTurnEnd) already closes the obligation whenever
10223
+ // `replyCalled === true` — exactly the interim-ack case that routes here —
10224
+ // so "leaving the obligation open" is not a real net. Arming the Stop-hook
10225
+ // re-prompt makes the failed send recoverable instead of silently lost.
10226
+ process.stderr.write(
10227
+ `telegram gateway: captured-prose delivery failed: ${(err as Error).message} — ` +
10228
+ `arming the silent-end re-prompt net (recordUndeliveredTurnEnd) so the ` +
10229
+ `answer is recoverable (chat=${chatId} origin=${originTurnId})\n`,
10230
+ )
10231
+ outcome = 'failed'
10232
+ }
10233
+ } else {
10234
+ process.stderr.write(
10235
+ `telegram gateway: captured-prose delivery skipped — this answer already went out ` +
10236
+ `(dedup age=${already.ageMs}ms chat=${chatId} origin=${originTurnId}); settling bookkeeping\n`,
10237
+ )
10238
+ outcome = 'skipped-dedup'
10239
+ }
10240
+ // Apply the settlement bookkeeping through the pure core (#3228 Finding 1):
10241
+ // sent / skipped-dedup → close obligation + clear state (answer is with the
10242
+ // user, so represent + exhausted fallback must not fire)
10243
+ // failed → arm the Stop-hook re-prompt net (recordUndelivered),
10244
+ // do NOT close/clear.
10245
+ const settlement = settleCapturedProseDelivery(outcome, {
10246
+ closeObligation: () => {
10247
+ if (OBLIGATION_LEDGER_ENABLED) {
10248
+ try { obligationLedger.close(originTurnId) } catch {}
10249
+ }
10250
+ },
10251
+ clearState: () => clearSilentEndState(statusKeyStr),
10252
+ recordUndelivered: () => {
10253
+ try {
10254
+ const silentEndDeps: SilentEndDeps | undefined = HISTORY_ENABLED
10255
+ ? {
10256
+ hasOutboundDeliveredSince: (cid, sinceMs, tid) =>
10257
+ hasOutboundDeliveredSince(cid, sinceMs, tid, 1),
10258
+ }
10259
+ : undefined
10260
+ return recordUndeliveredTurnEnd(
10261
+ { chatId, threadId: threadId ?? null, turnKey: statusKeyStr },
10262
+ silentEndDeps,
10263
+ )
10264
+ } catch (netErr) {
10265
+ process.stderr.write(
10266
+ `telegram gateway: captured-prose recovery-net arm failed: ${
10267
+ (netErr as Error).message
10268
+ } (chat=${chatId} origin=${originTurnId})\n`,
10269
+ )
10270
+ // Could not even record the undelivered turn — do NOT claim exhaustion
10271
+ // (firing a fallback we can't justify). Fail safe: leave recovery to
10272
+ // the obligation represent / next Stop hook.
10273
+ return { exhausted: false }
10274
+ }
10275
+ },
10276
+ })
10277
+
10278
+ // Exhaustion-boundary gap (#3228): the send FAILED on the attempt where the
10279
+ // Stop-hook re-prompt budget was already spent, so recordUndeliveredTurnEnd
10280
+ // cleared the state and the re-prompt can no longer recover the answer — and
10281
+ // the obligation was already closed by the interim-ack teardown. Without this
10282
+ // the user gets NEITHER the answer NOR the apology. Deliver a user-facing
10283
+ // fallback, preferring the REAL answer as plain text (a non-rich send often
10284
+ // survives the markdown/parse error the rich send threw on) before the
10285
+ // generic apology.
10286
+ if (outcome === 'failed' && settlement.exhausted) {
10287
+ process.stderr.write(
10288
+ `telegram gateway: WARN captured-prose exhausted-boundary fallback — rich send ` +
10289
+ `failed with the re-prompt budget already spent; attempting a plain-text ` +
10290
+ `delivery of the recovered answer before the generic apology ` +
10291
+ `(chat=${chatId} origin=${originTurnId})\n`,
10292
+ )
10293
+ const plain = redactOutboundText(text, 'captured_prose')
10294
+ const plainChunks = splitMarkdownChunks(plain, RICH_MESSAGE_MAX_CHARS)
10295
+ try {
10296
+ let liveThreadId: number | undefined = threadId
10297
+ for (const c of plainChunks) {
10298
+ // Plain sendMessage — NO parse_mode / rich rendering — so a markdown
10299
+ // construct that made sendRichMessage 400 is sent verbatim instead.
10300
+ const sent = await retryWithThreadFallback(
10301
+ robustApiCall,
10302
+ (tid) =>
10303
+ bot.api.sendMessage(
10304
+ chatId,
10305
+ c,
10306
+ tid != null ? { message_thread_id: tid } : {},
10307
+ ),
10308
+ { threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose-plain-fallback.sendMessage' },
10309
+ )
10310
+ if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
10311
+ liveThreadId = undefined
10312
+ }
10313
+ }
10314
+ // The real answer reached the user via plain text — record it so a late
10315
+ // reply-tool retry with the same content is deduped at its send site.
10316
+ outboundDedup.record(chatId, threadId, text, Date.now(), registryKey)
10317
+ process.stderr.write(
10318
+ `telegram gateway: captured-prose recovered via plain-text fallback ` +
10319
+ `(chat=${chatId} origin=${originTurnId})\n`,
10320
+ )
10321
+ } catch (plainErr) {
10322
+ // Plain text ALSO failed — post the generic apology so the turn is never
10323
+ // silent (mirrors the non-captured exhausted path, gateway turn_end #1161).
10324
+ process.stderr.write(
10325
+ `telegram gateway: captured-prose plain-text fallback ALSO failed: ${
10326
+ (plainErr as Error).message
10327
+ } — posting the generic silent-end apology (chat=${chatId} origin=${originTurnId})\n`,
10328
+ )
10329
+ void retryWithThreadFallback(
10330
+ robustApiCall,
10331
+ (tid) =>
10332
+ bot.api.sendMessage(
10333
+ chatId,
10334
+ silentEndFallbackText(turnDurationMs),
10335
+ tid != null ? { message_thread_id: tid } : {},
10336
+ ),
10337
+ { threadId, chat_id: chatId, verb: 'captured-prose-apology-fallback.sendMessage' },
10338
+ ).catch((err) => {
10339
+ process.stderr.write(
10340
+ `telegram gateway: captured-prose apology fallback send failed: ${
10341
+ err instanceof Error ? err.message : String(err)
10342
+ }\n`,
10343
+ )
10344
+ })
10345
+ }
10346
+ }
10347
+ }
10348
+
9970
10349
  function obligationSweep(): void {
9971
10350
  if (!OBLIGATION_LEDGER_ENABLED) return
9972
10351
  if (!obligationLedger.hasOpen()) return
@@ -11438,10 +11817,18 @@ const ipcServer: IpcServer = createIpcServer({
11438
11817
  },
11439
11818
 
11440
11819
  onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
11441
- // Cron fires (incl. cheap-cron, whose session events are dropped before
11442
- // currentTurn is set) are real activity re-arm idle auto-clear so a
11443
- // working scheduled agent isn't wiped after 3h of "no inbound".
11444
- markIdleActivity()
11820
+ // #3114 do NOT unconditionally stamp the MAIN idle clock here. A cron
11821
+ // fire (Tier-1 cheap-cron routed to `<agent>-cron`, or a Tier-2 main-
11822
+ // session cron) whose cadence is shorter than `idle_clear_after` would
11823
+ // otherwise re-arm the timer on every fire and suppress idle-clear
11824
+ // permanently. After #3113 a cron that does real work already stamps the
11825
+ // main clock via handleSessionEvent, so the blanket fire-time stamp is
11826
+ // redundant for main-bridge fires and wrong for cheap-cron (whose session
11827
+ // events are dropped for the cron identity). Only non-cron injects
11828
+ // (reaction, vault grant, resume — genuine operator/session presence) stamp
11829
+ // at inject time. See isCronInjectFire for the documented main-session-poll
11830
+ // residual (a Tier-2 NO_REPLY poll still warms the clock via its real turn).
11831
+ if (!isCronInjectFire(msg.inbound.meta)) markIdleActivity()
11445
11832
  const promptKey = typeof msg.inbound.meta?.prompt_key === 'string'
11446
11833
  ? msg.inbound.meta.prompt_key
11447
11834
  : 'unknown'
@@ -15000,7 +15387,8 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
15000
15387
  editRawText = redactOutboundText(editRawText, 'edit_message')
15001
15388
  // Fleet-wide consistent formatting (same order as the reply path: redact
15002
15389
  // first so secrets are matched literally, then normalize, then spacers on
15003
- // the rich path only).
15390
+ // the rich path only — the rich GFM renderer renders `\n\n` tight, so the
15391
+ // idempotent U+00A0 spacer restores a visible gap without double-spacing).
15004
15392
  if (!editLiteralText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
15005
15393
  // Voice scrub (#1683): same em-dash scrub as the reply path. Edits
15006
15394
  // are how silent-anchor and progress-update mutate already-sent
@@ -15289,6 +15677,68 @@ function resetOrphanedReplyTimeout(): void {
15289
15677
  }
15290
15678
  }
15291
15679
 
15680
+ /**
15681
+ * PR A — DETERMINISTIC answer-ready quiescence flush controller.
15682
+ *
15683
+ * The orchestration (arm / debounce / rollover-guard / fire-time re-verify /
15684
+ * disarm) lives in the extracted, unit-tested `AnswerReadyFlushController`
15685
+ * (answer-ready-flush.ts) — the gateway only supplies the thin deps below and
15686
+ * calls `.reset()` / `.clear(turn)`. Behaviour:
15687
+ *
15688
+ * - `reset()` (from `case 'text'`): (re)arm iff the turn has a genuine composed
15689
+ * terminal answer (the SAME `decideTurnFlush` classifier the turn-flush branch
15690
+ * uses) AND is quiescent. Each text chunk re-arms → the debounce.
15691
+ * - `clear(turn)` (from tool activity + `endCurrentTurnAtomic`): the DISARM.
15692
+ * - on fire: re-pin `currentTurn === turn`, re-verify quiescence, then dispatch
15693
+ * a positive `answer-ready-quiescence` synthetic turn_end that routes through
15694
+ * the IDENTICAL turn-flush send path (endCurrentTurnAtomic → send-gated IIFE →
15695
+ * honest PR-B record). Replaces the ~150 s dead wait with ~1 s, in code.
15696
+ *
15697
+ * Exactly-once: the synthetic turn_end's `endCurrentTurnAtomic` nulls the atom,
15698
+ * so a later REAL turn_end (or the orphaned backstop) short-circuits at its
15699
+ * `turn != null` guard; `endCurrentTurnAtomic` also calls `.clear(turn)` so a
15700
+ * real turn_end that lands FIRST cancels a pending flush. `outboundDedup` is the
15701
+ * second layer.
15702
+ *
15703
+ * The `answer-ready-quiescence` reason bypasses the `durationMs===-1`
15704
+ * recently-streaming SUPPRESSION guard (the terminal answer text itself stamps
15705
+ * recentlyStreaming): quiescence IS the positive "streaming has settled" signal,
15706
+ * the opposite of the hung-turn backstop that guard protects.
15707
+ */
15708
+ const answerReadyFlush = new AnswerReadyFlushController<CurrentTurn>({
15709
+ getCurrentTurn: () => currentTurn,
15710
+ getArmInput: (turn) => ({
15711
+ flush: {
15712
+ chatId: turn.sessionChatId,
15713
+ replyCalled: turn.replyCalled,
15714
+ capturedText: turn.capturedText,
15715
+ flushEnabled: TURN_FLUSH_SAFETY_ENABLED,
15716
+ },
15717
+ inFlightToolCount: toolFlightTracker.inFlightCount(),
15718
+ hasPendingAsyncDispatch: pendingProgress.hasPendingAsyncDispatch(
15719
+ statusKey(turn.sessionChatId, turn.sessionThreadId),
15720
+ ),
15721
+ flushWindowMs: ANSWER_READY_FLUSH_MS,
15722
+ }),
15723
+ getTimerHandle: (turn) => turn.answerReadyFlushTimeoutId,
15724
+ setTimerHandle: (turn, handle) => {
15725
+ turn.answerReadyFlushTimeoutId = handle
15726
+ },
15727
+ onFlush: () =>
15728
+ handleSessionEvent({ kind: 'turn_end', durationMs: -1, reason: 'answer-ready-quiescence' }),
15729
+ log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
15730
+ })
15731
+
15732
+ /** PR A — disarm the answer-ready flush timer for a turn (thin adapter). */
15733
+ function clearAnswerReadyFlushTimeout(turn: CurrentTurn | null): void {
15734
+ answerReadyFlush.clear(turn)
15735
+ }
15736
+
15737
+ /** PR A — (re)arm the answer-ready flush timer for the current turn (debounce). */
15738
+ function resetAnswerReadyFlushTimeout(): void {
15739
+ answerReadyFlush.reset()
15740
+ }
15741
+
15292
15742
  function closeActivityLane(chatId: string, threadId: number | undefined): void {
15293
15743
  const key = chatKeyWithSuffix(chatId, threadId, 'activity')
15294
15744
  const stream = activeDraftStreams.get(key)
@@ -15349,6 +15799,75 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
15349
15799
  return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
15350
15800
  }
15351
15801
 
15802
+ // PENDING_NARRATIVE_FLUSH_MS is now defined in and imported from
15803
+ // `narrative-flush.ts` (the kernel's home) so the main-agent gateway path and
15804
+ // the worker/sub-agent watcher share ONE source of truth for the time-box.
15805
+ // The main path paints a parked block via the SAME `showNarrativeStep` path a
15806
+ // lookahead would, and a timer-painted block that later proves to be the reply
15807
+ // is deterministically retracted (the RETRACT effect wired into the controller
15808
+ // below).
15809
+
15810
+ /**
15811
+ * Retract a narration step the flush timer painted EARLY that turned out to draft
15812
+ * the outgoing reply — the effect half of the anti-double-print guarantee's timer
15813
+ * path. Splice the line out of `mirrorLines` and re-render, so neither the live
15814
+ * nor the finalized card surfaces the answer as a narration step. The splice runs
15815
+ * synchronously BEFORE the reply's `clearActivitySummary` finalize reads
15816
+ * `mirrorLines`, so the persisted card is clean regardless of the re-render.
15817
+ */
15818
+ function retractNarrativeLine(turn: CurrentTurn, text: string): void {
15819
+ const clipped = clipNarrative(text)
15820
+ const idx = turn.mirrorLines.lastIndexOf(clipped)
15821
+ if (idx === -1) return // rolled out of the window already — nothing to retract
15822
+ turn.mirrorLines.splice(idx, 1)
15823
+ // Live re-render without the retracted line (the finalize path reads the same
15824
+ // spliced array; this only matters for the interim-ack case where finalize
15825
+ // isn't called on this reply). Guarded on a non-null render so an emptied feed
15826
+ // doesn't blank the card.
15827
+ const rerender = composeTurnActivity(turn)
15828
+ if (rerender == null) return
15829
+ turn.activityPendingRender = rerender
15830
+ const ea = emissionAuthorityFor(turn)
15831
+ cardDrainGate(turn, ea, () => {
15832
+ if (ea.mayDrain(turn)) {
15833
+ ea.openOrEditCard('narrative', () => {
15834
+ turn.activityInFlight = drainActivitySummary(turn, 'narrative')
15835
+ })
15836
+ }
15837
+ })
15838
+ }
15839
+
15840
+ /**
15841
+ * Build a per-turn narrative gate: the pure park/timer/retract state machine
15842
+ * (`narrative-flush.ts`) wired to THIS turn's SHOW effect (`showNarrativeStep`),
15843
+ * RETRACT effect (`retractNarrativeLine`), and a real `unref`'d `setTimeout`
15844
+ * scheduler. The scheduler captures the turn's own timer handle so a turn swap
15845
+ * can't mis-target it — mirrors the `noReplyDrainTimer` discipline.
15846
+ */
15847
+ function makeNarrativeGate(turn: CurrentTurn): NarrativeFlushController {
15848
+ let handle: ReturnType<typeof setTimeout> | null = null
15849
+ return new NarrativeFlushController(
15850
+ {
15851
+ show: (text) => showNarrativeStep(turn, text),
15852
+ retractShown: (text) => retractNarrativeLine(turn, text),
15853
+ },
15854
+ {
15855
+ arm: (fn, ms) => {
15856
+ if (handle != null) clearTimeout(handle)
15857
+ handle = setTimeout(fn, ms)
15858
+ handle.unref?.()
15859
+ },
15860
+ disarm: () => {
15861
+ if (handle != null) {
15862
+ clearTimeout(handle)
15863
+ handle = null
15864
+ }
15865
+ },
15866
+ },
15867
+ PENDING_NARRATIVE_FLUSH_MS,
15868
+ )
15869
+ }
15870
+
15352
15871
  /**
15353
15872
  * Render a SHOWN narrative text block as a transient liveness step — the
15354
15873
  * same path a tool label takes (appendActivityLabel → renderStepFeed), so
@@ -15394,27 +15913,20 @@ function resolvePendingNarrativeOnTool(
15394
15913
  toolName: string,
15395
15914
  input: Record<string, unknown> | undefined,
15396
15915
  ): void {
15397
- const pending = turn.pendingNarrative
15398
- if (pending == null) return
15399
- turn.pendingNarrative = null
15400
- if (REPLY_TOOLS.has(toolName)) {
15401
- const replyText = typeof input?.text === 'string' ? (input.text as string) : ''
15402
- if (isDraftOfReply(pending.text, replyText)) return // draft of the answer → SUPPRESS
15403
- }
15404
- showNarrativeStep(turn, pending.text) // working preamble / post-action narration → SHOW
15916
+ // Delegate to the pure park/timer/retract kernel: it cancels the early-paint
15917
+ // timer, retracts a timer-painted block that THIS reply drafts (anti-double-
15918
+ // print), then SHOWs / SUPPRESSes the parked block. See narrative-flush.ts §2.
15919
+ turn.narrativeGate.resolveOnTool(toolName, input)
15405
15920
  }
15406
15921
 
15407
15922
  /**
15408
15923
  * Narrative-dedup gate, step 1 (reducer-side): a new narrative block
15409
15924
  * arrived. A previously-pending block had nothing reply-shaped immediately
15410
15925
  * after it (pure narration) → flush it as SHOWN, then stage the new one for
15411
- * one lookahead step. See narrative-dedup.ts §2b.
15926
+ * one lookahead step AND arm the time-boxed early paint. See narrative-flush.ts.
15412
15927
  */
15413
15928
  function stagePendingNarrative(turn: CurrentTurn, text: string): void {
15414
- if (turn.pendingNarrative != null) {
15415
- showNarrativeStep(turn, turn.pendingNarrative.text)
15416
- }
15417
- turn.pendingNarrative = { text }
15929
+ turn.narrativeGate.stage(text)
15418
15930
  }
15419
15931
 
15420
15932
  /**
@@ -15422,14 +15934,11 @@ function stagePendingNarrative(turn: CurrentTurn, text: string): void {
15422
15934
  * trailing narrative block and nothing after it. SUPPRESS only when the turn
15423
15935
  * already delivered its answer via reply/stream_reply and the trailing text
15424
15936
  * is a draft of that answer; otherwise SHOW (genuine trailing narration like
15425
- * "Done — all green."). See narrative-dedup.ts §2b.
15937
+ * "Done — all green."). Also cancels the early-paint timer and retracts a
15938
+ * timer-painted draft of the delivered answer. See narrative-flush.ts §3.
15426
15939
  */
15427
15940
  function flushPendingNarrativeAtTurnEnd(turn: CurrentTurn, lastReplyText: string): void {
15428
- const pending = turn.pendingNarrative
15429
- if (pending == null) return
15430
- turn.pendingNarrative = null
15431
- if (lastReplyText.length > 0 && isDraftOfReply(pending.text, lastReplyText)) return // trailing duplicate of the answer
15432
- showNarrativeStep(turn, pending.text)
15941
+ turn.narrativeGate.flushAtTurnEnd(lastReplyText)
15433
15942
  }
15434
15943
 
15435
15944
  /**
@@ -16215,9 +16724,7 @@ function handleSessionEvent(ev: SessionEvent): void {
16215
16724
  // background workers keep the timer warm exactly as long as they are working.
16216
16725
  {
16217
16726
  const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
16218
- const signal = classifyIdleEvent(ev.kind, durationMs)
16219
- if (signal.activity) markIdleActivity()
16220
- if (signal.turnEnded) markIdleTurnEnd()
16727
+ idleTracker.noteEvent(ev.kind, Date.now(), durationMs)
16221
16728
  }
16222
16729
  switch (ev.kind) {
16223
16730
  case 'enqueue': {
@@ -16263,6 +16770,13 @@ function handleSessionEvent(ev: SessionEvent): void {
16263
16770
  clearTimeout(prior.orphanedReplyTimeoutId)
16264
16771
  prior.orphanedReplyTimeoutId = null
16265
16772
  }
16773
+ // Same bounded-leak class (early-paint 250ms setTimeout): the prior
16774
+ // turn may have armed its narrative gate's early-paint timer before
16775
+ // being superseded. Left untorn, ~250ms later it fires showNarrativeStep
16776
+ // on the dead turn and can paint a stale narration card below the new
16777
+ // turn's surface. Teardown is guard-safe and idempotent (no-op when never
16778
+ // armed / already fired / already disarmed by the prior turn's turn_end).
16779
+ prior?.narrativeGate?.teardown()
16266
16780
  // #1067: swap the entire turn atom in one assignment. Every
16267
16781
  // handler captures `const turn = currentTurn` at entry, so a
16268
16782
  // captured-then-awaited read can't reattribute to the new turn.
@@ -16322,6 +16836,7 @@ function handleSessionEvent(ev: SessionEvent): void {
16322
16836
  silentAnchorText: '',
16323
16837
  capturedText: [],
16324
16838
  orphanedReplyTimeoutId: null,
16839
+ answerReadyFlushTimeoutId: null,
16325
16840
  // Fresh liveness tracker: lastStreamEventAt seeded to the turn start
16326
16841
  // so a turn that never streams still trips the fuse after windowMs.
16327
16842
  liveness: new LivenessTracker(startedAt),
@@ -16339,7 +16854,10 @@ function handleSessionEvent(ev: SessionEvent): void {
16339
16854
  activityEverOpened: false,
16340
16855
  activityDrainFailures: 0,
16341
16856
  mirrorLines: [],
16342
- pendingNarrative: null,
16857
+ // Assigned immediately after this literal via makeNarrativeGate(next) —
16858
+ // the controller's SHOW/RETRACT effects close over the turn object, which
16859
+ // can't reference itself inside its own initializer.
16860
+ narrativeGate: undefined as unknown as NarrativeFlushController,
16343
16861
  lastReplyText: '',
16344
16862
  foregroundSubAgents: new Map(),
16345
16863
  answerStream: null,
@@ -16352,6 +16870,9 @@ function handleSessionEvent(ev: SessionEvent): void {
16352
16870
  statusKey(ev.chatId, enqThreadIdNum),
16353
16871
  ),
16354
16872
  }
16873
+ // Wire the per-turn narrative gate now that `next` exists (its SHOW/RETRACT
16874
+ // effects close over the turn). Born with this turn, torn down at turn end.
16875
+ next.narrativeGate = makeNarrativeGate(next)
16355
16876
  // PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
16356
16877
  // the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
16357
16878
  // per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
@@ -16520,6 +17041,11 @@ function handleSessionEvent(ev: SessionEvent): void {
16520
17041
  case 'tool_use': {
16521
17042
  const turn = currentTurn
16522
17043
  if (turn == null) return
17044
+ // PR A — the model resumed work (surface or otherwise). Cancel any pending
17045
+ // answer-ready quiescence flush: the turn is no longer quiescent. (Fire-time
17046
+ // re-verification would also catch this, but disarming here avoids a wasted
17047
+ // wakeup and matches the design's disarm-on-tool requirement.)
17048
+ clearAnswerReadyFlushTimeout(turn)
16523
17049
  // Narrative-dedup gate step 2 (JSONL-text-narrative primitive): a
16524
17050
  // narrative block was pending; this tool_use is the lookahead event
16525
17051
  // that decides it. reply/stream_reply with near-identical text ⇒
@@ -16606,6 +17132,11 @@ function handleSessionEvent(ev: SessionEvent): void {
16606
17132
  // where the JSONL tool_use rows arrive too late.
16607
17133
  const turn = currentTurn
16608
17134
  if (turn == null) return
17135
+ // PR A — a tool_label (real-time, ~250 ms) means the model is producing
17136
+ // work right now: cancel any pending answer-ready quiescence flush (the
17137
+ // turn is not quiescent). Fires ahead of the JSONL tool_use, so it disarms
17138
+ // the timer at the earliest deterministic point.
17139
+ clearAnswerReadyFlushTimeout(turn)
16609
17140
  // SECONDARY FIX: an active tool_label means the model is producing work
16610
17141
  // right now — re-arm the orphaned-reply fuse so a multi-phase tool turn
16611
17142
  // (write → compile → test → fix) that regularly emits labels doesn't let
@@ -16928,6 +17459,11 @@ function handleSessionEvent(ev: SessionEvent): void {
16928
17459
  preambleSuppressor.onText(ev.text)
16929
17460
  }
16930
17461
  resetOrphanedReplyTimeout()
17462
+ // PR A — (re)arm the deterministic answer-ready quiescence flush. Each
17463
+ // text chunk debounces the timer; it fires only after ~1 s of no new
17464
+ // stream events, delivering a composed toolless answer without waiting on
17465
+ // the unreliable turn_duration signal or the ~150 s orphaned backstop.
17466
+ resetAnswerReadyFlushTimeout()
16931
17467
 
16932
17468
  if (isContextExhaustionText(ev.text) && turn != null) {
16933
17469
  const chatId = turn.sessionChatId
@@ -17017,7 +17553,14 @@ function handleSessionEvent(ev: SessionEvent): void {
17017
17553
  // check to the full isLegitimatelyWorking predicate so detached background
17018
17554
  // work and human-wait tools (ask_user) are also protected.
17019
17555
  // INVARIANT: a REAL turn_end (durationMs >= 0) is NEVER suppressed.
17020
- if (ev.durationMs === -1) {
17556
+ // PR A carve-out: the answer-ready quiescence flush also uses
17557
+ // `durationMs:-1`, but it is a POSITIVE "streaming has settled" signal
17558
+ // fired only after ~1 s of no stream events AND no in-flight tool — the
17559
+ // exact opposite of a hung turn. It must NOT be suppressed by
17560
+ // recentlyStreaming (the terminal answer text itself stamps that window,
17561
+ // which is the whole bug). Its own arm/fire predicate already re-verified
17562
+ // quiescence, so let it through to deliver.
17563
+ if (ev.durationMs === -1 && ev.reason !== 'answer-ready-quiescence') {
17021
17564
  const turn = currentTurn
17022
17565
  const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
17023
17566
  // Widened to also suppress while the turn is RECENTLY STREAMING — a
@@ -17085,8 +17628,8 @@ function handleSessionEvent(ev: SessionEvent): void {
17085
17628
  // delivered reply text and SUPPRESS the duplicate; otherwise SHOW
17086
17629
  // genuine trailing narration ("Done — all green."). Must run BEFORE
17087
17630
  // clearActivitySummary so a SHOWN line lands in the feed's final
17088
- // render. Always clears turn.pendingNarrative so it can't leak across
17089
- // turns.
17631
+ // render. Always clears the gate's parked block (and disarms its
17632
+ // early-paint timer) so nothing can leak across turns.
17090
17633
  //
17091
17634
  // NIT 2 (reply-proxy precision): use `turn.lastReplyText` (the
17092
17635
  // most-recent reply/stream_reply input.text) rather than
@@ -17437,9 +17980,9 @@ function handleSessionEvent(ev: SessionEvent): void {
17437
17980
  // breaks into GFM hard breaks so the Bot API 10.1 rich path doesn't
17438
17981
  // collapse them (lists/tables/code left untouched). Runs BEFORE the
17439
17982
  // redact/scrub below, exactly as reply orders it (repair → normalize →
17440
- // redact → scrub), so masking sees the repaired text. The matching
17441
- // addParagraphSpacers pass runs on the send side just before
17442
- // splitMarkdownChunks (see below).
17983
+ // redact → scrub), so masking sees the repaired text. Paragraph gaps
17984
+ // are the plain `\n\n` normalizeParagraphBreaks guarantees no spacer
17985
+ // pass runs on the send side any more (removed in the #2669 follow-up).
17443
17986
  capturedText = normalizeParagraphBreaks(repairEscapedWhitespace(capturedText))
17444
17987
  // Component 3 — origin-thread backstop. `chatId`/`threadId` are
17445
17988
  // captured from the turn atom (turn.sessionChatId/sessionThreadId)
@@ -17499,6 +18042,17 @@ function handleSessionEvent(ev: SessionEvent): void {
17499
18042
  // that this branch never reaches, this set is belt-and-braces —
17500
18043
  // it keeps the captured `turn` atom internally consistent for any
17501
18044
  // future reader.)
18045
+ // PR B (Fix 4 — intentional record/ledger inconsistency, out of scope).
18046
+ // We keep setting finalAnswerDelivered=true HERE (before the async send)
18047
+ // so endCurrentTurnAtomic's obligation CLOSE (decideObligationTurnEnd,
18048
+ // ~4818) fires unchanged at turn end. PR B only makes the turns.jsonl
18049
+ // *record* honest (status send_failed when the send later throws) — it
18050
+ // deliberately does NOT change obligation behavior. Consequence: a
18051
+ // send_failed turn still CLOSES its obligation, so a flood-dropped
18052
+ // answer is NOT re-presented — an honest record without honest recovery.
18053
+ // Re-delivery on send_failed (drive obligation close from real send
18054
+ // success, leave it open on failure) is a separate change — see the
18055
+ // PR-B handback FOLLOW-UP note; do NOT touch the ledger in this PR.
17502
18056
  turn.finalAnswerDelivered = true
17503
18057
  // Feed-reopen refinement: turn-flush delivers the model's terminal
17504
18058
  // transcript text as the genuine answer (not an ack). Default to
@@ -17529,7 +18083,15 @@ function handleSessionEvent(ev: SessionEvent): void {
17529
18083
  // sendMessage await for this turn will see currentTurn == null
17530
18084
  // and bail; a new enqueue will swap in a fresh atom. The
17531
18085
  // `backstop*` locals above hold everything the IIFE needs.
17532
- endCurrentTurnAtomic(turn)
18086
+ //
18087
+ // PR B — defer the turns.jsonl record write to the send IIFE below so
18088
+ // the recorded `status` reflects the REAL send outcome, not the
18089
+ // speculative `finalAnswerDelivered=true` set just above. Everything
18090
+ // else in endCurrentTurnAtomic (atom null, gate release, obligation
18091
+ // bookkeeping, purge) still runs synchronously here for the #1067 /
18092
+ // #1556 wedge-safety reasons. `backstopTurnEndedAt` is null iff the
18093
+ // atom was already torn down elsewhere (no record to emit).
18094
+ const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true })
17533
18095
  // #549 fix — turn-flush takes ownership of the captured-text
17534
18096
  // backup; reset the preamble buffer (its content is already in
17535
18097
  // the captured `capturedText`, which turn-flush is about to send).
@@ -17569,6 +18131,15 @@ function handleSessionEvent(ev: SessionEvent): void {
17569
18131
  // async IIFE ran). The old redundant purgeReactionTracking
17570
18132
  // here re-fired on an already-cleared key WITHOUT `endingTurn`,
17571
18133
  // emitting an inconsistent shadow trace. Removed.
18134
+ //
18135
+ // PR B — the reply tool already delivered this turn's answer, so
18136
+ // the flush was legitimately suppressed. Emit the deferred record
18137
+ // as 'suppressed' (→ `complete`, since the reply path set
18138
+ // finalAnswerDelivered). Not a failure.
18139
+ if (backstopTurnEndedAt != null) {
18140
+ turn.deliveryOutcome = 'suppressed'
18141
+ emitTurnRecord(turn, backstopTurnEndedAt)
18142
+ }
17572
18143
  return
17573
18144
  }
17574
18145
  } catch {}
@@ -17583,16 +18154,26 @@ function handleSessionEvent(ev: SessionEvent): void {
17583
18154
  link_preview_options: { is_disabled: true },
17584
18155
  }
17585
18156
  const limit = RICH_MESSAGE_MAX_CHARS
17586
- // #2798 / #2692inject visible blank-line spacers into prose `\n\n`
17587
- // gaps before splitting, exactly as executeReply does. The rich GFM
17588
- // renderer collapses a bare `\n\n` gap TIGHT, so without this the
17589
- // paragraph boundaries from the '\n\n' block join (turn-flush-safety
17590
- // .ts) would still render jammed together. Mirrors reply's
17591
- // `addParagraphSpacers(text)` on the non-literal path.
17592
- const renderedText = addParagraphSpacers(capturedText)
17593
- const htmlChunks = splitMarkdownChunks(renderedText, limit)
18157
+ // PR B (Fix 1) send accounting is declared OUTSIDE the try so the
18158
+ // single-record `finally` below can read it, and ALL setup (the chunk
18159
+ // split) is pulled INSIDE the try so a throw there still lands in the
18160
+ // finally (→ one honest `send_failed` row) rather than rejecting the
18161
+ // IIFE with zero turns.jsonl rows written / an unhandled rejection.
18162
+ let htmlChunks: string[] = []
17594
18163
  const sentIds: number[] = []
18164
+ // track whether the send threw so the deferred record reflects the
18165
+ // real outcome (throw OR partial multi-chunk → send_failed).
18166
+ let sendThrew = false
17595
18167
  try {
18168
+ // #2798 / #2692 — inject visible blank-line spacers into prose `\n\n`
18169
+ // gaps before splitting, exactly as executeReply does. The rich GFM
18170
+ // renderer collapses a bare `\n\n` gap TIGHT, so without this the
18171
+ // paragraph boundaries from the '\n\n' block join (turn-flush-safety
18172
+ // .ts) would still render jammed together. Mirrors reply's
18173
+ // `addParagraphSpacers(text)` on the non-literal path (idempotent —
18174
+ // exactly one U+00A0 spacer per gap, never doubled).
18175
+ const renderedText = addParagraphSpacers(capturedText)
18176
+ htmlChunks = splitMarkdownChunks(renderedText, limit)
17596
18177
  // #654 deterministic double-message fix. If the progress
17597
18178
  // card is on screen (60s timer fired before turn_end), edit
17598
18179
  // it in place with the first chunk of the answer instead of
@@ -17700,10 +18281,33 @@ function handleSessionEvent(ev: SessionEvent): void {
17700
18281
  unpinProgressCardForChat?.(backstopChatId, backstopThreadId)
17701
18282
  }
17702
18283
  } catch (err) {
18284
+ sendThrew = true
17703
18285
  process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
17704
18286
  // #1713: backstop send failed — finalize as error so the
17705
18287
  // turn ends cleanly with 😱 rather than leaving it open.
17706
18288
  if (backstopCtrl) backstopCtrl.finalize('error')
18289
+ } finally {
18290
+ // PR B (Fix 1) — SINGLE-RECORD GUARANTEE. The send has now RESOLVED
18291
+ // (or thrown); this finally runs on EVERY in-process exit of the send
18292
+ // block — clean send, throw in setup/split, throw mid-send — so
18293
+ // exactly one turns.jsonl row is written, with the outcome reflecting
18294
+ // what actually happened. A throw or a partial multi-chunk delivery
18295
+ // (sentIds < htmlChunks) or an un-run/empty split → 'failed' → status
18296
+ // `send_failed`; a full delivery → 'delivered' → `complete`. This
18297
+ // replaces the false `complete` the old synchronous write produced
18298
+ // for a flood-dropped / errored answer the user never received, and
18299
+ // restores the old code's one-row-per-turn guarantee (the pre-finally
18300
+ // shape wrote ZERO rows if setup threw). The suppressed-reply branch
18301
+ // above already emitted its record and `return`ed BEFORE reaching
18302
+ // this try, so it can never double-write with this finally.
18303
+ if (backstopTurnEndedAt != null) {
18304
+ finalizeBackstopSend(turn, {
18305
+ threw: sendThrew,
18306
+ sentCount: sentIds.length,
18307
+ chunkCount: htmlChunks.length,
18308
+ })
18309
+ emitTurnRecord(turn, backstopTurnEndedAt)
18310
+ }
17707
18311
  }
17708
18312
  // #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
17709
18313
  // was removed. endCurrentTurnAtomic already ran the canonical purge
@@ -17803,6 +18407,65 @@ function handleSessionEvent(ev: SessionEvent): void {
17803
18407
  // is exactly `turn.finalAnswerDelivered === false` here (silent-marker
17804
18408
  // and flush both returned earlier), delegated to the pure gate core.
17805
18409
  if (turnEndDecision === 'reprompt') {
18410
+ // Option A transcript-prose bridge (#3227). Before falling through to
18411
+ // the re-prompt / represent safety nets, check whether the Stop hook
18412
+ // already isolated this turn's real answer from the transcript and
18413
+ // persisted it in silent-end-pending.json (`pendingText`). That file
18414
+ // lands BEFORE this turn_end handler runs (the hook fires upstream of
18415
+ // the gateway's own state write — see silent-end-interrupt-stop.mjs).
18416
+ // If a substantive answer is waiting, deliver it directly via the
18417
+ // normal send path NOW, instead of leaning on the (unreliable)
18418
+ // Stop-hook re-prompt or waiting ~2-5 min for the obligation
18419
+ // represent. The delivery closes the obligation + records dedup, so
18420
+ // the represent and a late reply-tool retry are both suppressed —
18421
+ // captured-prose delivery and represent are mutually exclusive.
18422
+ const proseDecision = CAPTURED_PROSE_DELIVERY_ENABLED
18423
+ ? decideCapturedProseDelivery({
18424
+ turnKey: tKey,
18425
+ // Per-turn nonce (#3228 Finding 3) — the persisted record must
18426
+ // belong to THIS turn, not a stale carryover from a prior turn
18427
+ // on the same chat/thread (tKey is not per-turn unique).
18428
+ turnId: turn.turnId,
18429
+ minChars: CAPTURED_PROSE_MIN_CHARS,
18430
+ })
18431
+ : { deliver: false as const, reason: 'no-state' as const }
18432
+ if (proseDecision.deliver && proseDecision.text != null) {
18433
+ // Deliver the recovered answer directly. This runs async and owns
18434
+ // its own bookkeeping — on success it closes the obligation +
18435
+ // records dedup + clears the silent-end state; on send FAILURE it
18436
+ // arms the recovery net itself (see below) instead of leaving the
18437
+ // answer lost.
18438
+ //
18439
+ // We DELIBERATELY skip recordUndeliveredTurnEnd on the HAPPY path
18440
+ // here: re-arming the Stop-hook re-prompt for an answer that just
18441
+ // went out is exactly what this bridge exists to avoid.
18442
+ //
18443
+ // #3228 Finding 1 — the send-failure net can NOT rely on the shared
18444
+ // turn-end teardown "leaving the obligation open". That teardown
18445
+ // (endCurrentTurnAtomic → decideObligationTurnEnd) closes the
18446
+ // obligation whenever `replyCalled === true`, which is EXACTLY the
18447
+ // interim-ack case that reaches this branch. So a thrown send would
18448
+ // otherwise leave the answer permanently lost (obligation already
18449
+ // closed by teardown, recordUndeliveredTurnEnd skipped). The real
18450
+ // net lives INSIDE deliverCapturedProse's catch: it calls
18451
+ // recordUndeliveredTurnEnd, arming the deterministic Stop-hook
18452
+ // re-prompt (parity with the non-captured path below).
18453
+ process.stderr.write(
18454
+ `telegram gateway: captured-prose delivery engaged on first silent-end ` +
18455
+ `chat=${chatId} turnKey=${tKey} (#3227)\n`,
18456
+ )
18457
+ void deliverCapturedProse({
18458
+ chatId,
18459
+ threadId,
18460
+ statusKeyStr: tKey,
18461
+ registryKey: turn.registryKey ?? null,
18462
+ originTurnId: turn.turnId,
18463
+ text: proseDecision.text,
18464
+ // For the honest "(waited Ns)" clause if the exhaustion-boundary
18465
+ // apology fallback fires (#3228).
18466
+ turnDurationMs,
18467
+ })
18468
+ } else {
17806
18469
  // PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
17807
18470
  // wire the represent-guard-style staleness
17808
18471
  // check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
@@ -17848,6 +18511,7 @@ function handleSessionEvent(ev: SessionEvent): void {
17848
18511
  )
17849
18512
  })
17850
18513
  }
18514
+ } // end else (no captured-prose to deliver)
17851
18515
  }
17852
18516
  signalTracker.clear(tKey)
17853
18517
  silencePoke.endTurn(tKey)
@@ -29422,6 +30086,18 @@ void (async () => {
29422
30086
  // channels.telegram.worker_feed.max_rows via the config cascade
29423
30087
  // (scaffold emits SWITCHROOM_TG_WORKER_FEED_MAX_ROWS); unset → 8.
29424
30088
  maxRows: workerFeedMaxRows,
30089
+ // Backstop TTL for the feed's stale-row reaper, DERIVED in code
30090
+ // from the watcher's effective in-flight terminal cap (same env /
30091
+ // default the watcher itself resolves) plus a margin — NOT a
30092
+ // hardcoded assumption about that cap's value. This keeps the
30093
+ // invariant "the feed must never reap a row the watcher still
30094
+ // considers live" enforced even if an operator raises the cap via
30095
+ // SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS: a worker mid-very-
30096
+ // long tool can go silent up to the cap before the watcher
30097
+ // declares it terminal, so the feed waits cap + margin before
30098
+ // force-collapsing a row the terminal signals somehow never
30099
+ // removed.
30100
+ staleWorkerTtlMs: resolveInflightTerminalCapMs() + WORKER_FEED_STALE_TTL_MARGIN_MS,
29425
30101
  // #3207 review: GROUP-level status pin. Workers now coalesce into
29426
30102
  // ONE shared message, so the pin must follow the GROUP lifecycle,
29427
30103
  // not a single worker's — otherwise a sibling's finish unpins a
@@ -29587,6 +30263,23 @@ void (async () => {
29587
30263
  `telegram gateway: worker ${agentId} NAMED AS LOST — falsely finalised twice, resurrection chain bound reached (issue #3023)\n`,
29588
30264
  )
29589
30265
  },
30266
+ // Worker-feed ghost-leak fix: the watcher's AUTHORITATIVE terminal
30267
+ // sweep (`cleanupTerminalAgent`) fires for EVERY terminal agent,
30268
+ // including the JSONL-vanished and boot-orphan paths that never
30269
+ // reach `onFinish`. Wire feed removal here so cleanup and feed-
30270
+ // remove can't diverge: `terminate` is idempotent (a no-op once
30271
+ // `onFinish` already removed the row) and, when this was the last
30272
+ // live worker, collapses the shared card to its terminal summary
30273
+ // and unpins it — closing the immortal/unpinned/buried-card leak.
30274
+ onTerminalCleanup: (agentId) => {
30275
+ try {
30276
+ void workerActivityFeed?.terminate(agentId)
30277
+ } catch (err) {
30278
+ process.stderr.write(
30279
+ `telegram gateway: worker terminal-cleanup feed removal error agent=${agentId}: ${(err as Error).message}\n`,
30280
+ )
30281
+ }
30282
+ },
29590
30283
  onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
29591
30284
  // Reaction promotion: if the parent turn already ended
29592
30285
  // with this (or another) worker still running, its 👍 was