switchroom 0.16.47 → 0.17.0

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 (106) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +24 -8
  3. package/dist/cli/drive-write-pretool.mjs +0 -5
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +1358 -1030
  6. package/dist/cli/ui/index.html +84 -12
  7. package/dist/host-control/main.js +53 -17
  8. package/dist/vault/approvals/kernel-server.js +4 -1
  9. package/dist/vault/broker/server.js +201 -56
  10. package/package.json +3 -3
  11. package/profiles/_base/cron-session.sh.hbs +1 -1
  12. package/profiles/_base/start.sh.hbs +54 -3
  13. package/skills/switchroom-architecture/telegram.md +8 -15
  14. package/skills/switchroom-cli/SKILL.md +4 -5
  15. package/skills/telegram-test-harness/SKILL.md +1 -1
  16. package/telegram-plugin/README.md +18 -29
  17. package/telegram-plugin/bridge/bridge.ts +1 -41
  18. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  19. package/telegram-plugin/dist/bridge/bridge.js +8 -43
  20. package/telegram-plugin/dist/gateway/gateway.js +682 -773
  21. package/telegram-plugin/dist/server.js +8 -43
  22. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  23. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  24. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  25. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  26. package/telegram-plugin/gateway/gateway.ts +518 -624
  27. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  28. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  29. package/telegram-plugin/gateway/model-command.ts +51 -3
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  31. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  32. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  33. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  34. package/telegram-plugin/history.ts +5 -0
  35. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  36. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  37. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  38. package/telegram-plugin/registry/turns-schema.ts +65 -1
  39. package/telegram-plugin/session-tail.ts +26 -4
  40. package/telegram-plugin/slot-banner-driver.ts +42 -2
  41. package/telegram-plugin/status-query-telemetry.ts +100 -0
  42. package/telegram-plugin/stream-reply-handler.ts +15 -16
  43. package/telegram-plugin/subagent-watcher.ts +182 -30
  44. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  45. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  46. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  47. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  48. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  49. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  51. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  52. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  53. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  54. package/telegram-plugin/tests/model-command.test.ts +54 -1
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  56. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  57. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  58. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  59. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  60. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  61. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  62. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  63. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  64. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  65. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  66. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  67. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  68. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  69. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  70. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  72. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  73. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  74. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  75. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  76. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  77. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  78. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  79. package/telegram-plugin/tool-activity-summary.ts +19 -0
  80. package/telegram-plugin/turn-flush-safety.ts +16 -1
  81. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  82. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  84. package/telegram-plugin/worker-activity-feed.ts +75 -15
  85. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  86. package/vendor/hindsight-memory/README.md +5 -0
  87. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  88. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  89. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  90. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  91. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  92. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  93. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  94. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  95. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  96. package/vendor/hindsight-memory/settings.json +4 -0
  97. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  98. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  99. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  100. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  101. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  102. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  103. package/telegram-plugin/silent-reply.ts +0 -58
  104. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  105. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  106. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -114,7 +114,7 @@ import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
114
114
  import { createPermissionCardStore } from './permission-card-store.js'
115
115
  import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
116
116
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
117
- import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, type SessionActivityHeader } from '../tool-activity-summary.js'
117
+ import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
118
118
  import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
119
119
  import { toolLabel } from '../tool-labels.js'
120
120
  import { createTypingWrapper } from '../typing-wrap.js'
@@ -285,6 +285,7 @@ import {
285
285
  } from '../active-reactions.js'
286
286
  import { sweepActiveReactions } from '../active-reactions-sweep.js'
287
287
  import { flushOnAgentDisconnect } from './disconnect-flush.js'
288
+ import { markBusyKeyLockstep, reapOrphanBusyKeys } from './busy-key-reaper.js'
288
289
  import { PreambleSuppressor } from './preamble-suppressor.js'
289
290
  import {
290
291
  fetchFolderPage,
@@ -383,6 +384,7 @@ import {
383
384
  import { loadObligations, persistObligations } from './obligation-store.js'
384
385
  import {
385
386
  loadStatusPins,
387
+ mutateStatusPinRow,
386
388
  pinnedMessageIsOurs,
387
389
  reconcileAndPersistStatusPin,
388
390
  runStatusPinBootCleanup,
@@ -392,6 +394,7 @@ import {
392
394
  } from './status-pin-store.js'
393
395
  import { driveEscalation } from './escalation-drive.js'
394
396
  import { shouldSuppressRepresent } from './represent-guard.js'
397
+ import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
395
398
  import { createInboundSpool } from './inbound-spool.js'
396
399
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
397
400
  import { decideInboundDelivery } from './inbound-delivery-gate.js'
@@ -422,6 +425,7 @@ import {
422
425
  forgetDelivery,
423
426
  shouldTrackDelivery,
424
427
  isTrackableResumeSynthetic,
428
+ isRedeliverySuspended,
425
429
  type PendingDelivery,
426
430
  } from './inbound-delivery-confirm.js'
427
431
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
@@ -434,6 +438,7 @@ import { chatKey, chatKeyWithSuffix, chatIdOfChatKey } from './chat-key.js'
434
438
  import { shadowEmit, isMachineInTurn, isDeliveryCutoverEnabled } from './inbound-delivery-machine-shadow.js'
435
439
  import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
436
440
  import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-dispatch.js'
441
+ import { probeGateParity } from './gate-parity-probe.js'
437
442
  import { maybeFireWarmup } from './prefix-warmup.js'
438
443
  import {
439
444
  buildVaultGrantApprovedInbound,
@@ -616,20 +621,26 @@ import {
616
621
  findLatestTurnIfInterrupted,
617
622
  findRecentTurnsForChat,
618
623
  getTurnByKey,
624
+ markTurnResumed,
619
625
  } from '../registry/turns-schema.js'
620
626
  import {
621
627
  buildResumeInterruptedInbound,
622
628
  buildResumeWatchdogReportInbound,
623
629
  selectResumeBuilder,
624
630
  } from './resume-inbound-builder.js'
625
- import { applySubagentsSchema, getSubagentByJsonlId } from '../registry/subagents-schema.js'
631
+ import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
626
632
  import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
627
633
  import {
628
634
  resolveSubagentStatusSurface,
629
635
  isOrphanSubagentStatusEnabled,
630
636
  } from './subagent-status-surface.js'
631
637
  import { formatIdleFooter } from '../idle-footer.js'
632
- import { resolveCallingSubagent } from './resolve-calling-subagent.js'
638
+ import {
639
+ deriveStatusQueryTelemetry,
640
+ formatStatusQueryLine,
641
+ formatStatusQueryUxFailureLine,
642
+ type StatusQuerySurfaces,
643
+ } from '../status-query-telemetry.js'
633
644
 
634
645
  // ─── Stderr logging ───────────────────────────────────────────────────────
635
646
  // Install the line-stamper FIRST so it wraps closest to the original
@@ -1403,9 +1414,15 @@ function resolveSubagentOriginChat(
1403
1414
  ): { chatId: string; threadId?: number } | null {
1404
1415
  if (turnsDb == null) return null
1405
1416
  try {
1406
- const sub = getSubagentByJsonlId(turnsDb, agentId)
1407
- if (sub?.parent_turn_key == null) return null
1408
- const turn = getTurnByKey(turnsDb, sub.parent_turn_key)
1417
+ // Transitive walk: a NESTED (depth-2+) worker's own parent_turn_key is
1418
+ // NULL by construction (its dispatching context is another sub-agent,
1419
+ // not a gateway turn) resolveSubagentOriginTurnKey follows the
1420
+ // parent_agent_id chain to the ancestor row that WAS stamped at
1421
+ // main-turn dispatch time, so nested cards + handbacks route to the
1422
+ // originating chat/topic instead of falling back to the owner DM.
1423
+ const originKey = resolveSubagentOriginTurnKey(turnsDb, agentId)
1424
+ if (originKey == null) return null
1425
+ const turn = getTurnByKey(turnsDb, originKey)
1409
1426
  if (turn == null || turn.chat_id.length === 0) return null
1410
1427
  const threadNum =
1411
1428
  turn.thread_id != null && turn.thread_id.length > 0
@@ -1536,8 +1553,8 @@ const deferredDoneReactions = new DeferredDoneReactions<StatusReactionController
1536
1553
  purge: (key) => purgeReactionTracking(key),
1537
1554
  })
1538
1555
 
1539
- // #546 — outbound content-dedup window. PR #599 introduced the four read
1540
- // sites (`outboundDedup.check` / `.record` in executeReply, executeStreamReply,
1556
+ // #546 — outbound content-dedup window. PR #599 introduced the read
1557
+ // sites (`outboundDedup.check` / `.record` in executeReply and
1541
1558
  // turn-flush) but the declaration was lost in a merge somewhere — every reply
1542
1559
  // path threw `outboundDedup is not defined` at runtime, blocking ALL outbound
1543
1560
  // from the agent. Restore the module-level singleton here.
@@ -1577,6 +1594,18 @@ const activeTurnStartedAt = new Map<string, number>()
1577
1594
  // reading activeTurnStartedAt because they want the receipt timestamp.
1578
1595
  const claudeBusyKeys = new Set<string>()
1579
1596
 
1597
+ // #2787 Mechanism B — insertion timestamps for the busy keys above, so the
1598
+ // confirm sweep can reap an orphaned marker. `busy` is stamped EAGERLY at
1599
+ // delivery and cleared only at turn_end; a delivered-but-never-enqueued inbound
1600
+ // (e.g. a composer strand or an enqueue-ack mismatch) therefore leaves its key
1601
+ // stuck forever, wedging the legacy-regime idle-drain (turnInFlightForGate reads
1602
+ // claudeBusyKeys.size) for EVERY topic until the ~300s silence poke — the
1603
+ // 5-minute all-topics stall of #1922. The reaper (reapOrphanBusyKeys) uses these
1604
+ // timestamps to bound that dangle. The delivery-machine cutover regime is already
1605
+ // immune (it tracks one activeTurn + TTL tick, never a per-key set); this bounds
1606
+ // the legacy path it hasn't replaced yet.
1607
+ const claudeBusyKeySince = new Map<string, number>()
1608
+
1580
1609
  /**
1581
1610
  * #2527 observability: count emoji transitions per status-reaction controller
1582
1611
  * so `turn_no_reply_warn` can report how many reaction changes happened while
@@ -1610,10 +1639,68 @@ function markClaudeBusyForInbound(m: {
1610
1639
  if (Number.isFinite(n)) tid = n
1611
1640
  }
1612
1641
  const key = chatKey(m.chatId, tid)
1613
- claudeBusyKeys.add(key)
1642
+ // #2787: lockstep add + idle→busy timestamp. Keyed on set membership (see
1643
+ // busy-key-reaper.ts) so a re-mark after a disconnect flush (which cleared
1644
+ // claudeBusyKeys directly) always re-stamps a fresh Date.now() and the orphan
1645
+ // TTL measures the CURRENT dangle, never a stale pre-disconnect timestamp.
1646
+ markBusyKeyLockstep(claudeBusyKeys, claudeBusyKeySince, key, Date.now())
1614
1647
  return key
1615
1648
  }
1616
1649
 
1650
+ // #2787 Mechanism B — reap orphaned busy markers. Called from the confirm sweep
1651
+ // ONLY once `currentTurn == null` has been asserted: with no turn in flight, any
1652
+ // surviving claudeBusyKeys entry is definitionally an orphan (a real turn would
1653
+ // hold currentTurn non-null), so clearing a stale one can never clobber a live
1654
+ // turn. Also prunes timestamp entries whose key already left the set (e.g. a
1655
+ // disconnect flush cleared claudeBusyKeys directly) so the map can't grow
1656
+ // unbounded.
1657
+ //
1658
+ // SLOW-DELIVERY SAFETY (the #1922 hazard): `currentTurn == null` is NOT proof of
1659
+ // idle — busy is marked EAGERLY at delivery, and the gateway→claude enqueue-ack
1660
+ // lag can be up to ~5 MINUTES under load (#1922). During that eager-mark→enqueue
1661
+ // window `currentTurn` is null yet the turn is real-and-merely-slow, so a bare
1662
+ // time-grace reaper could reap a genuine delivery and re-open the idle-drain gate
1663
+ // while claude is about to process the very inbound whose key it just reaped
1664
+ // (duplicate / concurrent delivery on the CORE inbound path). A time grace alone
1665
+ // cannot distinguish "slow" from "orphaned".
1666
+ //
1667
+ // So the reap is PROOF-GATED, not just time-gated (option (b), the load-bearing
1668
+ // guarantee): a key is reaped ONLY when it has NO corresponding entry in the
1669
+ // delivery-confirm queue (`deliveryQueue.pending`). A slow-but-real inbound is
1670
+ // tracked there from delivery until claude's `enqueue` ack lands (or forever, via
1671
+ // the never-drop re-deliver loop), so any key still awaiting its turn is present
1672
+ // and skipped — no matter how slow. Only a key that is busy-marked yet has no
1673
+ // pending delivery is a TRUE orphan: either it was acked (its turn ran and should
1674
+ // have cleared busy at turn_end — a genuinely stuck marker) or it was a
1675
+ // steer/interrupt inbound that shouldTrackDelivery excludes (it amends a running
1676
+ // turn, so with currentTurn == null its marker is likewise stale). Reaping only
1677
+ // these can never clobber a slow delivery.
1678
+ //
1679
+ // The time grace is retained as defense-in-depth on top of the proof gate, raised
1680
+ // to a default well above the observed enqueue-ack lag and env-tunable via the
1681
+ // config cascade. This bounds the legacy-regime idle-drain wedge from ~300s
1682
+ // (silence poke) to the grace window without racing a slow delivery.
1683
+ //
1684
+ // Config cascade: SWITCHROOM_BUSY_ORPHAN_TTL_MS is an OVERRIDE-mode scalar
1685
+ // (defaults → profiles → agents; a lower layer's value replaces, not merges).
1686
+ // Default 360_000ms (6 min) — comfortably above the ~5-min #1922 tail so the
1687
+ // grace never trips on a merely-slow delivery even if the proof gate is bypassed.
1688
+ // Clamped to a positive finite value; a degenerate override falls back to default.
1689
+ const _busyOrphanTtlRaw = process.env.SWITCHROOM_BUSY_ORPHAN_TTL_MS
1690
+ const _busyOrphanTtlParsed =
1691
+ _busyOrphanTtlRaw != null && _busyOrphanTtlRaw !== '' ? Number(_busyOrphanTtlRaw) : 360_000
1692
+ const CLAUDE_BUSY_ORPHAN_TTL_MS =
1693
+ Number.isFinite(_busyOrphanTtlParsed) && _busyOrphanTtlParsed > 0 ? _busyOrphanTtlParsed : 360_000
1694
+ function reapOrphanBusyKeysNow(now: number): void {
1695
+ reapOrphanBusyKeys(claudeBusyKeys, claudeBusyKeySince, now, {
1696
+ ttlMs: CLAUDE_BUSY_ORPHAN_TTL_MS,
1697
+ // Proof gate — a key still tracked in the delivery-confirm queue is a
1698
+ // slow-but-real inbound awaiting its enqueue ack, never an orphan (#1922).
1699
+ hasPendingDelivery: (key) => deliveryQueue.pending.has(key),
1700
+ log: (msg) => process.stderr.write(`${msg}\n`),
1701
+ })
1702
+ }
1703
+
1617
1704
  // ─── Reliable inbound delivery: deliver-until-acked (the marko drop-wedge) ─
1618
1705
  // A delivered inbound is ACKED only by the `enqueue` session-event (claude
1619
1706
  // actually started the turn) — NOT by sendToAgent returning true. Until
@@ -1934,13 +2021,8 @@ const POST_ANSWER_LIVENESS_STALE_MS = parsePostAnswerLivenessMs(
1934
2021
  process.env.SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS,
1935
2022
  ) || 30_000
1936
2023
 
1937
- /** Compact mm/ss-ish elapsed for the live feed suffix: "18s", "1m05s". */
1938
- function formatFeedElapsed(ms: number): string {
1939
- const s = Math.floor(ms / 1000)
1940
- if (s < 60) return `${s}s`
1941
- const m = Math.floor(s / 60)
1942
- return `${m}m${(s % 60).toString().padStart(2, '0')}s`
1943
- }
2024
+ // Live-feed step suffixes render via `formatStepSuffix` (tool-activity-summary):
2025
+ // the CURRENT step's own elapsed, shown only past STEP_TIMER_MIN_MS (10 s).
1944
2026
 
1945
2027
  /**
1946
2028
  * Authoritative "is a turn in flight?" for every gate that previously
@@ -1957,7 +2039,14 @@ function formatFeedElapsed(ms: number): string {
1957
2039
  * message self-blocks. See the snapshot at the inbound handler.
1958
2040
  */
1959
2041
  function turnInFlightForGate(): boolean {
1960
- return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0
2042
+ if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0
2043
+ // Machine is authoritative. Run the log-only drift canary (#2794): the
2044
+ // imperative `claudeBusyKeys` shadow is still live in parallel, so a
2045
+ // dangerous over-hold divergence (machine holds the gate while the
2046
+ // imperative view is idle) is surfaced without changing behaviour. The
2047
+ // benign orphan-dangle direction — the wedge the machine self-heals — is
2048
+ // NOT flagged. `probeGateParity` returns the machine value unchanged.
2049
+ return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
1961
2050
  }
1962
2051
 
1963
2052
  /**
@@ -3263,6 +3352,7 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
3263
3352
  // the markClaudeBusyForInbound on the delivery path. Safe no-op
3264
3353
  // when the key was never marked (synthetic purge from a sweep).
3265
3354
  claudeBusyKeys.delete(key)
3355
+ claudeBusyKeySince.delete(key) // #2787: keep the orphan-TTL map in lockstep
3266
3356
  // #2527: clear the per-key reaction-transition counter and first-reply
3267
3357
  // sentinel alongside the controller so we don't leak state across turns.
3268
3358
  reactionTransitionCounts.delete(key)
@@ -3448,6 +3538,7 @@ function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
3448
3538
  // PR3b: keep claudeBusyKeys in sync — same lifecycle as the
3449
3539
  // activeTurnStartedAt entry it's mirroring here.
3450
3540
  claudeBusyKeys.delete(key)
3541
+ claudeBusyKeySince.delete(key) // #2787: keep the orphan-TTL map in lockstep
3451
3542
  // Shadow trace so the structural turn-end metric still records.
3452
3543
  // outboundEmitted=true is correct here — we only reach this from
3453
3544
  // executeReply AFTER an outbound landed.
@@ -4451,7 +4542,7 @@ const STATUS_QUERY_RE = /^\s*status\??\s*$/i
4451
4542
 
4452
4543
  // ─── Permission handling ──────────────────────────────────────────────────
4453
4544
  const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
4454
- const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number; card_text: string; cards: { chatId: string; messageId: number }[] }>()
4545
+ const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number; card_text: string; cards: { chatId: string; messageId: number; threadId?: number | null }[] }>()
4455
4546
  // PERMISSION_TTL_MS / ttlForTool / the timed-out card builder now live in
4456
4547
  // ./permission-timeout.ts (pure + unit-testable). hostd gated verbs get a
4457
4548
  // 30-min window; everything else keeps the 10-min default.
@@ -5652,22 +5743,37 @@ const statusPinStoreFs = {
5652
5743
  }
5653
5744
  const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
5654
5745
 
5655
- // The full live claim set as persisted rows (confirmed pins), from the Maps.
5656
- function snapshotStatusPins(): PersistedStatusPin[] {
5657
- const snapshot: PersistedStatusPin[] = []
5658
- for (const [pinKey, state] of statusPinState) {
5659
- const chatId = statusPinChatIds.get(pinKey)
5660
- if (chatId == null) continue
5661
- snapshot.push({ pinKey, chatId, messageId: state.messageId })
5662
- }
5663
- return snapshot
5664
- }
5665
-
5666
- // The live claim set EXCLUDING one key used by reconcileAndPersistStatusPin so
5667
- // it can rewrite the whole set atomically while it flips that one key's record
5668
- // between pending / confirmed / absent.
5669
- function snapshotStatusPinsExcept(exceptKey: string): PersistedStatusPin[] {
5670
- return snapshotStatusPins().filter((p) => p.pinKey !== exceptKey)
5746
+ // Slot-banner pin persistence (#421 crash-recovery). The slot banner is pinned
5747
+ // in the owner chat when the agent is on a non-default OAuth slot. Rather than a
5748
+ // parallel store + second boot hook, its pin is persisted in the SAME
5749
+ // status-pins.json store under a distinct `banner:` pinKey, so the ONE existing
5750
+ // runStatusPinBootCleanup unpins an orphaned banner on boot for free. Every
5751
+ // write to the shared store is a per-key read-modify-write under the store's
5752
+ // per-path lock (mutateStatusPinRow / reconcileAndPersistStatusPin), so the two
5753
+ // pin kinds (map-backed status pins + this banner row) can never clobber each
5754
+ // other's rows — the "other" rows always come from the authoritative on-disk
5755
+ // file, never from a possibly-stale in-memory snapshot.
5756
+ const BANNER_PIN_KEY = 'banner:owner'
5757
+ // Banner persistence rides the same "store is usable" gate as status pins minus
5758
+ // the worker-pin feature flag: the banner is an independent feature, so it must
5759
+ // persist/recover whenever there's a real state volume (STATIC = no volume/dry
5760
+ // run → no-op). The boot-cleanup gate below is widened to cover this.
5761
+ const bannerPinPersistEnabled = !STATIC
5762
+
5763
+ // Persist (or drop) the slot-banner's pin row into the shared store. Routes
5764
+ // through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
5765
+ // serialised on the store's per-path lock, so the live fg:/wk: status-pin rows
5766
+ // on disk are carried through unchanged and never clobbered. Best-effort +
5767
+ // gated: no-op when the store isn't usable (STATIC). Fire-and-forget — the
5768
+ // mutation never rejects (persist is fail-open, load is fail-open).
5769
+ function persistBannerRow(row: PersistedStatusPin | null): void {
5770
+ if (!bannerPinPersistEnabled) return
5771
+ void mutateStatusPinRow(
5772
+ STATUS_PIN_STORE_PATH,
5773
+ statusPinStoreFs,
5774
+ BANNER_PIN_KEY,
5775
+ row,
5776
+ )
5671
5777
  }
5672
5778
 
5673
5779
  // The Bot API surface the pin driver needs. `lockedBot` is defined later; wrap
@@ -5700,7 +5806,11 @@ function statusPinApi(): PinBotApi {
5700
5806
  * positives.
5701
5807
  */
5702
5808
  async function statusPinBootCleanup(): Promise<void> {
5703
- if (!statusPinPersistEnabled) return
5809
+ // Runs when EITHER pin kind could have written the shared store: the
5810
+ // map-backed status pins (statusPinPersistEnabled) or the slot banner
5811
+ // (bannerPinPersistEnabled). A single cleanup drains ALL orphaned rows —
5812
+ // status pins AND banner alike — since they share status-pins.json.
5813
+ if (!statusPinPersistEnabled && !bannerPinPersistEnabled) return
5704
5814
  const api = statusPinApi()
5705
5815
  const { cleared, total } = await runStatusPinBootCleanup({
5706
5816
  path: STATUS_PIN_STORE_PATH,
@@ -5811,7 +5921,6 @@ async function reconcileStatusPinInner(
5811
5921
  pinKey,
5812
5922
  chatId,
5813
5923
  op,
5814
- snapshotOthers: () => snapshotStatusPinsExcept(pinKey),
5815
5924
  applyPin: runReconcile,
5816
5925
  })
5817
5926
  if (next == null) {
@@ -6624,19 +6733,56 @@ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Pro
6624
6733
  forgetDelivery(deliveryQueue, p.key)
6625
6734
  }
6626
6735
  }
6736
+ // #2787 Mechanism A — which chats/topics currently hold a live permission or
6737
+ // ask_user card. A pending card is a live interaction for ITS OWN chat, so the
6738
+ // confirm sweep must not re-clear the composer + re-send there. But the OLD guard
6739
+ // (`pendingPermissions.size > 0 || pendingAskUser.size > 0 → return`) suspended
6740
+ // the sweep GLOBALLY: one card parked in a single topic (or in the operator DM,
6741
+ // a different chatId entirely) froze re-delivery of every stranded inbound across
6742
+ // EVERY topic until that one card resolved. This scopes the suspension to the
6743
+ // card's own target. Returns per-topic chatKeys where the topic is known and bare
6744
+ // chatIds where it isn't (a card fanned to operator DMs records chatId only, and
6745
+ // a whole-chat suspension is the safe conservative fallback there); the callsite
6746
+ // tests a delivery entry against both renderings.
6747
+ function sweepSuspendedTargets(): { keys: Set<string>; chats: Set<string> } {
6748
+ const keys = new Set<string>()
6749
+ const chats = new Set<string>()
6750
+ for (const p of pendingPermissions.values()) {
6751
+ for (const c of p.cards) {
6752
+ if (c.threadId != null) keys.add(chatKey(c.chatId, c.threadId))
6753
+ else chats.add(c.chatId)
6754
+ }
6755
+ // A permission whose card send hasn't resolved yet (cards still empty) has
6756
+ // no recorded target — suspend nothing for it; the next sweep sees it once
6757
+ // the send resolves, and the currentTurn guard already covers the in-turn
6758
+ // window a fresh permission request lives in.
6759
+ }
6760
+ for (const a of pendingAskUser.values()) {
6761
+ if (a.threadId != null) keys.add(chatKey(a.chatId, a.threadId))
6762
+ else chats.add(a.chatId)
6763
+ }
6764
+ return { keys, chats }
6765
+ }
6766
+
6627
6767
  const _deliveryConfirmSweep = setInterval(() => {
6628
6768
  if (!DELIVERY_CONFIRM_ENABLED) return
6629
6769
  // Re-deliver ONLY when claude is genuinely idle. `currentTurn` is set solely
6630
6770
  // by the enqueue session-event and nulled at turn-end, so `currentTurn != null`
6631
6771
  // means a real turn is in flight — re-clearing the composer + re-sending now
6632
- // would clobber it (the exact mid-turn wedge this queue exists to prevent). A
6633
- // pending permission / ask_user prompt is likewise a live interaction. Defer:
6634
- // leave the entry pending (it isn't acked) so the next idle sweep retries.
6772
+ // would clobber it (the exact mid-turn wedge this queue exists to prevent).
6635
6773
  // NB: claudeBusyKeys (turnInFlightForGate) is set EAGERLY at delivery and
6636
6774
  // stays set through a strand, so it is NOT a usable "idle" signal here.
6637
6775
  if (currentTurn != null) return
6638
- if (pendingPermissions.size > 0 || pendingAskUser.size > 0) return
6776
+ // #2787 Mechanism B: no turn in flight → reap any orphaned busy marker so a
6777
+ // delivered-but-never-enqueued inbound can't wedge the idle-drain globally.
6778
+ reapOrphanBusyKeysNow(Date.now())
6779
+ // #2787 Mechanism A: a pending permission / ask_user card suspends re-delivery
6780
+ // ONLY for its own chat/topic — never globally. Skip just the stranded entries
6781
+ // whose target holds a live card; sweep the rest so unrelated topics keep
6782
+ // getting re-delivered.
6783
+ const suspended = sweepSuspendedTargets()
6639
6784
  for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
6785
+ if (isRedeliverySuspended(p.key, suspended)) continue
6640
6786
  void redeliverStrandedInbound(p)
6641
6787
  }
6642
6788
  }, DELIVERY_CONFIRM_SWEEP_MS)
@@ -6709,8 +6855,61 @@ const inboundSpool = STATIC
6709
6855
  existsSync: (p) => existsSync(p),
6710
6856
  statSizeSync: (p) => statSync(p).size,
6711
6857
  },
6858
+ // #2789 B: durability degradation must be visible, not silent. When
6859
+ // spool appends start failing we're back to in-memory-only — a
6860
+ // later crash loses those messages. Latched log so the operator /
6861
+ // health surface sees the transition rather than a silent downgrade.
6862
+ onDegraded: (info) => {
6863
+ process.stderr.write(
6864
+ `telegram gateway: inbound-spool durability ` +
6865
+ `${info.degraded ? 'DEGRADED to in-memory-only' : 'RECOVERED'} ` +
6866
+ `path=${info.path} consecutiveFailures=${info.consecutiveFailures}` +
6867
+ `${info.error != null ? ` error=${info.error}` : ''}\n`,
6868
+ )
6869
+ },
6712
6870
  })
6713
- const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
6871
+
6872
+ // #2789 A: the in-memory cap eviction used to silently drop the oldest
6873
+ // buffered inbound within a live session (the durable spool copy only
6874
+ // replays at boot / escalates after 15 min). Surface it as a coalesced,
6875
+ // per-chat "messages deferred" notice so the eviction is visible — the
6876
+ // evicted message still lives in the spool, so it is deferred, not lost.
6877
+ const EVICT_NOTICE_COOLDOWN_MS = 5 * 60 * 1000
6878
+ const evictNoticeByChat = new Map<string, number>()
6879
+ const pendingInboundBuffer = createPendingInboundBuffer({
6880
+ spool: inboundSpool,
6881
+ onEvict: (_agent, evicted) => {
6882
+ const chat = evicted.chatId
6883
+ const evThread =
6884
+ typeof evicted.meta?.threadId === 'string' && evicted.meta.threadId
6885
+ ? Number(evicted.meta.threadId)
6886
+ : undefined
6887
+ const key = `${chat}:${evThread ?? '-'}`
6888
+ const last = evictNoticeByChat.get(key)
6889
+ const nowMs = Date.now()
6890
+ // Coalesce: at most one deferral notice per chat per cooldown so a
6891
+ // sustained >32 burst posts one line, not one per evicted message.
6892
+ if (last != null && nowMs - last < EVICT_NOTICE_COOLDOWN_MS) return
6893
+ evictNoticeByChat.set(key, nowMs)
6894
+ const threadOpts = evThread != null ? { message_thread_id: evThread } : {}
6895
+ // Honesty: the evicted message is NOT re-pushed into the buffer
6896
+ // in-session — its only in-session resolution is the escalation sweep
6897
+ // (sweepEscalations, ~15 min), which either delivers it or posts the
6898
+ // "couldn't deliver … please resend" retraction. So this notice must
6899
+ // NOT promise a prompt pickup ("shortly"), or it would contradict a
6900
+ // later escalation. Word it to match that reality: saved, handled when
6901
+ // the current turn frees up, and prompted-to-resend if it can't be.
6902
+ void swallowingApiCall(
6903
+ () =>
6904
+ bot.api.sendMessage(
6905
+ chat,
6906
+ "⏳ Messages are arriving faster than I can process them. Your messages are saved and will be handled once I finish the current turn — if any can't be picked up, I'll ask you to resend it.",
6907
+ { ...threadOpts },
6908
+ ),
6909
+ { chat_id: chat, verb: 'inbound-buffer-eviction' },
6910
+ )
6911
+ },
6912
+ })
6714
6913
 
6715
6914
  // PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
6716
6915
  // CLEAN idle: no turn in flight AND the inbound buffer is empty — so the
@@ -6880,6 +7079,22 @@ function obligationSweep(): void {
6880
7079
  // normal close path) — close silently instead of alarming the user with a
6881
7080
  // false "I may have missed this". This is Fix 4: escalate only on knowledge,
6882
7081
  // not doubt. Fall back to false (safe: never suppresses) if history unavailable.
7082
+ //
7083
+ // #2788 Gap A — bridge-flap gate. The escalate branch DIRECT-SENDS (via
7084
+ // bot.api.sendMessage below), bypassing the bridge. So while the bridge is
7085
+ // down the represent branch is naturally stranded (bridge → buffer) but this
7086
+ // branch would still fire a false "I may have missed this", even though the
7087
+ // real reply is merely queued behind a transient outage. Defer: if the bridge
7088
+ // is not alive, leave the obligation OPEN and re-drive on a later sweep once
7089
+ // it recovers. Obligations survive bridge death (durable ledger, re-evaluated
7090
+ // every sweep), so this deferral adds NO unbounded liveness dependency — the
7091
+ // whole gateway already rests on the bridge eventually reconnecting.
7092
+ if (shouldDeferEscalationForBridge({ bridgeAlive: ipcServer.getClient(agent)?.isAlive() === true })) {
7093
+ process.stderr.write(
7094
+ `telegram gateway: obligation escalation deferred — bridge down (nudge waits for reconnect) origin=${o.originTurnId}\n`,
7095
+ )
7096
+ return
7097
+ }
6883
7098
  if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
6884
7099
  process.stderr.write(
6885
7100
  `telegram gateway: obligation closed silently — outbound delivered since open origin=${o.originTurnId}\n`,
@@ -6938,6 +7153,25 @@ if (bootResumeInbound != null) {
6938
7153
  } else {
6939
7154
  pendingInboundBuffer.push(bootResumeInbound.agent, bootResumeInbound.msg)
6940
7155
  }
7156
+ // At-most-once resume (#2793 part A): now that the resume inbound is
7157
+ // DURABLY committed (spooled, or buffered in STATIC mode), stamp the
7158
+ // turn's `resumed_at` so a later restart can't re-mint a fresh resume for
7159
+ // the same turn and re-run side effects that already executed. This is
7160
+ // synchronous and runs before any async delivery/ack can interleave, so
7161
+ // the ledger is set before the spool entry can be consumed — closing the
7162
+ // accept-vs-consume double-execution window. Stamping AFTER the durable
7163
+ // put (never before) means a crash in between leaves the turn un-stamped
7164
+ // and the resume is retried, not silently dropped.
7165
+ const resumeTurnKey = bootResumeInbound.msg.meta?.resume_turn_key
7166
+ if (turnsDb != null && typeof resumeTurnKey === 'string' && resumeTurnKey.length > 0) {
7167
+ try {
7168
+ markTurnResumed(turnsDb, resumeTurnKey)
7169
+ } catch (err) {
7170
+ process.stderr.write(
7171
+ `telegram gateway: markTurnResumed failed turnKey=${resumeTurnKey}: ${(err as Error).message}\n`,
7172
+ )
7173
+ }
7174
+ }
6941
7175
  }
6942
7176
  // Boot-replay: re-queue every un-acked spooled inbound into the
6943
7177
  // in-memory buffer so the existing drain triggers (onClientRegistered
@@ -7272,6 +7506,7 @@ const ipcServer: IpcServer = createIpcServer({
7272
7506
  activeReactionMsgIds,
7273
7507
  activeTurnStartedAt,
7274
7508
  claudeBusyKeys,
7509
+ claudeBusyKeySince,
7275
7510
  activeDraftStreams,
7276
7511
  clearActiveReactions: () => {
7277
7512
  const ad = resolveAgentDirFromEnv()
@@ -7328,11 +7563,10 @@ const ipcServer: IpcServer = createIpcServer({
7328
7563
  // compaction occupancy read (see maybeProactiveCompact).
7329
7564
  if (msg.activeFile) lastSessionActiveFile = msg.activeFile
7330
7565
  const ev = msg.event as unknown as SessionEvent
7331
- // Pass the envelope's chatId so non-enqueue events can route to the
7332
- // correct card even when the driver's currentChatId is stale.
7333
- const chatHint = msg.chatId || null
7334
- const threadHint = msg.threadId != null ? String(msg.threadId) : undefined
7335
- progressDriver?.ingest(ev, chatHint, threadHint)
7566
+ // #1122/#1126: session events used to be ingested into the pinned progress
7567
+ // card here (`progressDriver.ingest`). The card is retired and the driver
7568
+ // is permanently null, so that call was a dead no-op — removed. Session
7569
+ // events still drive the live surfaces via `handleSessionEvent`.
7336
7570
  handleSessionEvent(ev)
7337
7571
  // Problem B: keep the deferred-interrupt boundary tracker in lockstep with
7338
7572
  // the session stream (tool_use opens, tool_result/turn_end close). If a `!`
@@ -7498,7 +7732,7 @@ const ipcServer: IpcServer = createIpcServer({
7498
7732
  // found"), re-send thread-less into the main chat so the card still
7499
7733
  // ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
7500
7734
  // allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware send
7501
- void retryWithThreadFallback<{ message_id: number }>(
7735
+ void retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
7502
7736
  robustApiCall,
7503
7737
  (tid) =>
7504
7738
  bot.api.sendRichMessage(chatId, richMessage(text), {
@@ -7514,7 +7748,19 @@ const ipcServer: IpcServer = createIpcServer({
7514
7748
  // guard the lookup.
7515
7749
  const pend = pendingPermissions.get(requestId)
7516
7750
  if (pend && sent && typeof sent.message_id === 'number') {
7517
- pend.cards.push({ chatId, messageId: sent.message_id })
7751
+ // #2787: record where the card ACTUALLY LANDED so the confirm sweep
7752
+ // scopes its re-delivery suspension to the real chat/topic. When
7753
+ // retryWithThreadFallback hit THREAD_NOT_FOUND (stale/renumbered
7754
+ // topic) it re-sent thread-less into the main chat — the returned
7755
+ // Message then carries no message_thread_id, so we must record the
7756
+ // landed topic (undefined → suspend by bare chatId), NOT the stale
7757
+ // requested `threadId`. Keying suspension on the stale topic would
7758
+ // leave the main-chat card unsuspended (a re-deliver could clobber
7759
+ // the live card) while needlessly suspending a topic that holds
7760
+ // nothing. `sent.message_thread_id` reflects reality in all three
7761
+ // cases: topic success → tid, main-chat / fallback → undefined.
7762
+ const landedThreadId = sent.message_thread_id ?? undefined
7763
+ pend.cards.push({ chatId, messageId: sent.message_id, threadId: landedThreadId })
7518
7764
  permCardStore.add({
7519
7765
  requestId,
7520
7766
  chatId,
@@ -8374,7 +8620,7 @@ if (!STATIC) {
8374
8620
  // promise EXPLICITLY (honest failure) instead of letting it sit
8375
8621
  // forever. This is what makes the guarantee deterministic: every
8376
8622
  // queued message ends either delivered or visibly retracted.
8377
- inboundSpool?.sweepEscalations((e, { postNotice }) => {
8623
+ inboundSpool?.sweepEscalations((e, { postNotice, droppedCount }) => {
8378
8624
  const chat = e.msg.chatId
8379
8625
  const escThread =
8380
8626
  typeof e.msg.meta?.threadId === 'string' && e.msg.meta.threadId
@@ -8393,13 +8639,17 @@ if (!STATIC) {
8393
8639
  // outage that re-ages a synthetic into the bound every 15 min posts ONE
8394
8640
  // notice, not one per cycle (the 2026-06-09 marko "please resend" spam).
8395
8641
  if (!postNotice) return
8642
+ // #2789 C: report the REAL number of dropped messages for this chat
8643
+ // in this sweep rather than under-counting a multi-message drop as a
8644
+ // single one.
8645
+ const n = droppedCount > 0 ? droppedCount : 1
8646
+ const noticeText =
8647
+ n === 1
8648
+ ? "⚠️ I couldn't deliver an earlier message to the agent after repeated retries (it survived restarts but the agent never picked it up). Please resend it."
8649
+ : `⚠️ I couldn't deliver ${n} earlier messages to the agent after repeated retries (they survived restarts but the agent never picked them up). Please resend them.`
8396
8650
  void swallowingApiCall(
8397
8651
  () =>
8398
- bot.api.sendMessage(
8399
- chat,
8400
- "⚠️ I couldn't deliver an earlier message to the agent after repeated retries (it survived restarts but the agent never picked it up). Please resend it.",
8401
- { ...threadOpts },
8402
- ),
8652
+ bot.api.sendMessage(chat, noticeText, { ...threadOpts }),
8403
8653
  { chat_id: chat, verb: 'inbound-spool-escalation' },
8404
8654
  )
8405
8655
  })
@@ -8411,7 +8661,7 @@ if (!STATIC) {
8411
8661
  /** Allowlisted tool names that bridges may invoke via IPC. Prevents a rogue
8412
8662
  * bridge from calling arbitrary functions by name. */
8413
8663
  const ALLOWED_TOOLS = new Set([
8414
- 'reply', 'stream_reply', 'progress_update', 'react', 'download_attachment',
8664
+ 'reply', 'progress_update', 'react', 'download_attachment',
8415
8665
  'edit_message', 'send_typing', 'pin_message', 'delete_message',
8416
8666
  'forward_message', 'get_recent_messages',
8417
8667
  'send_checklist', 'update_checklist',
@@ -8432,8 +8682,6 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
8432
8682
  switch (tool) {
8433
8683
  case 'reply':
8434
8684
  return executeReply(args)
8435
- case 'stream_reply':
8436
- return executeStreamReply(args)
8437
8685
  case 'progress_update':
8438
8686
  return executeProgressUpdate(args)
8439
8687
  case 'react':
@@ -9010,7 +9258,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
9010
9258
  // delivery-channel decision must NOT pollute final-answer CLASSIFICATION: a
9011
9259
  // final answer the model intended to ping is STILL the final answer even when
9012
9260
  // the framework silences the actual ping. Classify on the model's original
9013
- // intent (what executeStreamReply already does), so an over-ping-silenced
9261
+ // intent (what executeReply already does), so an over-ping-silenced
9014
9262
  // final answer sets finalAnswerDelivered=true — fixing both a spurious
9015
9263
  // silent-end re-prompt and a false 'undelivered' (😐) terminal reaction.
9016
9264
  const modelDisableNotification = args.disable_notification === true
@@ -10106,441 +10354,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
10106
10354
  return { content: [{ type: 'text', text: result }] }
10107
10355
  }
10108
10356
 
10109
- async function executeStreamReply(args: Record<string, unknown>): Promise<unknown> {
10110
- // #1664 — pin the turn at entry; see executeReply for the rationale.
10111
- const turn = currentTurn
10112
- if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
10113
- if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
10114
- // chat_id allowlist fallback — mirrors executeReply (~8725). stream_reply is
10115
- // the primary final-answer path, so a wrong/late chat_id (int/string mismatch,
10116
- // or the model echoing the wrong identifier after a silence poke flipped
10117
- // currentTurn to null) would otherwise fail assertAllowedChat in the stream
10118
- // controller → an invisible reply. Rewrite args.chat_id in place so every
10119
- // downstream consumer (origin resolution, dedup key, the send) sees the
10120
- // corrected value. Tier 1: live turn's sessionChatId. Tier 2: last-known
10121
- // turn's chat (survives silence poke — Bug D fix; currentTurn is null).
10122
- {
10123
- const _rawChatId = String(args.chat_id ?? '')
10124
- const resolved = resolveChatIdFallback(
10125
- _rawChatId,
10126
- loadAccess(),
10127
- turn?.sessionChatId,
10128
- lastActiveTurnChatId,
10129
- turn != null,
10130
- )
10131
- if (resolved.tier !== 'raw') {
10132
- process.stderr.write(
10133
- `telegram gateway: stream_reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
10134
- `routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
10135
- )
10136
- args.chat_id = resolved.chatId
10137
- }
10138
- }
10139
- // Thread precedence (matches executeReply; component 3 — turn-origin
10140
- // routing): when the model passes no explicit message_thread_id, inject
10141
- // the ORIGIN turn's thread (matched by origin_turn_id) — authoritative
10142
- // even after currentTurn flips — falling back to the live turn's thread
10143
- // when no origin is resolvable (legacy #1664). Injecting into
10144
- // args.message_thread_id threads every downstream consumer consistently
10145
- // (dedup key, voice-scrub metric, draft transport, the send), so a
10146
- // streamed handback/synthetic-turn reply lands in the right supergroup
10147
- // topic and a late stream-reply can't be stolen by a successor turn. DM:
10148
- // every tier undefined → unchanged. Kill switch off → legacy live-turn
10149
- // injection only.
10150
- // Origin resolution is hoisted UNCONDITIONALLY (outside the
10151
- // message_thread_id==null guard below) so the obligation-close path has
10152
- // the correct routedOriginTurn even when the model explicitly passes
10153
- // message_thread_id (forum-topic streams). Without this hoist, Fix 1
10154
- // is a no-op for forum-topic streams — the origin is never resolved and
10155
- // closeObligationOnSubstantiveReply falls through to the live-turn
10156
- // fallback. Matches executeReply's unconditional resolution. Thread
10157
- // injection still stays scoped to the message_thread_id==null branch —
10158
- // only the obligation-close input changes.
10159
- let streamRoutedOriginTurn: CurrentTurn | null = null
10160
- // Track whether the origin was found via echo (for the routing log below).
10161
- let streamOriginVia: 'echo' | 'quoted' | null = null
10162
- if (TURN_ORIGIN_ROUTING_ENABLED) {
10163
- // Origin precedence: model echo first, then the framework-owned quoted
10164
- // message_id as a deterministic fallback (mirrors executeReply).
10165
- const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
10166
- const quotedTurn =
10167
- echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
10168
- const originTurn = echoedTurn ?? quotedTurn
10169
- streamRoutedOriginTurn = originTurn ?? null
10170
- streamOriginVia = originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted'
10171
- }
10172
- if (args.message_thread_id == null) {
10173
- let injected: number | undefined
10174
- if (TURN_ORIGIN_ROUTING_ENABLED) {
10175
- injected = resolveAnswerThreadWithLog(
10176
- String(args.chat_id),
10177
- undefined,
10178
- streamRoutedOriginTurn,
10179
- streamOriginVia,
10180
- turn,
10181
- 'stream_reply',
10182
- )
10183
- } else {
10184
- injected = turn?.sessionThreadId
10185
- }
10186
- if (injected != null) args.message_thread_id = String(injected)
10187
- }
10188
-
10189
- // Outbound secret scrub (#2044): mask before the dedup key, the draft
10190
- // stream sends, and the history record. stream_reply carries the FULL
10191
- // text-so-far on every call, so redacting each call keeps the answer-
10192
- // stream's incremental diffing comparing redacted-against-redacted.
10193
- args.text = redactOutboundText(args.text as string, 'stream_reply')
10194
-
10195
- // Voice scrub (PR #1683 follow-up). Modern Claude on the fleet
10196
- // uses the answer-stream / draft-stream path for multi-paragraph
10197
- // replies — the model emits via stream_reply and the original
10198
- // PR #1683 scrub site (executeReply) never sees the text. klanker's
10199
- // 2026-05-24 log showed model output with em-dashes routed via
10200
- // stream_reply done=true, materializing as sendMessage with no
10201
- // scrub. Mirror the executeReply pattern here: scrub BEFORE the
10202
- // outbound-dedup check (so retries see the scrubbed key) and
10203
- // mutate args.text so all downstream consumers (the stream-
10204
- // controller, dedup record, history record) see the scrubbed
10205
- // version. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
10206
- {
10207
- // Cross-path consistency (#2755): normalizePunctuation runs BEFORE
10208
- // scrubVoice here, matching the reply/edit paths, so a spaced em-dash
10209
- // gets the same comma treatment on every outbound path (scrubVoice
10210
- // would otherwise see the raw dash first on this path only and apply
10211
- // its period substitution). handleStreamReply's own deps run
10212
- // normalizePunctuation again downstream — it is idempotent, so the
10213
- // second pass is a no-op. Side effect: scrubVoice's dash telemetry
10214
- // (voice_scrub_applied) drops to ~zero on this path since the dashes
10215
- // are consumed here first — deliberate.
10216
- args.text = normalizePunctuation(args.text as string)
10217
- const scrub = scrubVoice(args.text as string)
10218
- if (scrub.replaced > 0) {
10219
- args.text = scrub.scrubbed
10220
- emitRuntimeMetric({
10221
- kind: 'voice_scrub_applied',
10222
- chatKey: statusKey(String(args.chat_id ?? ''), args.message_thread_id != null
10223
- ? Number(args.message_thread_id) : undefined),
10224
- replaced: scrub.replaced,
10225
- site: 'stream_reply',
10226
- })
10227
- }
10228
- }
10229
-
10230
- // #546 dedup check: stream_reply done=true is the most-common
10231
- // retry shape — claude-code re-emits the final-text call when
10232
- // the previous bridge missed the ack. If turn-flush already sent
10233
- // the same content, swallow the retry and return success.
10234
- // Only check on done=true (the terminal call); intermediate
10235
- // streaming chunks are progress edits, not full sends.
10236
- if (args.done === true) {
10237
- const sChatId = String(args.chat_id ?? '')
10238
- const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
10239
- const sText = args.text as string
10240
- const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null)
10241
- if (dup != null) {
10242
- process.stderr.write(
10243
- `telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ` +
10244
- `ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}\n`,
10245
- )
10246
- return { content: [{ type: 'text', text: 'sent (deduped — same content sent via earlier path)' }] }
10247
- }
10248
- }
10249
-
10250
- const access = loadAccess()
10251
- // Detect chat type for throttle-default selection.
10252
- // Private (DM) chats have positive numeric IDs; groups/channels are negative.
10253
- const streamChatId = String(args.chat_id ?? '')
10254
- const streamIsPrivate = isDmChatId(streamChatId)
10255
- const streamIsForumTopic = args.message_thread_id != null && args.message_thread_id !== ''
10256
- // Pre-allocated draft handoff removed in #553 PR 5 — draft-stream
10257
- // now allocates a fresh draft id on its first send. The pre-alloc
10258
- // path has been retired along with the placeholder text it was
10259
- // designed to overwrite cleanly.
10260
-
10261
- // #271: validate + namespace callback_data for stream_reply. Same
10262
- // wrapping as executeReply — URL buttons pass through, callback_data
10263
- // gets the `agent:` prefix so the dispatcher can route taps back to
10264
- // this agent. Only attached on done=true so buttons land on the
10265
- // final answer message, not on intermediate draft edits.
10266
- let streamReplyMarkup: { inline_keyboard: AnyButton[][] } | undefined
10267
- let streamButtonMeta: Map<string, AgentButtonMeta> | undefined
10268
- const rawStreamKeyboard = args.inline_keyboard as AnyButton[][] | undefined
10269
- if (rawStreamKeyboard != null && Boolean(args.done)) {
10270
- const validationErrors = validateInlineKeyboard(rawStreamKeyboard)
10271
- if (validationErrors.length > 0) {
10272
- const summary = validationErrors
10273
- .map((e) => `${e.path}.${e.field}: ${e.reason}`)
10274
- .join('; ')
10275
- throw new Error(`inline_keyboard validation failed: ${summary}`)
10276
- }
10277
- streamButtonMeta = extractAgentButtonMeta(rawStreamKeyboard)
10278
- streamReplyMarkup = { inline_keyboard: wrapAgentCallbacks(rawStreamKeyboard) }
10279
- }
10280
-
10281
- // #1122 KPI: stream_reply's FIRST emit is a fresh user-visible outbound
10282
- // message (subsequent calls edit the same message — no device ping).
10283
- // Snapshot state BEFORE handleStreamReply to detect first-emit precisely.
10284
- {
10285
- const streamThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
10286
- const sKeyBefore = streamKey(streamChatId, streamThreadId)
10287
- if (!activeDraftStreams.has(sKeyBefore)) {
10288
- const sKey = statusKey(streamChatId, streamThreadId)
10289
- signalTracker.noteOutbound(sKey, Date.now())
10290
- silencePoke.noteOutbound(sKey, Date.now())
10291
- // PR3b-cutover: feed lastOutboundAt to the delivery machine (see
10292
- // executeReply) so its TTL tick suppresses an active-turn fallback.
10293
- shadowEmit({ kind: 'modelOutbound', key: sKey as _ChatKey, at: Date.now() })
10294
- // #2527: emit turn_reply_timing on the first stream_reply of the turn,
10295
- // mirroring the same gate in executeReply. Guards with firstTextReplyLogged
10296
- // so a turn that calls reply first and stream_reply second doesn't double-emit.
10297
- if (turn != null && !firstTextReplyLogged.has(sKey)) {
10298
- firstTextReplyLogged.add(sKey)
10299
- logStreamingEvent({
10300
- kind: 'turn_reply_timing',
10301
- chatId: streamChatId,
10302
- threadId: streamThreadId,
10303
- turnId: turn.turnId,
10304
- timeToFirstTextReplyMs: Date.now() - turn.gatewayReceiveAt,
10305
- })
10306
- }
10307
- // #1741 — see executeReply for the rationale: only a plausibly-
10308
- // final stream_reply clears the silent-end state. An interim
10309
- // ack via stream_reply must NOT clear; the Stop hook needs
10310
- // the state to persist if turn_end fails to land.
10311
- if (
10312
- isFinalAnswerReply({
10313
- text: (args.text as string | undefined) ?? '',
10314
- disableNotification: args.disable_notification === true,
10315
- done: args.done === true,
10316
- })
10317
- ) {
10318
- clearSilentEndState(sKey)
10319
- }
10320
- }
10321
- }
10322
-
10323
- // Lever 2 (design §9 lever 2): finalize the activity card BEFORE the stream
10324
- // send so the card keeps its lower message_id and the reply is structurally
10325
- // last. ONLY for a *substantive* final (a stream_reply done=true or ≥200
10326
- // chars) — for a short pinging interim chunk do NOTHING (finalizing an ack
10327
- // early would close → reopen → emit more, the #2141 ack-then-work feed, R3).
10328
- // `clearActivitySummary` edits in place + nulls activityMessageId; the sticky
10329
- // latch set here blocks any post-reply re-OPEN below the answer.
10330
- if (
10331
- turn != null
10332
- && isSubstantiveFinalReply({
10333
- text: (args.text as string | undefined) ?? '',
10334
- disableNotification: args.disable_notification === true,
10335
- done: args.done === true,
10336
- })
10337
- ) {
10338
- // PR-4a: routed through the emission-authority façade (no-op delegates —
10339
- // the latch-set and the finalize run exactly as before).
10340
- const ea = emissionAuthorityFor(turn)
10341
- ea.markSubstantiveFinalDelivered(() => {
10342
- turn.finalAnswerEverDelivered = true
10343
- if (turn.finalAnswerDeliveredAt == null) turn.finalAnswerDeliveredAt = Date.now()
10344
- })
10345
- ea.finalizeCard(() => {
10346
- clearActivitySummary(turn)
10347
- })
10348
- }
10349
-
10350
- const result = await handleStreamReply(
10351
- {
10352
- chat_id: streamChatId,
10353
- text: args.text as string,
10354
- done: Boolean(args.done),
10355
- message_thread_id: args.message_thread_id as string | undefined,
10356
- format: args.format as string | undefined,
10357
- reply_to: args.reply_to as string | undefined,
10358
- quote: args.quote as boolean | undefined,
10359
- ...(args.protect_content === true ? { protect_content: true } : {}),
10360
- ...(args.quote_text != null ? { quote_text: args.quote_text as string } : {}),
10361
- ...(streamReplyMarkup != null ? { reply_markup: streamReplyMarkup } : {}),
10362
- ...(args.disable_notification === true ? { disable_notification: true } : {}),
10363
- },
10364
- { activeDraftStreams, suppressPtyPreview },
10365
- {
10366
- // grammy's Bot<Context, Api<RawApi>> has a wider api shape than the
10367
- // local StreamBotApi interface, but is structurally compatible at
10368
- // runtime (StreamBotApi is a subset). Cast through unknown to
10369
- // bypass the structural-typing strictness.
10370
- bot: lockedBot as unknown as { api: import('../stream-controller.js').StreamBotApi },
10371
- retry: robustApiCall,
10372
- repairEscapedWhitespace,
10373
- normalizeParagraphBreaks,
10374
- normalizePunctuation,
10375
- stripExcessBold,
10376
- addParagraphSpacers,
10377
- assertAllowedChat,
10378
- resolveThreadId,
10379
- disableLinkPreview: access.disableLinkPreview !== false,
10380
- defaultFormat: access.parseMode ?? 'html',
10381
- logStreamingEvent,
10382
- isPrivateChat: streamIsPrivate,
10383
- isForumTopic: streamIsForumTopic,
10384
- // Issue #310: deliver the outbound count bump BEFORE forceCompleteTurn
10385
- // so the terminal render sees outboundDeliveredCount > 0. The handler
10386
- // calls this dep in that order internally.
10387
- recordOutboundDelivered: (chatId, threadId) => {
10388
- progressDriver?.recordOutboundDelivered(
10389
- chatId,
10390
- threadId != null ? String(threadId) : undefined,
10391
- )
10392
- },
10393
- forceCompleteTurn: (chatId, threadId) => {
10394
- progressDriver?.forceCompleteTurn({
10395
- chatId,
10396
- threadId: threadId != null ? String(threadId) : undefined,
10397
- })
10398
- },
10399
- historyEnabled: HISTORY_ENABLED,
10400
- recordOutbound,
10401
- ...(HISTORY_ENABLED ? { getLatestInboundMessageId } : {}),
10402
- writeError: (line) => process.stderr.write(line),
10403
- // When the operator sets `channels.telegram.stream_throttle_ms` in yaml,
10404
- // the env override wins; otherwise draft-stream's DM/group defaults apply
10405
- // (400 ms for DMs, 1000 ms for groups). `throttleMs: undefined` passes
10406
- // through to draft-stream where the per-chat-type default applies.
10407
- ...(STREAM_THROTTLE_MS_OVERRIDE != null ? { throttleMs: STREAM_THROTTLE_MS_OVERRIDE } : {}),
10408
- progressCardActive: streamMode === 'checklist',
10409
- },
10410
- )
10411
- // Issue #137: bump the per-turn outbound counter on every successful
10412
- // stream_reply call (partial OR final). Even a single chunk landing
10413
- // proves the delivery path worked. messageId may be null when the
10414
- // call only updated the streaming draft and didn't sendMessage on
10415
- // this invocation — that case still counts as activity.
10416
- if (result.messageId != null) {
10417
- try {
10418
- progressDriver?.recordOutboundDelivered(
10419
- String(args.chat_id ?? ''),
10420
- args.message_thread_id as string | undefined,
10421
- )
10422
- } catch { /* best-effort signal */ }
10423
- // Issue #203: stream_reply is the agent's primary reply path. Without
10424
- // ticking the silent-gap tracker here, turn_signal_gap reports the
10425
- // entire turn duration as silent for any turn that uses stream_reply
10426
- // — which per CLAUDE.md guidance is most of them. The metric would be
10427
- // worse than no metric. Tick on every successful delivery (partial or
10428
- // final) so the gap measurement reflects real silent intervals.
10429
- try {
10430
- const threadIdNum = args.message_thread_id != null
10431
- ? Number(args.message_thread_id)
10432
- : undefined
10433
- signalTracker.noteSignal(
10434
- statusKey(String(args.chat_id ?? ''), threadIdNum),
10435
- Date.now(),
10436
- )
10437
- } catch { /* best-effort signal */ }
10438
- }
10439
- // #710: stash agent-button meta keyed by the final message id so the
10440
- // callback handler can honor ack_text / single_use on tap.
10441
- if (
10442
- args.done === true
10443
- && result.messageId != null
10444
- && streamButtonMeta != null
10445
- && streamButtonMeta.size > 0
10446
- ) {
10447
- rememberAgentButtonMeta(String(args.chat_id ?? ''), result.messageId, streamButtonMeta)
10448
- }
10449
- // #546 dedup record: capture the final stream_reply text on the
10450
- // terminal call so a subsequent retry (different bridge, same
10451
- // content) lands as a no-op instead of a second message.
10452
- if (args.done === true && result.messageId != null) {
10453
- const sChatId = String(args.chat_id ?? '')
10454
- const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
10455
- outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now(), currentTurn?.registryKey ?? null)
10456
- // #1445 cross-turn pending-async ambient. The terminal stream_reply
10457
- // (done=true) is the user-visible anchor for any cross-turn wait
10458
- // that follows. Capture it so if this turn ends with a pending
10459
- // async dispatch, the framework edits THIS message in place at
10460
- // intervals.
10461
- //
10462
- // #2669 — capture whether the stream-reply-handler sent literally
10463
- // (format:'text') so the cross-turn edit tick re-edits with the same
10464
- // shape. The default rich-markdown path re-edits via the rich path.
10465
- const streamFormat = (args.format as string | undefined) ?? (access.parseMode ?? 'html')
10466
- const streamLiteralText = streamFormat === 'text'
10467
- // #1760 primary fix — clear any stale prior-turn ticker before
10468
- // re-anchoring on stream_reply done. See the matching comment at
10469
- // the executeReply finalize site.
10470
- pendingProgress.clearPending(statusKey(sChatId, sThreadId), 'reply_finalize')
10471
- pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
10472
- messageId: result.messageId,
10473
- text: args.text as string,
10474
- literalText: streamLiteralText,
10475
- })
10476
- }
10477
- // #1664 — mark the turn's final answer as delivered. For stream_reply a
10478
- // call with done=true IS the final answer by definition (the model
10479
- // explicitly closed the stream). A non-terminal stream_reply chunk also
10480
- // counts when it carries the final-answer signals — notification-bearing
10481
- // OR substantive length — via the same `isFinalAnswerReply` predicate
10482
- // executeReply uses. See the CurrentTurn.finalAnswerDelivered doc-comment
10483
- // for why replyCalled is not a sufficient signal here.
10484
- if (
10485
- turn != null &&
10486
- isFinalAnswerReply({
10487
- text: (args.text as string | undefined) ?? '',
10488
- disableNotification: args.disable_notification === true,
10489
- done: args.done === true,
10490
- })
10491
- ) {
10492
- turn.finalAnswerDelivered = true
10493
- // Feed-reopen refinement: a stream_reply done=true (or a ≥200-char
10494
- // chunk) is substantive; a short pinging non-done chunk is an ack. Only
10495
- // the latter should re-open the feed on subsequent post-answer work.
10496
- turn.finalAnswerSubstantive = isSubstantiveFinalReply({
10497
- text: (args.text as string | undefined) ?? '',
10498
- disableNotification: args.disable_notification === true,
10499
- done: args.done === true,
10500
- })
10501
- // Sticky ordering latch (lever 1): set once a SUBSTANTIVE final lands;
10502
- // never cleared by reopen. The card OPEN gate keys on this sticky latch.
10503
- if (turn.finalAnswerSubstantive) turn.finalAnswerEverDelivered = true
10504
- if (turn.finalAnswerSubstantive && turn.finalAnswerDeliveredAt == null) turn.finalAnswerDeliveredAt = Date.now()
10505
- if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, streamRoutedOriginTurn)
10506
- // #1744 follow-up — stream_reply edge case. The first-emit gate at
10507
- // L5178 only clears silent-end state on the FIRST emit of a stream.
10508
- // If a stream's first emit was ack-shaped (disable_notification:true,
10509
- // short text, no done) it correctly did NOT clear the state. But a
10510
- // LATER emit in the same stream may flip `done=true` or carry
10511
- // substantive text — that's the real final answer landing, and the
10512
- // state file must be cleared here too. clearSilentEndState is
10513
- // idempotent (no-op when the file is absent or the turnKey doesn't
10514
- // match), so calling it unconditionally on every final-answer-shaped
10515
- // emit is safe even if the first-emit path already cleared.
10516
- const streamThreadIdForClear = args.message_thread_id != null
10517
- ? Number(args.message_thread_id)
10518
- : undefined
10519
- clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear))
10520
- }
10521
- // v0.13.30 follow-up — release the buffer gate on every successful
10522
- // stream_reply too. Same rationale as executeReply: short replies
10523
- // with `disable_notification: true` would otherwise wedge the gate
10524
- // forever. `result.status` is always 'updated' | 'finalized'
10525
- // (stream-reply-handler.ts:305) at this point — earlier failures
10526
- // throw or return before reaching here.
10527
- {
10528
- const sChat = String(args.chat_id ?? '')
10529
- const sThread = resolveThreadId(sChat, args.message_thread_id as string | undefined)
10530
- // Component 1: pass the turn (finalAnswerDelivered set above for a
10531
- // final stream emit). Interim stream chunks leave it false → no
10532
- // cross-topic drain until the done=true / substantive emit lands.
10533
- releaseTurnBufferGate(statusKey(sChat, sThread), turn ?? undefined)
10534
- // Component 5: reap the queued-status placeholder for THIS turn's
10535
- // topic once the final answer landed. Key on the turn's own session
10536
- // thread (where the placeholder lives), not the answer's resolved one.
10537
- if (turn?.finalAnswerDelivered === true) {
10538
- reapQueuedStatus(turn.sessionChatId, turn.sessionThreadId)
10539
- }
10540
- }
10541
- return { content: [{ type: 'text', text: `${result.status} (id: ${result.messageId ?? 'pending'})` }] }
10542
- }
10543
-
10544
10357
  async function executeProgressUpdate(args: Record<string, unknown>): Promise<unknown> {
10545
10358
  if (!args.chat_id) throw new Error('progress_update: chat_id is required')
10546
10359
  if (!args.text) throw new Error('progress_update: text is required')
@@ -10552,6 +10365,14 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
10552
10365
 
10553
10366
  assertAllowedChat(chat_id)
10554
10367
 
10368
+ // Outbound secret scrub (#2044). progress_update was the ONLY send site not
10369
+ // calling redactOutboundText, so a secret the agent echoed into a progress
10370
+ // line reached Telegram unmasked (reply/stream_reply/edit_message/turn_flush
10371
+ // all redact). Mask BEFORE the 300-char truncation below: a secret straddling
10372
+ // the 300-char cut would otherwise be sliced apart and evade the detector, so
10373
+ // redaction has to run on the full untruncated text first.
10374
+ text = redactOutboundText(text, 'progress_update')
10375
+
10555
10376
  // Truncate to 300 chars
10556
10377
  if (text.length > 300) {
10557
10378
  text = text.slice(0, 299) + '…'
@@ -10592,44 +10413,14 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
10592
10413
  progressUpdateTurnCount.set(key, currentCount + 1)
10593
10414
  }
10594
10415
 
10595
- // Issue #305 Option A — try the card-injection path first.
10596
- // If the call originates from a sub-agent and the parent has an active
10597
- // pinned card, narrative lands as the sub-agent's row body. Falls through
10598
- // to the message-send path on miss (parent-agent calls, no active card,
10599
- // race with watcher backfill, etc).
10600
- const agentIdHint = (typeof args.agent_id === 'string' && args.agent_id) || null
10601
- const toolUseIdHint = (typeof args.tool_use_id === 'string' && args.tool_use_id) || null
10602
- const subAgent = resolveCallingSubagent({
10603
- db: turnsDb,
10604
- chatId: chat_id,
10605
- threadId,
10606
- agentIdHint,
10607
- toolUseIdHint,
10608
- })
10609
- if (subAgent != null && progressDriver != null) {
10610
- const cardText = text.length > 200 ? text.slice(0, 199) + '…' : text
10611
- const result = progressDriver.recordSubAgentNarrative({
10612
- chatId: chat_id,
10613
- threadId: threadId != null ? String(threadId) : undefined,
10614
- agentId: subAgent.agentId,
10615
- text: cardText,
10616
- })
10617
- if (result.ok) {
10618
- progressUpdateLastSent.set(key, now)
10619
- try {
10620
- signalTracker.noteSignal(key, Date.now())
10621
- } catch { /* best-effort signal */ }
10622
- return {
10623
- content: [
10624
- {
10625
- type: 'text',
10626
- text: JSON.stringify({ ok: true, mode: 'card', agent_id: subAgent.agentId }),
10627
- },
10628
- ],
10629
- }
10630
- }
10631
- // Otherwise fall through to message-send below.
10632
- }
10416
+ // Issue #305 Option A — the card-injection path (route a sub-agent's
10417
+ // `progress_update` narrative into its row on the parent's pinned card)
10418
+ // was retired with the progress card in #1122/#1126: `progressDriver` is
10419
+ // permanently null, so `recordSubAgentNarrative` could never fire and the
10420
+ // whole `resolveCallingSubagent` card-inject block was a dead no-op that
10421
+ // always fell through here. Removed. Sub-agent progress now surfaces via
10422
+ // the `🛠 Worker` activity feed + the model's own in-voice handback, and a
10423
+ // `progress_update` call always takes the plain message-send path below.
10633
10424
 
10634
10425
  // Send plain message (no quote-reply). Single rich-markdown path (#2669):
10635
10426
  // `parseMode:'text'` config opts into a literal plain send; everything
@@ -11798,7 +11589,11 @@ function resetOrphanedReplyTimeout(): void {
11798
11589
  currentSessionChatId: turn.sessionChatId,
11799
11590
  capturedTextCount: turn.capturedText.length,
11800
11591
  replyCalled: turn.replyCalled,
11801
- progressCardActive: progressDriver != null,
11592
+ // #1122/#1126: the pinned card is retired and `progressDriver` is
11593
+ // permanently null, so this suppression signal is always false. Kept as a
11594
+ // literal (the shouldArm* predicate still accepts the flag) rather than
11595
+ // reading the dead driver.
11596
+ progressCardActive: false,
11802
11597
  })) {
11803
11598
  turn.orphanedReplyTimeoutId = setTimeout(() => {
11804
11599
  // The timer fires asynchronously; re-read currentTurn so we
@@ -11812,7 +11607,8 @@ function resetOrphanedReplyTimeout(): void {
11812
11607
  currentSessionChatId: t.sessionChatId,
11813
11608
  capturedTextCount: t.capturedText.length,
11814
11609
  replyCalled: t.replyCalled,
11815
- progressCardActive: progressDriver != null,
11610
+ // #1122/#1126: retired card / null driver — always false (see above).
11611
+ progressCardActive: false,
11816
11612
  })) {
11817
11613
  // Feed-survival guard: re-arm the fuse while the turn is
11818
11614
  // legitimately working — an in-flight tool, a detached background
@@ -12192,7 +11988,10 @@ function openLivenessFeedIfDue(turn: CurrentTurn): void {
12192
11988
  const livenessHeader: SessionActivityHeader = {
12193
11989
  label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
12194
11990
  }
12195
- const rendered = renderActivityFeedWithNested(lines, [], false, ` · ${formatFeedElapsed(age)}`, undefined, livenessHeader)
11991
+ // Liveness card is a single "step" whose start is the turn start, so `age`
11992
+ // IS the step's own elapsed. formatStepSuffix keeps the `→` line timer-free
11993
+ // until the step has run ≥ STEP_TIMER_MIN_MS (header total still shows).
11994
+ const rendered = renderActivityFeedWithNested(lines, [], false, formatStepSuffix(age), undefined, livenessHeader)
12196
11995
  if (rendered == null) return
12197
11996
  turn.activityPendingRender = rendered
12198
11997
  const ea = emissionAuthorityFor(turn)
@@ -12312,8 +12111,10 @@ function feedHeartbeatTick(): void {
12312
12111
  label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
12313
12112
  }
12314
12113
  const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working in background…']
12114
+ // `subagentAt` is the worker's last ADVANCE — the current step's start —
12115
+ // so this suffix is already per-step. formatStepSuffix adds the 10 s gate.
12315
12116
  const elapsed = Date.now() - subagentAt
12316
- const rendered = renderActivityFeedWithNested(lines, [], false, ` · ${formatFeedElapsed(elapsed)}`, undefined, livenessHeader)
12117
+ const rendered = renderActivityFeedWithNested(lines, [], false, formatStepSuffix(elapsed), undefined, livenessHeader)
12317
12118
  if (rendered == null) return
12318
12119
  turn.activityPendingRender = rendered
12319
12120
  const ea = emissionAuthorityFor(turn)
@@ -12354,7 +12155,10 @@ function feedHeartbeatTick(): void {
12354
12155
  if (turn.lastToolLabelAt == null) return // feed not driven by a labelled step
12355
12156
  const elapsed = Date.now() - turn.lastToolLabelAt
12356
12157
  if (elapsed < FEED_HEARTBEAT_MIN_STALE_MS) return // step is fresh; feed advancing normally
12357
- const rendered = composeTurnActivity(turn, false, ` · ${formatFeedElapsed(elapsed)}`)
12158
+ // `lastToolLabelAt` resets on every new tool label, so `elapsed` is the
12159
+ // CURRENT step's own run time. formatStepSuffix holds the timer back until
12160
+ // the step passes STEP_TIMER_MIN_MS (10 s) — header total is unaffected.
12161
+ const rendered = composeTurnActivity(turn, false, formatStepSuffix(elapsed))
12358
12162
  if (rendered == null) return
12359
12163
  turn.activityPendingRender = rendered
12360
12164
  const ea = emissionAuthorityFor(turn)
@@ -12614,6 +12418,13 @@ function handleSessionEvent(ev: SessionEvent): void {
12614
12418
  deliveryQueue,
12615
12419
  chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
12616
12420
  ev.messageId,
12421
+ // #2786 — pass the raw enqueue envelope so the ack survives the
12422
+ // composer merging/reordering inbound wrappers (the single
12423
+ // re-parsed `ev.messageId` can then belong to a sibling, not our
12424
+ // tracked message). The tolerant match scans all ids in this
12425
+ // content; a synthetic-source turn still lacks the user id, so the
12426
+ // cross-source false-ack guard holds.
12427
+ ev.rawContent,
12617
12428
  )
12618
12429
  }
12619
12430
  // PR3b-cutover: feed the authoritative turn-start to the delivery
@@ -13562,6 +13373,18 @@ function handleSessionEvent(ev: SessionEvent): void {
13562
13373
 
13563
13374
  if (flushDecision.kind === 'flush') {
13564
13375
  let capturedText = flushDecision.text
13376
+ // #2798 — turn-flush delivers the model's terminal prose when it
13377
+ // skipped reply/stream_reply, but historically bypassed the reply
13378
+ // path's markdown normalization entirely. Mirror executeReply's front
13379
+ // of pipeline here so the backstop renders identically: repair LLM
13380
+ // JSON-escape bungles (literal `\n`), then promote lone prose paragraph
13381
+ // breaks into GFM hard breaks so the Bot API 10.1 rich path doesn't
13382
+ // collapse them (lists/tables/code left untouched). Runs BEFORE the
13383
+ // redact/scrub below, exactly as reply orders it (repair → normalize →
13384
+ // redact → scrub), so masking sees the repaired text. The matching
13385
+ // addParagraphSpacers pass runs on the send side just before
13386
+ // splitMarkdownChunks (see below).
13387
+ capturedText = normalizeParagraphBreaks(repairEscapedWhitespace(capturedText))
13565
13388
  // Component 3 — origin-thread backstop. `chatId`/`threadId` are
13566
13389
  // captured from the turn atom (turn.sessionChatId/sessionThreadId)
13567
13390
  // at the top of this turn_end handler, NOT from the live
@@ -13581,6 +13404,15 @@ function handleSessionEvent(ev: SessionEvent): void {
13581
13404
  // the preview, and recordOutbound.
13582
13405
  capturedText = redactOutboundText(capturedText, 'turn_flush')
13583
13406
 
13407
+ // #2798 reply-parity — normalize dashes/bullets and trip the over-bold
13408
+ // guard deterministically, on code-masked text. This is the SAME chain
13409
+ // the reply path applies (`stripExcessBold(normalizePunctuation(text))`)
13410
+ // in the SAME order: after redact, before scrubVoice. Without it the
13411
+ // turn-flush backstop rendered punctuation/bold differently from an
13412
+ // identical reply. Kept inline to mirror reply exactly — a shared helper
13413
+ // is a deliberate future refactor, not this change.
13414
+ capturedText = stripExcessBold(normalizePunctuation(capturedText))
13415
+
13584
13416
  // Voice scrub (PR #1683 follow-up). Turn-flush is the path
13585
13417
  // that fires when the model emits raw transcript text WITHOUT
13586
13418
  // calling reply / stream_reply. That captured text bypasses
@@ -13692,7 +13524,14 @@ function handleSessionEvent(ev: SessionEvent): void {
13692
13524
  link_preview_options: { is_disabled: true },
13693
13525
  }
13694
13526
  const limit = RICH_MESSAGE_MAX_CHARS
13695
- const htmlChunks = splitMarkdownChunks(capturedText, limit)
13527
+ // #2798 / #2692 — inject visible blank-line spacers into prose `\n\n`
13528
+ // gaps before splitting, exactly as executeReply does. The rich GFM
13529
+ // renderer collapses a bare `\n\n` gap TIGHT, so without this the
13530
+ // paragraph boundaries from the '\n\n' block join (turn-flush-safety
13531
+ // .ts) would still render jammed together. Mirrors reply's
13532
+ // `addParagraphSpacers(text)` on the non-literal path.
13533
+ const renderedText = addParagraphSpacers(capturedText)
13534
+ const htmlChunks = splitMarkdownChunks(renderedText, limit)
13696
13535
  const sentIds: number[] = []
13697
13536
  try {
13698
13537
  // #654 deterministic double-message fix. If the progress
@@ -13969,12 +13808,13 @@ function handleSessionEvent(ev: SessionEvent): void {
13969
13808
  }
13970
13809
  // #550: symmetric cleanup with the writeTurnActiveMarker call at
13971
13810
  // the enqueue arm (line ~2810). Pre-fix, removal was single-pathed
13972
- // through progressDriver.onTurnComplete, which silently no-ops
13973
- // when forceCompleteTurn finds no active card leaking the
13974
- // marker across restarts and triggering watchdog false-positive
13975
- // restarts. The onTurnComplete callback is retained as defence-
13976
- // in-depth (both paths are idempotent unlinkSync swallows
13977
- // ENOENT).
13811
+ // through the (now-retired, #1122/#1126) progressDriver.onTurnComplete
13812
+ // callback, which silently no-op'd when forceCompleteTurn found no
13813
+ // active card — leaking the marker across restarts and triggering
13814
+ // watchdog false-positive restarts. That driver callback no longer
13815
+ // exists (progressDriver is permanently null); this explicit
13816
+ // removeTurnActiveMarker is now the sole cleanup path (idempotent —
13817
+ // unlinkSync swallows ENOENT).
13978
13818
  removeTurnActiveMarker(STATE_DIR)
13979
13819
  // #1067: null the atom in one assignment, replacing the seven
13980
13820
  // field clears the pre-refactor version did. Any late-arriving
@@ -14651,36 +14491,56 @@ async function handleInbound(
14651
14491
  }
14652
14492
 
14653
14493
  // Issue #109: when the user has to ask "status?" mid-turn, the live progress
14654
- // surface (pinned card + status reactions) has failed its job. Log the
14655
- // event with a snapshot of the card state so we can count + analyze
14656
- // frequency. We don't intercept the message — it still flows through to
14657
- // the agent, since the agent may have a useful answer the card hasn't
14658
- // surfaced yet.
14494
+ // surface (status reactions + the edit-in-place `🛠 Worker` feed) may have
14495
+ // failed its job. Log the event with a snapshot of what is ACTUALLY in
14496
+ // flight so we can count + analyze frequency. We don't intercept the
14497
+ // message — it still flows through to the agent, since the agent may have a
14498
+ // useful answer the surfaces haven't communicated yet.
14499
+ //
14500
+ // Truthful telemetry (was a lying diagnostic): the pinned progress card was
14501
+ // retired in #1122/#1126 and `progressDriver` is now permanently null, so
14502
+ // the old `progressDriver.peek(...)` snapshot ALWAYS reported idle/zero
14503
+ // regardless of live work — actively misleading debugging. We now read the
14504
+ // LIVE surfaces instead: the running-background-worker DB count
14505
+ // (`countRunningWorkers`), the `🛠 Worker` activity-feed size, the
14506
+ // cross-turn pending-async-dispatch flag (`pending-work-progress.ts`), and
14507
+ // whether a turn is active (`activeTurnStartedAt`). A live background worker
14508
+ // ⇒ non-idle. A `ux-failure` is emitted ONLY when all of those are genuinely
14509
+ // zero AND no turn is active — i.e. the user asked "status?" and there really
14510
+ // was nothing in flight for the surfaces to have shown.
14659
14511
  //
14660
- // Log shape (grep anchor: "ux-failure: status-query"):
14661
- // ux-failure: status-query agent=<n> chat_id=<n> thread=<n|none>
14662
- // card_stage=<stage> card_turn_age_s=<int>
14663
- // card_items=<n> card_subagents=<n>
14512
+ // Log shape (grep anchor: "status-query"):
14513
+ // status-query agent=<n> chat_id=<n> thread=<n|none> stage=<stage>
14514
+ // turn_age_s=<int> running_workers=<n> worker_feed=<n>
14515
+ // pending_async=<0|1>
14516
+ // When idle, a second line carries the grep anchor "ux-failure: status-query".
14664
14517
  //
14665
- // `card_turn_age_s` is always a non-negative integer; `-1` is the
14666
- // sentinel for "no active turn / driver idle" so structured-log parsers
14667
- // (Loki, Datadog, awk) can treat the field as numeric without
14668
- // string-comparison branches.
14518
+ // `turn_age_s` is always a non-negative integer; `-1` is the sentinel for
14519
+ // "no active turn" so structured-log parsers (Loki, Datadog, awk) can treat
14520
+ // the field as numeric without string-comparison branches.
14669
14521
  if (STATUS_QUERY_RE.test(text)) {
14670
14522
  try {
14671
- const threadKey = messageThreadId != null ? String(messageThreadId) : undefined
14672
- const cardState = progressDriver?.peek(chat_id, threadKey)
14673
- const turnAgeS = cardState?.turnStartedAt
14674
- ? Math.max(0, Math.floor((Date.now() - cardState.turnStartedAt) / 1000))
14675
- : -1
14676
- const stage = cardState?.stage ?? 'idle'
14677
- const itemCount = cardState?.items.length ?? 0
14678
- const subAgentCount = cardState?.subAgents.size ?? 0
14523
+ const thread = messageThreadId != null ? String(messageThreadId) : 'none'
14524
+ const key = statusKey(chat_id, messageThreadId)
14525
+ const turnStartedAt = activeTurnStartedAt.get(key)
14526
+ const turnActive = turnStartedAt != null
14527
+ const surfaces: StatusQuerySurfaces = {
14528
+ turnActive,
14529
+ turnAgeS: turnActive
14530
+ ? Math.max(0, Math.floor((Date.now() - turnStartedAt) / 1000))
14531
+ : -1,
14532
+ runningWorkers: countRunningWorkers(),
14533
+ workerFeedSize: workerActivityFeed?.size ?? 0,
14534
+ pendingAsync: pendingProgress.hasPendingAsyncDispatch(key) ? 1 : 0,
14535
+ }
14536
+ const telemetry = deriveStatusQueryTelemetry(surfaces)
14679
14537
  const agentName = process.env.SWITCHROOM_AGENT_NAME ?? '-'
14680
14538
  process.stderr.write(
14681
- `telegram gateway: ux-failure: status-query agent=${agentName} chat_id=${chat_id} thread=${threadKey ?? 'none'} ` +
14682
- `card_stage=${stage} card_turn_age_s=${turnAgeS} card_items=${itemCount} card_subagents=${subAgentCount}\n`,
14539
+ formatStatusQueryLine(agentName, String(chat_id), thread, surfaces, telemetry),
14683
14540
  )
14541
+ if (telemetry.idle) {
14542
+ process.stderr.write(formatStatusQueryUxFailureLine(agentName, String(chat_id), thread))
14543
+ }
14684
14544
  } catch (err) {
14685
14545
  process.stderr.write(`telegram gateway: status-query telemetry failed: ${(err as Error).message}\n`)
14686
14546
  }
@@ -18294,6 +18154,17 @@ async function refreshPinnedBanner(reason: string): Promise<void> {
18294
18154
  onError: (phase, err) => {
18295
18155
  process.stderr.write(`telegram gateway: banner ${phase} failed (${reason}): ${err}\n`)
18296
18156
  },
18157
+ // Durable pin persistence into the SHARED status-pin store (distinct
18158
+ // `banner:` pinKey). persist-BEFORE-pin ordering: pending() lands before
18159
+ // the pinChatMessage call so a crash in that window is recoverable by the
18160
+ // one runStatusPinBootCleanup on next boot. Best-effort / gated no-op.
18161
+ persist: {
18162
+ pending: (chatId, messageId) =>
18163
+ persistBannerRow({ pinKey: BANNER_PIN_KEY, chatId, messageId, pending: true }),
18164
+ confirm: (chatId, messageId) =>
18165
+ persistBannerRow({ pinKey: BANNER_PIN_KEY, chatId, messageId }),
18166
+ clear: () => persistBannerRow(null),
18167
+ },
18297
18168
  })
18298
18169
  } catch (err) {
18299
18170
  process.stderr.write(`telegram gateway: banner refresh error (${reason}): ${err}\n`)
@@ -24679,10 +24550,22 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
24679
24550
  // pacing + the silence-poke safety net. The module-level helper vars
24680
24551
  // (`unpinProgressCardForChat`, `getPinnedProgressCardMessageId`,
24681
24552
  // `completeProgressCardTurn`, `flushProgressCardsForShutdown`,
24682
- // `progressDriver`) stay declared and `null`, so the dozens of
24553
+ // `progressDriver`) stay declared and `null`, so the remaining
24683
24554
  // optional-chained call sites scattered through this file remain
24684
- // valid TypeScript and no-op at runtime. Those dead sites are
24685
- // scheduled for a follow-up cleanup PR.
24555
+ // valid TypeScript and no-op at runtime.
24556
+ //
24557
+ // Cleanup pass (truthful-telemetry PR): the actively-misleading dead sites
24558
+ // have been purged — the `status-query` telemetry now reads the live surfaces
24559
+ // instead of the null-driver `peek()`; the sub-agent `onStall`/`onUnstall`/
24560
+ // `onStallTerminal` gateway wirings (which falsely implied a stall renders a
24561
+ // visual badge) are removed; the dead sub-agent card-injection block in
24562
+ // `progress_update` is gone; and `progressCardActive` reads the literal false
24563
+ // instead of `progressDriver != null`. The remaining `progressDriver?.X` /
24564
+ // `unpinProgressCardForChat?.(...)` / `completeProgressCardTurn?.(...)` sites
24565
+ // are interface-bound no-ops (deps handed to stream-reply-handler /
24566
+ // disconnect-flush, or constant-folding guards inside the turn-flush backstop
24567
+ // hot path); removing them means changing those modules' public shapes and is
24568
+ // deferred so this PR stays scoped to truthful telemetry + comment fixes.
24686
24569
 
24687
24570
 
24688
24571
  // ─── Startup ──────────────────────────────────────────────────────────────
@@ -25402,55 +25285,21 @@ void (async () => {
25402
25285
  // the model's own beat-4 handback reply; the watcher's
25403
25286
  // role here is registry liveness + the `onFinish` cue.
25404
25287
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
25405
- // Option C (#393): route stall detections into the progress-card
25406
- // driver so the pinned card re-renders with a ⚠️ indicator even
25407
- // when the bridge has disconnected and events have stopped flowing.
25408
- onStall: (agentId, idleMs, description) => {
25409
- progressDriver?.onSubAgentStall(agentId, idleMs, description)
25410
- },
25411
- // Symmetric to onStall: clear the Stalled badge as soon
25412
- // as the watcher sees JSONL activity return, instead of
25413
- // waiting on the next render tick to recompute idle ms.
25414
- onUnstall: (agentId, description) => {
25415
- progressDriver?.onSubAgentUnstall?.(agentId, description)
25416
- },
25417
- // RFC §Bug 6: background `Agent` dispatches in some
25418
- // Claude Code versions don't write a terminal
25419
- // `system + turn_duration` line to the sub-agent JSONL.
25420
- // The watcher detects the silent-stall window and
25421
- // synthesises terminal here; we mirror it into the
25422
- // progress driver as a real `sub_agent_turn_end`
25423
- // SessionEvent so the deferred-completion gate
25424
- // releases and the pinned card flips from 🌀 to ✅.
25288
+ // #1122/#1126 cleanup: the `onStall` / `onUnstall` /
25289
+ // `onStallTerminal` callbacks used to route into
25290
+ // `progressDriver.onSubAgentStall` / `onSubAgentUnstall` /
25291
+ // `ingest(sub_agent_turn_end)` so the pinned card could render a
25292
+ // ⚠️ badge and release its deferred-completion gate. That card
25293
+ // was deleted and `progressDriver` is permanently null, so those
25294
+ // wirings were dead no-ops that falsely implied a stall renders a
25295
+ // visual badge (it doesn't). They are removed here.
25425
25296
  //
25426
- // Find the parent turnKey + chatId by peeking the
25427
- // driver's fleet (same idiom onFinish uses just below).
25428
- onStallTerminal: (agentId) => {
25429
- try {
25430
- const fleets = progressDriver?.peekAllFleets() ?? []
25431
- for (const f of fleets) {
25432
- if (f.fleet.has(agentId)) {
25433
- progressDriver?.ingest(
25434
- { kind: 'sub_agent_turn_end', agentId },
25435
- f.chatId ?? null,
25436
- undefined,
25437
- )
25438
- return
25439
- }
25440
- }
25441
- // No fleet entry — the driver-side state was lost
25442
- // (cs.dispose ran or peek timing missed). Drop a
25443
- // log line and fall through to onFinish's audit
25444
- // surface; the card may already be cleaned up.
25445
- process.stderr.write(
25446
- `telegram gateway: subagent-watcher: onStallTerminal ${agentId} — no fleet entry to retarget; synthetic sub_agent_turn_end skipped\n`,
25447
- )
25448
- } catch (err) {
25449
- process.stderr.write(
25450
- `telegram gateway: subagent-watcher: onStallTerminal ${agentId} ingest error: ${(err as Error).message}\n`,
25451
- )
25452
- }
25453
- },
25297
+ // The load-bearing completion path is NOT here: subagent-watcher's
25298
+ // silent-stall terminal synthesis writes the terminal registry-DB
25299
+ // row itself (`recordSubagentEnd`) and fires `maybySendStateTransition`
25300
+ // → `onFinish` (the handback) independently of `onStallTerminal`.
25301
+ // Re-painting a live background worker on stall/unstall is PR 2's
25302
+ // job (via the `🛠 Worker` activity feed, not a retired card).
25454
25303
  // conversational-pacing beat 4 — the handback. A foreground
25455
25304
  // sub-agent hands its result straight back as the Task tool
25456
25305
  // result, inside the parent's own turn; the model sees it
@@ -25504,6 +25353,27 @@ void (async () => {
25504
25353
  } catch { /* best-effort */ }
25505
25354
  }
25506
25355
  const isBackground = dispatch.isBackground
25356
+ // NESTED (depth-2+) worker terminal: its live status surfaced
25357
+ // via the worker feed (see onProgress), so finalize that card
25358
+ // cleanly — never leave it frozen mid-"→ step". But NO user
25359
+ // handback: its result returns to its DISPATCHING WORKER as
25360
+ // the Task tool result (the depth-1 worker folds it into its
25361
+ // own handback); injecting a second gateway handback would
25362
+ // double-report the same work.
25363
+ if (dispatch.isNested) {
25364
+ if (workerFeedEnabled) {
25365
+ void workerActivityFeed?.finish(agentId, {
25366
+ description: dispatch.feedDescription,
25367
+ lastTool: null,
25368
+ toolCount,
25369
+ latestSummary: resultText,
25370
+ elapsedMs: durationMs,
25371
+ state: outcome === 'failed' ? 'failed' : 'done',
25372
+ })
25373
+ reconcileWorkerPin(agentId, null, false)
25374
+ }
25375
+ return
25376
+ }
25507
25377
  if (!isBackground) {
25508
25378
  // Model A — a foreground sub-agent finished. Collapse its
25509
25379
  // nested child block from the parent's activity draft; the
@@ -25703,7 +25573,18 @@ void (async () => {
25703
25573
  dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
25704
25574
  } catch { /* best-effort */ }
25705
25575
  }
25706
- const isBackground = dispatch.isBackground
25576
+ // A NESTED (depth-2+) worker surfaces via the worker feed
25577
+ // regardless of its own background flag: its "parent" is a
25578
+ // worker, not the gateway's live turn, so nesting it into
25579
+ // `currentTurn` would attribute it to an unrelated turn.
25580
+ const isBackground = dispatch.isBackground || dispatch.isNested
25581
+ // The live step line for THIS tick: the friendly tool label on
25582
+ // tool ticks, else the narrative. Previously the worker feed
25583
+ // was fed ONLY `latestSummary` (prose), so a tools-only worker
25584
+ // — the common case — had a card that heartbeat-edited but
25585
+ // never grew past "starting…" (the frozen-card symptom). The
25586
+ // foreground nest path below already used this precedence.
25587
+ const stepLine = (progressLine != null && progressLine.length > 0) ? progressLine : latestSummary
25707
25588
  if (!isBackground) {
25708
25589
  // Model A — a foreground sub-agent runs inside the parent's
25709
25590
  // turn, so its live narrative nests under the parent's
@@ -25719,7 +25600,14 @@ void (async () => {
25719
25600
  // the worker feed (owner-DM fallback), not into the void.
25720
25601
  const surface = resolveSubagentStatusSurface({
25721
25602
  isBackground: false,
25722
- liveTurnPresent: currentTurn != null,
25603
+ // A ROW-LESS worker must not nest into the live turn: with
25604
+ // no registry row we cannot know it belongs to this turn
25605
+ // (it is most often a nested dispatch whose row hasn't
25606
+ // linked yet), and polluting the parent's card misroutes
25607
+ // it. Route it via the orphan worker-feed path instead —
25608
+ // once the row links (watcher retry, ≤ a poll tick) the
25609
+ // classification self-corrects.
25610
+ liveTurnPresent: currentTurn != null && dispatch.hasRow,
25723
25611
  workerFeedEnabled,
25724
25612
  orphanStatusEnabled,
25725
25613
  })
@@ -25733,7 +25621,7 @@ void (async () => {
25733
25621
  description: dispatch.feedDescription,
25734
25622
  lastTool,
25735
25623
  toolCount,
25736
- latestSummary,
25624
+ latestSummary: stepLine,
25737
25625
  elapsedMs,
25738
25626
  state: 'running',
25739
25627
  },
@@ -25774,9 +25662,12 @@ void (async () => {
25774
25662
  narrative = []
25775
25663
  turn.foregroundSubAgents.set(agentId, narrative)
25776
25664
  }
25777
- // Dedup against the immediately-preceding line the watcher
25778
- // re-emits the same narrative across ticks while a tool runs.
25779
- if (narrative[narrative.length - 1] !== child) {
25665
+ // Dedup within the whole rolling window (mirrors the worker
25666
+ // feed's accumulateNarrative): the watcher re-emits the same
25667
+ // narrative across ticks, and a preamble + its tool label can
25668
+ // repeat non-adjacently (A,B,A) — both must collapse to one
25669
+ // ordered step.
25670
+ if (!narrative.includes(child)) {
25780
25671
  narrative.push(child)
25781
25672
  if (narrative.length > FOREGROUND_SUBAGENT_ACCUM_MAX) {
25782
25673
  narrative.splice(0, narrative.length - FOREGROUND_SUBAGENT_ACCUM_MAX)
@@ -25868,7 +25759,10 @@ void (async () => {
25868
25759
  description: dispatch.feedDescription,
25869
25760
  lastTool,
25870
25761
  toolCount,
25871
- latestSummary,
25762
+ // The tick's step line (tool label on tool ticks, prose
25763
+ // on text ticks) — NOT bare latestSummary, which starved
25764
+ // tools-only workers of steps (frozen "starting…").
25765
+ latestSummary: stepLine,
25872
25766
  elapsedMs,
25873
25767
  state: 'running',
25874
25768
  },