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
@@ -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'
@@ -348,7 +349,7 @@ const REPLY_TO_TEXT_MAX = 200
348
349
  // #1161 silent-end fallback text now lives in ../silent-end.ts
349
350
  // (`silentEndFallbackText`, imported above) so the transport-boundary
350
351
  // tests exercise the real string — see PR #2892.
351
- import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
352
+ import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
352
353
  import { richMessage } from '../rich-send.js'
353
354
  import { scrubVoice } from '../text-voice-scrub.js'
354
355
  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
  /**
@@ -8125,6 +8223,10 @@ const STREAM_THROTTLE_MS_OVERRIDE: number | undefined = (() => {
8125
8223
  })()
8126
8224
  const TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled()
8127
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
+
8128
8230
  // When SET, the answer-lane stream (telegram-plugin/answer-stream.ts) renders
8129
8231
  // the model's transcript text as a USER-VISIBLE edit-in-place message. Default
8130
8232
  // OFF: the lane stays dormant and the reply tool is the single canonical
@@ -9177,6 +9279,17 @@ const SILENCE_FLOOR_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FLOOR_MS', 45_00
9177
9279
  // #2527 — role-aware terminal reaction honesty (the "thumbs-up false done"
9178
9280
  // fix). Default ON; SWITCHROOM_TG_TERMINAL_HONESTY=0 reverts to always-👍.
9179
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'
9180
9293
  // SILENCE_DEFER_INFLIGHT_TOOLS: previously an opt-in (=1). The new
9181
9294
  // isLegitimatelyWorking callback supersedes this — defer is now the DEFAULT
9182
9295
  // when the callback is wired. The legacy flag is kept so `=0` still lets
@@ -9989,6 +10102,250 @@ function agentHasInFlightBackgroundWork(now: number): boolean {
9989
10102
  // Throttle for the background-work defer diagnostic (the 5s sweep would otherwise
9990
10103
  // log every tick across a multi-minute research window).
9991
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
+
9992
10349
  function obligationSweep(): void {
9993
10350
  if (!OBLIGATION_LEDGER_ENABLED) return
9994
10351
  if (!obligationLedger.hasOpen()) return
@@ -11460,10 +11817,18 @@ const ipcServer: IpcServer = createIpcServer({
11460
11817
  },
11461
11818
 
11462
11819
  onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
11463
- // Cron fires (incl. cheap-cron, whose session events are dropped before
11464
- // currentTurn is set) are real activity re-arm idle auto-clear so a
11465
- // working scheduled agent isn't wiped after 3h of "no inbound".
11466
- 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()
11467
11832
  const promptKey = typeof msg.inbound.meta?.prompt_key === 'string'
11468
11833
  ? msg.inbound.meta.prompt_key
11469
11834
  : 'unknown'
@@ -15021,11 +15386,10 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
15021
15386
  // secret into a live bubble or the history row. Mask before scrub/send.
15022
15387
  editRawText = redactOutboundText(editRawText, 'edit_message')
15023
15388
  // Fleet-wide consistent formatting (same order as the reply path: redact
15024
- // first so secrets are matched literally, then normalize). No paragraph
15025
- // spacer pass — the NBSP spacer was removed in the #2669 follow-up because it
15026
- // double-gapped every paragraph; the rich renderer already shows `\n\n` as
15027
- // one blank line.
15028
- if (!editLiteralText) editRawText = stripExcessBold(normalizePunctuation(editRawText))
15389
+ // first so secrets are matched literally, then normalize, then spacers on
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).
15392
+ if (!editLiteralText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
15029
15393
  // Voice scrub (#1683): same em-dash scrub as the reply path. Edits
15030
15394
  // are how silent-anchor and progress-update mutate already-sent
15031
15395
  // bubbles, so without this an edit can re-introduce dashes the
@@ -15313,6 +15677,68 @@ function resetOrphanedReplyTimeout(): void {
15313
15677
  }
15314
15678
  }
15315
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
+
15316
15742
  function closeActivityLane(chatId: string, threadId: number | undefined): void {
15317
15743
  const key = chatKeyWithSuffix(chatId, threadId, 'activity')
15318
15744
  const stream = activeDraftStreams.get(key)
@@ -15373,6 +15799,75 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
15373
15799
  return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
15374
15800
  }
15375
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
+
15376
15871
  /**
15377
15872
  * Render a SHOWN narrative text block as a transient liveness step — the
15378
15873
  * same path a tool label takes (appendActivityLabel → renderStepFeed), so
@@ -15418,27 +15913,20 @@ function resolvePendingNarrativeOnTool(
15418
15913
  toolName: string,
15419
15914
  input: Record<string, unknown> | undefined,
15420
15915
  ): void {
15421
- const pending = turn.pendingNarrative
15422
- if (pending == null) return
15423
- turn.pendingNarrative = null
15424
- if (REPLY_TOOLS.has(toolName)) {
15425
- const replyText = typeof input?.text === 'string' ? (input.text as string) : ''
15426
- if (isDraftOfReply(pending.text, replyText)) return // draft of the answer → SUPPRESS
15427
- }
15428
- 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)
15429
15920
  }
15430
15921
 
15431
15922
  /**
15432
15923
  * Narrative-dedup gate, step 1 (reducer-side): a new narrative block
15433
15924
  * arrived. A previously-pending block had nothing reply-shaped immediately
15434
15925
  * after it (pure narration) → flush it as SHOWN, then stage the new one for
15435
- * one lookahead step. See narrative-dedup.ts §2b.
15926
+ * one lookahead step AND arm the time-boxed early paint. See narrative-flush.ts.
15436
15927
  */
15437
15928
  function stagePendingNarrative(turn: CurrentTurn, text: string): void {
15438
- if (turn.pendingNarrative != null) {
15439
- showNarrativeStep(turn, turn.pendingNarrative.text)
15440
- }
15441
- turn.pendingNarrative = { text }
15929
+ turn.narrativeGate.stage(text)
15442
15930
  }
15443
15931
 
15444
15932
  /**
@@ -15446,14 +15934,11 @@ function stagePendingNarrative(turn: CurrentTurn, text: string): void {
15446
15934
  * trailing narrative block and nothing after it. SUPPRESS only when the turn
15447
15935
  * already delivered its answer via reply/stream_reply and the trailing text
15448
15936
  * is a draft of that answer; otherwise SHOW (genuine trailing narration like
15449
- * "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.
15450
15939
  */
15451
15940
  function flushPendingNarrativeAtTurnEnd(turn: CurrentTurn, lastReplyText: string): void {
15452
- const pending = turn.pendingNarrative
15453
- if (pending == null) return
15454
- turn.pendingNarrative = null
15455
- if (lastReplyText.length > 0 && isDraftOfReply(pending.text, lastReplyText)) return // trailing duplicate of the answer
15456
- showNarrativeStep(turn, pending.text)
15941
+ turn.narrativeGate.flushAtTurnEnd(lastReplyText)
15457
15942
  }
15458
15943
 
15459
15944
  /**
@@ -16239,9 +16724,7 @@ function handleSessionEvent(ev: SessionEvent): void {
16239
16724
  // background workers keep the timer warm exactly as long as they are working.
16240
16725
  {
16241
16726
  const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
16242
- const signal = classifyIdleEvent(ev.kind, durationMs)
16243
- if (signal.activity) markIdleActivity()
16244
- if (signal.turnEnded) markIdleTurnEnd()
16727
+ idleTracker.noteEvent(ev.kind, Date.now(), durationMs)
16245
16728
  }
16246
16729
  switch (ev.kind) {
16247
16730
  case 'enqueue': {
@@ -16287,6 +16770,13 @@ function handleSessionEvent(ev: SessionEvent): void {
16287
16770
  clearTimeout(prior.orphanedReplyTimeoutId)
16288
16771
  prior.orphanedReplyTimeoutId = null
16289
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()
16290
16780
  // #1067: swap the entire turn atom in one assignment. Every
16291
16781
  // handler captures `const turn = currentTurn` at entry, so a
16292
16782
  // captured-then-awaited read can't reattribute to the new turn.
@@ -16346,6 +16836,7 @@ function handleSessionEvent(ev: SessionEvent): void {
16346
16836
  silentAnchorText: '',
16347
16837
  capturedText: [],
16348
16838
  orphanedReplyTimeoutId: null,
16839
+ answerReadyFlushTimeoutId: null,
16349
16840
  // Fresh liveness tracker: lastStreamEventAt seeded to the turn start
16350
16841
  // so a turn that never streams still trips the fuse after windowMs.
16351
16842
  liveness: new LivenessTracker(startedAt),
@@ -16363,7 +16854,10 @@ function handleSessionEvent(ev: SessionEvent): void {
16363
16854
  activityEverOpened: false,
16364
16855
  activityDrainFailures: 0,
16365
16856
  mirrorLines: [],
16366
- 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,
16367
16861
  lastReplyText: '',
16368
16862
  foregroundSubAgents: new Map(),
16369
16863
  answerStream: null,
@@ -16376,6 +16870,9 @@ function handleSessionEvent(ev: SessionEvent): void {
16376
16870
  statusKey(ev.chatId, enqThreadIdNum),
16377
16871
  ),
16378
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)
16379
16876
  // PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
16380
16877
  // the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
16381
16878
  // per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
@@ -16544,6 +17041,11 @@ function handleSessionEvent(ev: SessionEvent): void {
16544
17041
  case 'tool_use': {
16545
17042
  const turn = currentTurn
16546
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)
16547
17049
  // Narrative-dedup gate step 2 (JSONL-text-narrative primitive): a
16548
17050
  // narrative block was pending; this tool_use is the lookahead event
16549
17051
  // that decides it. reply/stream_reply with near-identical text ⇒
@@ -16630,6 +17132,11 @@ function handleSessionEvent(ev: SessionEvent): void {
16630
17132
  // where the JSONL tool_use rows arrive too late.
16631
17133
  const turn = currentTurn
16632
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)
16633
17140
  // SECONDARY FIX: an active tool_label means the model is producing work
16634
17141
  // right now — re-arm the orphaned-reply fuse so a multi-phase tool turn
16635
17142
  // (write → compile → test → fix) that regularly emits labels doesn't let
@@ -16952,6 +17459,11 @@ function handleSessionEvent(ev: SessionEvent): void {
16952
17459
  preambleSuppressor.onText(ev.text)
16953
17460
  }
16954
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()
16955
17467
 
16956
17468
  if (isContextExhaustionText(ev.text) && turn != null) {
16957
17469
  const chatId = turn.sessionChatId
@@ -17041,7 +17553,14 @@ function handleSessionEvent(ev: SessionEvent): void {
17041
17553
  // check to the full isLegitimatelyWorking predicate so detached background
17042
17554
  // work and human-wait tools (ask_user) are also protected.
17043
17555
  // INVARIANT: a REAL turn_end (durationMs >= 0) is NEVER suppressed.
17044
- 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') {
17045
17564
  const turn = currentTurn
17046
17565
  const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
17047
17566
  // Widened to also suppress while the turn is RECENTLY STREAMING — a
@@ -17109,8 +17628,8 @@ function handleSessionEvent(ev: SessionEvent): void {
17109
17628
  // delivered reply text and SUPPRESS the duplicate; otherwise SHOW
17110
17629
  // genuine trailing narration ("Done — all green."). Must run BEFORE
17111
17630
  // clearActivitySummary so a SHOWN line lands in the feed's final
17112
- // render. Always clears turn.pendingNarrative so it can't leak across
17113
- // turns.
17631
+ // render. Always clears the gate's parked block (and disarms its
17632
+ // early-paint timer) so nothing can leak across turns.
17114
17633
  //
17115
17634
  // NIT 2 (reply-proxy precision): use `turn.lastReplyText` (the
17116
17635
  // most-recent reply/stream_reply input.text) rather than
@@ -17523,6 +18042,17 @@ function handleSessionEvent(ev: SessionEvent): void {
17523
18042
  // that this branch never reaches, this set is belt-and-braces —
17524
18043
  // it keeps the captured `turn` atom internally consistent for any
17525
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.
17526
18056
  turn.finalAnswerDelivered = true
17527
18057
  // Feed-reopen refinement: turn-flush delivers the model's terminal
17528
18058
  // transcript text as the genuine answer (not an ack). Default to
@@ -17553,7 +18083,15 @@ function handleSessionEvent(ev: SessionEvent): void {
17553
18083
  // sendMessage await for this turn will see currentTurn == null
17554
18084
  // and bail; a new enqueue will swap in a fresh atom. The
17555
18085
  // `backstop*` locals above hold everything the IIFE needs.
17556
- 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 })
17557
18095
  // #549 fix — turn-flush takes ownership of the captured-text
17558
18096
  // backup; reset the preamble buffer (its content is already in
17559
18097
  // the captured `capturedText`, which turn-flush is about to send).
@@ -17593,6 +18131,15 @@ function handleSessionEvent(ev: SessionEvent): void {
17593
18131
  // async IIFE ran). The old redundant purgeReactionTracking
17594
18132
  // here re-fired on an already-cleared key WITHOUT `endingTurn`,
17595
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
+ }
17596
18143
  return
17597
18144
  }
17598
18145
  } catch {}
@@ -17607,15 +18154,26 @@ function handleSessionEvent(ev: SessionEvent): void {
17607
18154
  link_preview_options: { is_disabled: true },
17608
18155
  }
17609
18156
  const limit = RICH_MESSAGE_MAX_CHARS
17610
- // The `\n\n` block joins from turn-flush-safety.ts render as normal
17611
- // single blank lines under the Bot API 10.1 rich GFM path, so no
17612
- // spacer pass runs before splitting (the NBSP spacer was removed in
17613
- // the #2669 follow-up it double-gapped every paragraph). Mirrors
17614
- // executeReply, which now also sends the normalized text as-is.
17615
- const renderedText = capturedText
17616
- 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[] = []
17617
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
17618
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)
17619
18177
  // #654 deterministic double-message fix. If the progress
17620
18178
  // card is on screen (60s timer fired before turn_end), edit
17621
18179
  // it in place with the first chunk of the answer instead of
@@ -17723,10 +18281,33 @@ function handleSessionEvent(ev: SessionEvent): void {
17723
18281
  unpinProgressCardForChat?.(backstopChatId, backstopThreadId)
17724
18282
  }
17725
18283
  } catch (err) {
18284
+ sendThrew = true
17726
18285
  process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
17727
18286
  // #1713: backstop send failed — finalize as error so the
17728
18287
  // turn ends cleanly with 😱 rather than leaving it open.
17729
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
+ }
17730
18311
  }
17731
18312
  // #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
17732
18313
  // was removed. endCurrentTurnAtomic already ran the canonical purge
@@ -17826,6 +18407,65 @@ function handleSessionEvent(ev: SessionEvent): void {
17826
18407
  // is exactly `turn.finalAnswerDelivered === false` here (silent-marker
17827
18408
  // and flush both returned earlier), delegated to the pure gate core.
17828
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 {
17829
18469
  // PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
17830
18470
  // wire the represent-guard-style staleness
17831
18471
  // check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
@@ -17871,6 +18511,7 @@ function handleSessionEvent(ev: SessionEvent): void {
17871
18511
  )
17872
18512
  })
17873
18513
  }
18514
+ } // end else (no captured-prose to deliver)
17874
18515
  }
17875
18516
  signalTracker.clear(tKey)
17876
18517
  silencePoke.endTurn(tKey)
@@ -29445,6 +30086,18 @@ void (async () => {
29445
30086
  // channels.telegram.worker_feed.max_rows via the config cascade
29446
30087
  // (scaffold emits SWITCHROOM_TG_WORKER_FEED_MAX_ROWS); unset → 8.
29447
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,
29448
30101
  // #3207 review: GROUP-level status pin. Workers now coalesce into
29449
30102
  // ONE shared message, so the pin must follow the GROUP lifecycle,
29450
30103
  // not a single worker's — otherwise a sibling's finish unpins a
@@ -29610,6 +30263,23 @@ void (async () => {
29610
30263
  `telegram gateway: worker ${agentId} NAMED AS LOST — falsely finalised twice, resurrection chain bound reached (issue #3023)\n`,
29611
30264
  )
29612
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
+ },
29613
30283
  onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
29614
30284
  // Reaction promotion: if the parent turn already ended
29615
30285
  // with this (or another) worker still running, its 👍 was
@@ -29872,7 +30542,7 @@ void (async () => {
29872
30542
  // suppresses stale-after-restart delivery (a 4-h-old
29873
30543
  // "still working (5m)" would be a lie). Sweep on handback
29874
30544
  // lives in the `onFinish` block just above.
29875
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
30545
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model, skeleton }) => {
29876
30546
  let fleetChatId = ''
29877
30547
  try {
29878
30548
  const fleets = progressDriver?.peekAllFleets() ?? []
@@ -29962,6 +30632,15 @@ void (async () => {
29962
30632
  return
29963
30633
  }
29964
30634
  if (surface !== 'nest') return // 'skip' — orphan-status off
30635
+ // #3233: a skeleton liveness cue carries NO step content by
30636
+ // construction (empty latestSummary/progressLine) — it exists
30637
+ // ONLY to create/keep-alive the orphan worker-feed row handled
30638
+ // just above. Branch EXPLICITLY on the `skeleton` discriminator
30639
+ // rather than inferring "no content" from an empty step line:
30640
+ // a skeleton cue must never nest into the parent's live turn
30641
+ // card (there is nothing to render, and the parent's own card
30642
+ // already owns the turn). Deterministic, controls-in-code.
30643
+ if (skeleton) return
29965
30644
  const turn = currentTurn
29966
30645
  if (turn == null) return // defensive: 'nest' implies a live turn
29967
30646
  // Render regardless of `replyCalled` — a foreground Task
@@ -30107,8 +30786,17 @@ void (async () => {
30107
30786
  return
30108
30787
  }
30109
30788
 
30789
+ // #3233: with the worker feed DISABLED, the legacy bucket relay
30790
+ // below injects a synthesized "still working" inbound turn. A
30791
+ // skeleton liveness cue carries an EMPTY latestSummary, so
30792
+ // letting it reach the relay would queue a blank/contentless
30793
+ // progress card. The `skeleton` discriminator is threaded into
30794
+ // the pure decision (gate 1b → 'skeleton-liveness'), which drops
30795
+ // it deterministically (controls-in-code, unit-tested) rather
30796
+ // than an opaque inline return here.
30110
30797
  const progressOrigin = resolveSubagentOriginChat(agentId)
30111
30798
  const decision = decideSubagentProgress({
30799
+ skeleton: skeleton === true,
30112
30800
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
30113
30801
  isBackground,
30114
30802
  // Prefer the conversation the Task was dispatched from over