switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -34,6 +34,7 @@ import {
34
34
  type AskUserArgs,
35
35
  type AskUserOutcome,
36
36
  } from '../ask-user.js'
37
+ import { redactAskUserFields, redactChecklistFields } from '../outbound-field-redact.js'
37
38
  import { parseInterruptMarker } from '../interrupt-marker.js'
38
39
  import {
39
40
  ToolFlightTracker,
@@ -112,7 +113,7 @@ import {
112
113
  import type { WebhookGatewayRecord } from '../../src/web/webhook-gateway-record.js'
113
114
  import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
114
115
  import type { PinState, DesiredPin } from '../status-pin.js'
115
- import { decidePinAction } from '../status-pin.js'
116
+ import { decidePinAction, PinRightsCache } from '../status-pin.js'
116
117
  import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
117
118
  import { parseSourceMessageId } from './source-message-id.js'
118
119
  import {
@@ -155,6 +156,7 @@ import {
155
156
  computeBootSweepStripTargets,
156
157
  distinctRequestIds,
157
158
  } from './permission-rearm.js'
159
+ import { sweepPermissionTtl } from './permission-ttl-sweep.js'
158
160
  import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
159
161
  import {
160
162
  createAlwaysAllowPersistQueue,
@@ -168,6 +170,15 @@ import {
168
170
  buildMissedApprovalRetryInbound,
169
171
  } from './missed-approvals-card.js'
170
172
  import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
173
+ import {
174
+ createBlockedApprovalStore,
175
+ safeActionForRecord,
176
+ selectOldestHeld,
177
+ selectHeldForRedelivery,
178
+ holdReasonFor,
179
+ heldRetryBackoffMs,
180
+ type UndeliverableMark,
181
+ } from './approval-hold.js'
171
182
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
172
183
  import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
173
184
  import { formatModelLabel } from '../model-label.js'
@@ -177,6 +188,7 @@ import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
177
188
  import { toolLabel } from '../tool-labels.js'
178
189
  import { createTypingWrapper } from '../typing-wrap.js'
179
190
  import { createTurnTypingLoop } from './turn-typing-loop.js'
191
+ import { createTypingEmitter, TYPING_REFRESH_MS } from '../typing-emitter.js'
180
192
  import { type DraftStreamHandle } from '../draft-stream.js'
181
193
  import { handlePtyPartialPure, type PtyHandlerState } from '../pty-partial-handler.js'
182
194
  import { handleStreamReply } from '../stream-reply-handler.js'
@@ -186,10 +198,23 @@ import {
186
198
  createSwallowingRetryApiCall,
187
199
  retryWithThreadFallback,
188
200
  isPhotoDimensionRejectError,
201
+ isFloodWaitActiveError,
189
202
  } from '../retry-api-call.js'
203
+ import { createSendGate, sendGateEnabledFromEnv } from '../send-gate.js'
204
+ import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
190
205
  import { classifyPhotoFile, rerouteResultSuffix } from '../photo-precheck.js'
191
206
  import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
192
- import { floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
207
+ import {
208
+ floodStatePath,
209
+ floodWindowsPath,
210
+ makeFloodWaitRecorder,
211
+ makeFloodWaitProbe,
212
+ makeFloodWindowRecorder,
213
+ loadInitialFloodWindows,
214
+ readFloodWindows,
215
+ markFloodWindowAlerted,
216
+ suppressNonEssentialSendMs,
217
+ } from '../flood-circuit-breaker.js'
193
218
  import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
194
219
  import { logStreamingEvent } from '../streaming-metrics.js'
195
220
  import * as signalTracker from '../turn-signal-tracker.js'
@@ -253,6 +278,14 @@ import {
253
278
  cancelAccountAuthSession,
254
279
  cleanScratchDir as cleanAuthAddScratchDir,
255
280
  } from './auth-add-flow.js'
281
+ import {
282
+ pendingLoopbackFlows,
283
+ startLoopbackFlow,
284
+ submitLoopbackRedirect,
285
+ cancelLoopbackFlow,
286
+ shouldConsumeLoopbackPaste,
287
+ trackFlowExit,
288
+ } from './auth-loopback-relay.js'
256
289
  import {
257
290
  initHistory, recordInbound, recordOutbound, recordEdit,
258
291
  deleteFromHistory, query as queryHistory, getLatestInboundMessageId,
@@ -308,10 +341,13 @@ import {
308
341
  } from '../telegram-button-constraints.js'
309
342
  import {
310
343
  wrapAgentCallbacks,
344
+ redactAgentKeyboard,
311
345
  parseAgentCallback,
312
346
  extractAgentButtonMeta,
313
347
  keyboardIsSingleUse,
314
348
  finalizeCallback,
349
+ resolveTapAnnotation,
350
+ applyTapAnnotationEdit,
315
351
  type AgentButtonMeta,
316
352
  } from '../inline-keyboard-callbacks.js'
317
353
  import {
@@ -340,6 +376,8 @@ import {
340
376
  decideTurnFlush,
341
377
  isTurnFlushSafetyEnabled,
342
378
  } from '../turn-flush-safety.js'
379
+ // #1667 — pure decision core for the turn_end answer-delivery gate (#1664).
380
+ import { decideTurnEndGate } from './turn-end-gate.js'
343
381
  // #1122 PR3: turn-flush-prose-recovery removed with the progress card.
344
382
  import { resolveAgentDirFromEnv } from '../agent-dir.js'
345
383
  import {
@@ -408,6 +446,7 @@ import {
408
446
  readSessionModelFileRaw,
409
447
  restoreSessionModelFileRaw,
410
448
  clearSessionModelFile,
449
+ clearSessionModelBootAttempts,
411
450
  readConfiguredDefaultModel,
412
451
  writeRelaunchModelIntent,
413
452
  clearRelaunchModelIntent,
@@ -453,7 +492,7 @@ import { resolveOutboundTopic as resolveOutboundTopicHelper, topicForRecipient,
453
492
  import { readTurnUsages } from '../../src/agents/perf.js'
454
493
  import { buildContextOccupancy, writeContextOccupancySnapshot } from './context-occupancy.js'
455
494
  import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
456
- import { decideIdleClear, idleDurationToMs, DEFAULT_IDLE_CLEAR_MS } from './idle-clear.js'
495
+ import { decideIdleClear, classifyIdleEvent, idleDurationToMs, DEFAULT_IDLE_CLEAR_MS } from './idle-clear.js'
457
496
  import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
458
497
  import {
459
498
  tryHostdDispatch,
@@ -463,12 +502,19 @@ import {
463
502
  pollHostdStatus,
464
503
  hostdGetStatusOnce,
465
504
  warnLegacySpawnIfHostdDisabled,
505
+ withOperatorAttestation,
466
506
  _resetHostdEnabledCache,
467
507
  } from './hostd-dispatch.js'
468
508
  import { formatUpdateStatusLine } from './update-status-line.js'
469
509
  import type { HostdRequest, HostdResponse } from '../../src/host-control/protocol.js'
470
510
  import type { AgentAudit } from '../welcome-text.js'
471
511
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
512
+ import {
513
+ createDmPinSweeper,
514
+ collectDmChatIdsFromStores,
515
+ unexpiredStoreRepinIds,
516
+ type DmPinSweeper,
517
+ } from './dm-pin-sweep.js'
472
518
  import { startWebhookIngestServer } from './webhook-ingest-server.js'
473
519
  import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
474
520
 
@@ -495,6 +541,7 @@ import {
495
541
  type TrackedStatusPin,
496
542
  } from './status-pin-store.js'
497
543
  import {
544
+ loadActivityCards,
498
545
  writeActivityCardRecord,
499
546
  clearActivityCardRecord,
500
547
  runActivityCardBootReaper,
@@ -502,6 +549,13 @@ import {
502
549
  restartOrphanCardFinalizeText,
503
550
  type ActivityCardStoreFsSeam,
504
551
  } from './activity-card-store.js'
552
+ import {
553
+ loadQueuedCards,
554
+ writeQueuedCardRecord,
555
+ clearQueuedCardRecord,
556
+ runQueuedCardBootReaper,
557
+ type QueuedCardStoreFsSeam,
558
+ } from './queued-card-store.js'
505
559
  import {
506
560
  decideWorkerPinReaps,
507
561
  WORKER_PIN_TTL_MS_DEFAULT,
@@ -511,6 +565,7 @@ import { shouldSuppressRepresent } from './represent-guard.js'
511
565
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
512
566
  import { createInboundSpool } from './inbound-spool.js'
513
567
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
568
+ import { withTurnEndGateBackstop } from './turn-end-gate-backstop.js'
514
569
  import { decideInboundDelivery, reserveInboundDelivery } from './inbound-delivery-gate.js'
515
570
  import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
516
571
  import { decideFeedReopen } from './feed-reopen-gate.js'
@@ -589,6 +644,7 @@ import type {
589
644
  SendOutboundMessage,
590
645
  QuotaWallDetectedMessage,
591
646
  QueryPendingPermissionMessage,
647
+ CheckPreApprovedMessage,
592
648
  PostSkillProposalMessage,
593
649
  PermissionEvent,
594
650
  RolloutStatusPostMessage,
@@ -637,8 +693,8 @@ import {
637
693
  startSubagentWatcher,
638
694
  type SubagentWatcherHandle,
639
695
  } from '../subagent-watcher.js'
640
- import { listRecords as listWorktreeRecords } from '../../src/worktree/registry.js'
641
- import { ownedWorktreeCwds } from '../worktree-watch-cwds.js'
696
+ import { listRecords as listWorktreeRecords, touchHeartbeat as touchWorktreeHeartbeat } from '../../src/worktree/registry.js'
697
+ import { makeWorktreeWatchProvider } from '../worktree-watch-cwds.js'
642
698
  import {
643
699
  startBootCard,
644
700
  resolvePersonaName,
@@ -664,6 +720,7 @@ import {
664
720
  import { createScopedGrantStore } from './scoped-grant-store.js'
665
721
  import { grantRestartDecision, type GrantRestartDecision } from './grant-restart.js'
666
722
  import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
723
+ import { isDiffPreApproved } from './pre-approval-check.js'
667
724
  import {
668
725
  readClaudeJsonOverage,
669
726
  evaluateCreditState,
@@ -790,6 +847,69 @@ process.on('beforeExit', () => {
790
847
  // ─── Env + state dir ──────────────────────────────────────────────────────
791
848
  const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
792
849
  const permCardStore = createPermissionCardStore(STATE_DIR)
850
+ // #3084 follow-up — the blocked-approval surface. A SHARED top-level directory
851
+ // (compose binds host `~/.switchroom/blocked-approvals` here), not per-agent
852
+ // state: switchroom-web reads every agent's record from one place, and the
853
+ // records are 0644 so its uid-1000 process can actually read them. The agent's
854
+ // own telegram state is 0600 and is NOT web-readable.
855
+ // If the mount is missing (a container predating the volume), the write lands
856
+ // inside the container and the hold still works — only the off-Telegram
857
+ // SURFACE is lost. The record is best-effort by construction; it must never be
858
+ // able to fail the hold back into an auto-deny.
859
+ const BLOCKED_APPROVALS_DIR =
860
+ process.env.SWITCHROOM_BLOCKED_APPROVALS_DIR ?? '/state/blocked-approvals'
861
+ // The canonical "which agent am I" identity (profiles/_base/start.sh.hbs:430).
862
+ const AGENT_NAME = process.env.SWITCHROOM_AGENT_NAME ?? 'agent'
863
+ const blockedApprovalStore = createBlockedApprovalStore(
864
+ BLOCKED_APPROVALS_DIR,
865
+ AGENT_NAME,
866
+ // Fallback: the agent's OWN state dir, which the scaffold chowns to the agent
867
+ // uid, so a write there always succeeds. Guarantees the record can never be
868
+ // silently lost when the shared dir isn't writable by this agent's uid.
869
+ process.env.SWITCHROOM_AGENT_STATE_DIR ?? '/state/agent',
870
+ )
871
+
872
+ /**
873
+ * Rewrite the blocked-approval surface from the LIVE pending state.
874
+ *
875
+ * A reconcile, not a delete — deliberately. The store holds one record per
876
+ * agent, but an agent can hold SEVERAL permissions at once: Claude Code issues
877
+ * parallel tool calls, each hitting `canUseTool`, and
878
+ * `cancelPendingPermissionsForHalt` iterates multiple pendings. A naive
879
+ * `clear()` on any single resolution would delete the file while ANOTHER
880
+ * request was still held, blinding the operator to a real, live block.
881
+ *
882
+ * So derive the record from whatever is still held. Oldest block wins — it has
883
+ * been waiting longest. Nothing held → clear.
884
+ *
885
+ * Also called at BOOT. `pendingPermissions` is in-memory and empty on a fresh
886
+ * process, so this clears any record orphaned by a restart mid-hold — otherwise
887
+ * the dashboard would show a permanently blocked agent forever. Nothing can
888
+ * legitimately be held across a restart: the bridge re-sends unresolved requests
889
+ * on IPC reconnect (`bridge/permission-ledger.ts`), which re-raises them and
890
+ * re-marks them if the channel is still shut.
891
+ */
892
+ function reconcileBlockedApprovals(): void {
893
+ // Selection (oldest hold wins; null ⇒ nothing held) is the pure, unit-tested
894
+ // `selectOldestHeld` in approval-hold.ts — keep policy out of gateway.ts.
895
+ const oldest = selectOldestHeld(pendingPermissions)
896
+ if (oldest == null) {
897
+ blockedApprovalStore.clear()
898
+ return
899
+ }
900
+ blockedApprovalStore.write({
901
+ agent: AGENT_NAME,
902
+ requestId: oldest.requestId,
903
+ toolName: oldest.pend.tool_name,
904
+ // NOT bare naturalAction(): for Bash/Glob/Grep/WebFetch/mcp__* it
905
+ // interpolates the RAW tool input, and this file is world-readable.
906
+ action: safeActionForRecord(naturalAction, oldest.pend.tool_name, oldest.pend.input_preview),
907
+ blockedSince: oldest.pend.startedAt,
908
+ undeliverableSince: oldest.mark.since,
909
+ retryableAt: oldest.mark.retryableAt,
910
+ reason: oldest.mark.reason,
911
+ })
912
+ }
793
913
  // Durable store for the four AGENT-INITIATED approval-card families
794
914
  // (vault_request_access / vault_request_save / request_secret /
795
915
  // mental_model_propose). Persists card METADATA only — never any secret value
@@ -1347,6 +1467,17 @@ type Access = {
1347
1467
  short_name?: string
1348
1468
  author_name?: string
1349
1469
  }
1470
+ /** Auto-confirm "✅ You chose: X" annotation on inline_keyboard taps
1471
+ * (#789). When enabled, tapping an agent-emitted single-use button
1472
+ * annotates the source message body so the chat surface is
1473
+ * self-documenting. Off by default; only honored when parseMode is the
1474
+ * default 'html'. Projected from
1475
+ * channels.telegram.button_choice_confirmation by scaffold. */
1476
+ button_choice_confirmation?: {
1477
+ enabled?: boolean
1478
+ format?: string
1479
+ timezone?: 'gateway' | 'utc'
1480
+ }
1350
1481
  }
1351
1482
 
1352
1483
  function defaultAccess(): Access {
@@ -1431,6 +1562,8 @@ function readAccessFile(): Access {
1431
1562
  voice_in: parsed.voice_in,
1432
1563
  voice_out: parsed.voice_out,
1433
1564
  telegraph: parsed.telegraph,
1565
+ // #789: button-choice-confirmation config projected by scaffold.
1566
+ button_choice_confirmation: parsed.button_choice_confirmation,
1434
1567
  }
1435
1568
  } catch (err) {
1436
1569
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
@@ -2586,6 +2719,17 @@ function turnInFlightForGate(): boolean {
2586
2719
  // Keeping the gate closed for as long as there is an outstanding approval
2587
2720
  // card prevents that race. The gate reopens when the card is tapped or times
2588
2721
  // out (pendingPermissions.delete in finalizeCallback / TTL sweep).
2722
+ //
2723
+ // #3084 follow-up — ONE case now has no timeout valve: an approval HELD
2724
+ // because its card could not be DELIVERED (a Telegram flood ban) never
2725
+ // expires, by design. The leash may not fabricate a verdict for an ask no
2726
+ // human ever saw, so the entry — and therefore this gate — persists until a
2727
+ // human answers. An agent can consequently buffer normal inbound for the
2728
+ // duration of a channel outage. That is the intended trade: a buffered
2729
+ // message is recoverable, a silently auto-denied approval is not. The escape
2730
+ // hatches are unaffected — /allow, /deny and /pending are grammy command
2731
+ // handlers and do not sit behind this gate — and the block is surfaced
2732
+ // off-Telegram in blocked-approvals/<agent>.json.
2589
2733
  const hasPendingApproval = pendingPermissions.size > 0
2590
2734
  if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0 || hasPendingApproval
2591
2735
  // Machine is authoritative. Run the log-only drift canary (#2794): the
@@ -3659,6 +3803,9 @@ function postQueuedStatus(chatId: string, bufferedThread: number, inFlightThread
3659
3803
  return
3660
3804
  }
3661
3805
  queuedStatusMsgIds.set(key, { chatId, threadId: bufferedThread, messageId })
3806
+ // #3002: write through to the durable store so a gateway restart before the
3807
+ // in-process reap can still delete this orphaned card on boot.
3808
+ persistQueuedCard(key, chatId, bufferedThread, messageId)
3662
3809
  })()
3663
3810
  }
3664
3811
 
@@ -3695,6 +3842,9 @@ function reapQueuedStatus(chatId: string, thread: number | undefined): void {
3695
3842
  const entry = queuedStatusMsgIds.get(key)
3696
3843
  if (entry == null) return
3697
3844
  queuedStatusMsgIds.delete(key)
3845
+ // #3002: drop the durable row too, scoped to this exact message id (reap-race
3846
+ // guard) so a fresh live card under the same key keeps its own protection.
3847
+ clearQueuedCard(key, entry.messageId)
3698
3848
  void swallowingApiCall(
3699
3849
  () => bot.api.deleteMessage(chatId, entry.messageId),
3700
3850
  { chat_id: chatId, verb: 'queued-status.reap', ...(entry.threadId != null ? { threadId: entry.threadId } : {}) },
@@ -3807,6 +3957,8 @@ function postBusyAck(chatId: string, threadId: number | undefined, text: string)
3807
3957
  return
3808
3958
  }
3809
3959
  queuedStatusMsgIds.set(key, { chatId, threadId: threadId ?? null, messageId })
3960
+ // #3002: write through to the durable store (see postQueuedStatus).
3961
+ persistQueuedCard(key, chatId, threadId ?? null, messageId)
3810
3962
  })()
3811
3963
  }
3812
3964
 
@@ -3981,6 +4133,7 @@ function cancelPendingPermissionsForHalt(origin: string): void {
3981
4133
  void stripCancelledPermissionCards(details.card_text, details.cards)
3982
4134
  pendingPermissions.delete(requestId)
3983
4135
  permCardStore.remove(requestId)
4136
+ reconcileBlockedApprovals()
3984
4137
  process.stderr.write(
3985
4138
  `telegram gateway: halt-now cancelled pending permission origin=${origin} ` +
3986
4139
  `request=${requestId} tool=${details.tool_name}\n`,
@@ -4805,10 +4958,18 @@ function maybeProactiveCompact(): void {
4805
4958
  // ─── Idle auto-clear ──────────────────────────────────────────────────────
4806
4959
  // Wall-clock idle → /clear (idle-clear.ts). Independent of proactive-compact
4807
4960
  // (occupancy-driven at the turn-end gate): a fully-idle agent never ends a
4808
- // turn, so this runs on its own interval. Any activity (inbound / turn start /
4809
- // cron fire) resets the timer via markIdleActivity(); fires once per idle
4810
- // period; never mid-turn (turnInFlightForGate, the same gate compaction uses).
4961
+ // turn, so this runs on its own interval. "Idle" means NOTHING HAS HAPPENED
4962
+ // since the last thing happened inbound, cron fire, or ANY claude session
4963
+ // event (turn start, tool call, tool result, text, sub-agent event, turn end)
4964
+ // resets the timer via markIdleActivity(); a turn ending additionally stamps
4965
+ // markIdleTurnEnd(). Fires once per idle period; never mid-turn
4966
+ // (turnInFlightForGate, the same gate compaction uses).
4967
+ //
4968
+ // It is emphatically NOT "no turn has *started* recently" — that reading wiped
4969
+ // overlord's 3h of working context on 2026-07-11 for the crime of being busy.
4970
+ // See the idle-clear.ts header.
4811
4971
  let lastIdleActivityAt = Date.now();
4972
+ let lastIdleTurnEndAt: number | null = null;
4812
4973
  let idleAutoCleared = false;
4813
4974
  let idleClearDispatching = false;
4814
4975
 
@@ -4818,6 +4979,15 @@ function markIdleActivity(): void {
4818
4979
  idleAutoCleared = false;
4819
4980
  }
4820
4981
 
4982
+ /**
4983
+ * Stamp "a turn just ended". The idle window is measured from
4984
+ * max(lastActivityAt, lastTurnEndedAt), so a turn that ran LONGER than the
4985
+ * window can't be cleared on the first tick after `turnInFlight` goes false.
4986
+ */
4987
+ function markIdleTurnEnd(): void {
4988
+ lastIdleTurnEndAt = Date.now();
4989
+ }
4990
+
4821
4991
  /** Idle window in ms: env override → per-agent config → 3h default. 0 disables. */
4822
4992
  function resolveIdleClearMs(): number {
4823
4993
  const env = process.env.SWITCHROOM_IDLE_CLEAR_MS;
@@ -4849,6 +5019,7 @@ function maybeIdleClear(): void {
4849
5019
  const decision = decideIdleClear(
4850
5020
  {
4851
5021
  lastActivityAt: lastIdleActivityAt,
5022
+ lastTurnEndedAt: lastIdleTurnEndAt,
4852
5023
  idleClearMs,
4853
5024
  alreadyCleared: idleAutoCleared,
4854
5025
  turnInFlight: turnInFlightForGate(),
@@ -5275,6 +5446,241 @@ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp'])
5275
5446
  // The length/newline text splitter moved to outbound-send-path.ts (#2996) as
5276
5447
  // `chunkText`; the sole gateway caller now goes through `computeReplyChunks`.
5277
5448
 
5449
+ // ─── Robust API call wrapper ──────────────────────────────────────────────
5450
+ // Extracted to telegram-plugin/retry-api-call.ts so it's unit-testable in
5451
+ // isolation; the gateway just composes the pure policy with its own logger.
5452
+ // #2923: the shared flood-wait marker. Every observed 429 retry_after window
5453
+ // is persisted here via onFloodWait, and both boot-card callsites consult the
5454
+ // SAME file to suppress a restart card while a per-bot flood ban is open (so a
5455
+ // restart doesn't post into the window and extend the ban). Falls back to a
5456
+ // no-op recorder when TELEGRAM_STATE_DIR is unset (dev/one-shot contexts).
5457
+ // STATE_DIR always resolves (env or a ~/.claude fallback), so this is live.
5458
+ // Declared ABOVE the typing indicator because the typing sends now route
5459
+ // through the same policy (#3084) — see nonEssentialApiCall below.
5460
+ const FLOOD_STATE_PATH = floodStatePath(STATE_DIR)
5461
+ // #3084 PR 2/3 — sibling file for SCOPED flood windows (global / chat / group /
5462
+ // msg-edit). Kept separate from the single-object flood-wait.json so #3094's
5463
+ // makeFloodWaitProbe schema is untouched (part3-design §7).
5464
+ const FLOOD_WINDOWS_PATH = floodWindowsPath(STATE_DIR)
5465
+ const recordFloodWindow = makeFloodWindowRecorder(FLOOD_WINDOWS_PATH)
5466
+
5467
+ // #3084 PR 2/3 — deterministic outbound send gate (token buckets + per-message
5468
+ // edit floor + no-op-edit skip + PRIORITY SHEDDING + DEGRADED MODE). Wrapped
5469
+ // HERE at the robustApiCall layer so every Bot API call routed through the
5470
+ // standard retry policy also transits one scheduler (no call site can bypass
5471
+ // it). ON BY DEFAULT (escape hatch): only when SWITCHROOM_TELEGRAM_SEND_GATE is
5472
+ // explicitly set to 0/false/off/no is the gate a pure passthrough and the retry
5473
+ // policy behaves exactly as before. Composes with #3094's pre-call flood gate
5474
+ // and #3097's non-essential
5475
+ // drop policy — those decide whether a call happens at all; this paces the
5476
+ // calls that do.
5477
+ //
5478
+ // §7 restart-proof flood state: initialWindows are loaded from disk BEFORE the
5479
+ // gate (and thus before ANY outbound call, boot cards included), so a boot mid-
5480
+ // ban never resends into an open window. onWindowOpen write-throughs every
5481
+ // runtime-opened window to FLOOD_WINDOWS_PATH; bootRamp starts the global
5482
+ // bucket at half capacity for 10s to absorb the boot-card burst.
5483
+ const sendGate = createSendGate({
5484
+ enabled: sendGateEnabledFromEnv(),
5485
+ initialWindows: loadInitialFloodWindows(FLOOD_STATE_PATH, FLOOD_WINDOWS_PATH, Date.now()),
5486
+ bootRamp: {},
5487
+ onWindowOpen: (scopeKey, untilTs) => recordFloodWindow(scopeKey, untilTs),
5488
+ })
5489
+
5490
+ // Hoisted so the held-card re-delivery sweep (#3084 follow-up) asks the SAME
5491
+ // probe `robustApiCall` does, off the same on-disk window. The sweep's check and
5492
+ // the retry policy's own pre-call short-circuit are then two independent reads
5493
+ // of one source of truth, not two notions of "is the channel open".
5494
+ const probeFloodWaitRemainingMs = makeFloodWaitProbe(FLOOD_STATE_PATH)
5495
+ const rawRobustApiCall = createRetryApiCall({
5496
+ log: (line) => process.stderr.write(line),
5497
+ onFloodWait: (retryAfterSec) => {
5498
+ // #2923/#3094 — persist the single-object global window (probe reads this).
5499
+ makeFloodWaitRecorder(FLOOD_STATE_PATH)(retryAfterSec)
5500
+ // #3084 PR 2 — also open a GLOBAL send-gate window so cosmetic traffic sheds
5501
+ // for the ban's duration even on a SHORT (slept-and-retried) 429 that never
5502
+ // throws FLOOD_WAIT_ACTIVE. Scope-precise windows are opened by the gate's
5503
+ // own FLOOD_WAIT_ACTIVE catch (which has the call's opts).
5504
+ try {
5505
+ sendGate.openFloodWindow('global', Date.now() + Math.max(0, retryAfterSec) * 1000)
5506
+ } catch {
5507
+ /* best-effort — never let the window hook break the retry path */
5508
+ }
5509
+ },
5510
+ // #3094: while a LONG per-bot ban is open, don't issue the call at all.
5511
+ // retryApiCall no longer sleeps a multi-hour retry_after (it throws
5512
+ // FLOOD_WAIT_ACTIVE instead), and the card surfaces re-drive on a 5-6s
5513
+ // heartbeat — without this gate that turns the fix into an amplifier that
5514
+ // fires thousands of requests into the open window and extends the ban.
5515
+ floodWaitRemainingMs: probeFloodWaitRemainingMs,
5516
+ })
5517
+
5518
+ const robustApiCall = <T>(
5519
+ fn: () => Promise<T>,
5520
+ opts?: Parameters<typeof rawRobustApiCall<T>>[1],
5521
+ ): Promise<T> => sendGate.gate(() => rawRobustApiCall(fn, opts), opts)
5522
+
5523
+ // Fire-and-forget wrapper for outbound surfaces that previously had
5524
+ // `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
5525
+ // (instead of crashing the gateway) on THREAD_NOT_FOUND, give-up, 403,
5526
+ // and any non-benign error — logs a one-liner so the failure isn't
5527
+ // completely silent. See #1075.
5528
+ const swallowingApiCall = createSwallowingRetryApiCall(
5529
+ robustApiCall,
5530
+ (line) => process.stderr.write(line),
5531
+ )
5532
+
5533
+ /**
5534
+ * The ONE seam every `setMessageReaction` in this gateway goes through (#3155).
5535
+ *
5536
+ * Reactions are cosmetic outbound traffic. Before this seam, ~13 raw
5537
+ * `bot.api.setMessageReaction(...)` / `lockedBot.api.setMessageReaction(...)`
5538
+ * call sites fired reactions OUTSIDE the send gate AND the flood circuit
5539
+ * breaker, so:
5540
+ *
5541
+ * (a) reaction spam was never PACED/SHED by the gate when it's on — a busy
5542
+ * turn's status-reaction churn added straight onto the per-bot flood
5543
+ * budget the REPLIES need; and
5544
+ * (b) a 429 from a reaction was INVISIBLE to the breaker — the flood window
5545
+ * is only learned through the retry module's `onFloodWait` hook, which a
5546
+ * raw `bot.api` call never reaches. Reactions were a residual flood
5547
+ * vector even with the gate ON.
5548
+ *
5549
+ * Routing through `robustApiCall` (= chat-lock → send-gate → retry/breaker,
5550
+ * the SAME path every other outbound call takes) with
5551
+ * `priorityClass: 'cosmetic'` fixes both: the gate paces/sheds reactions under
5552
+ * pressure, and a 429 runs `onFloodWait` so the window is recorded. Callers
5553
+ * keep their own fire-and-forget / `.catch(() => {})` / `await` semantics —
5554
+ * this returns the promise and does NOT swallow.
5555
+ */
5556
+ const gatedSetMessageReaction = (
5557
+ chatId: string,
5558
+ messageId: number,
5559
+ reaction: ReactionTypeEmoji[],
5560
+ ): Promise<unknown> =>
5561
+ robustApiCall(() => lockedBot.api.setMessageReaction(chatId, messageId, reaction), {
5562
+ chat_id: chatId,
5563
+ verb: 'set-message-reaction',
5564
+ priorityClass: 'cosmetic',
5565
+ })
5566
+
5567
+ /** React with a single emoji through the gate (cosmetic). Wraps {@link gatedSetMessageReaction}. */
5568
+ const sendReaction = (
5569
+ chatId: string,
5570
+ messageId: number,
5571
+ emoji: ReactionTypeEmoji['emoji'],
5572
+ ): Promise<unknown> => gatedSetMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }])
5573
+
5574
+ /**
5575
+ * Bot-API surface handed to `redactAuthCodeMessage` (#488) so its 🔑 reaction
5576
+ * routes through the send gate + flood breaker like every other reaction
5577
+ * (#3155). The OAuth-code DELETE stays raw/best-effort exactly as before — its
5578
+ * own `.then(onOk, onErr)` logging (the "token may still be visible" breadcrumb)
5579
+ * must keep firing, which a swallowing wrapper would suppress.
5580
+ */
5581
+ const redactAuthCodeApi = {
5582
+ deleteMessage: (chatId: string, messageId: number) =>
5583
+ // allow-raw-bot-api: auth-code redact delete stays raw/best-effort (behavior unchanged, #488); #3155 gates only the reaction.
5584
+ bot.api.deleteMessage(chatId, messageId),
5585
+ setMessageReaction: (
5586
+ chatId: string,
5587
+ messageId: number,
5588
+ reaction: Array<{ type: 'emoji'; emoji: string }>,
5589
+ ) => gatedSetMessageReaction(chatId, messageId, reaction as ReactionTypeEmoji[]),
5590
+ }
5591
+
5592
+ /**
5593
+ * The wrapper for NON-ESSENTIAL sends that must NEVER retry (#3084).
5594
+ *
5595
+ * A typing indicator is disposable: if it fails, the correct answer is to drop
5596
+ * it, because a retry spends more of the per-bot flood budget the REPLIES need
5597
+ * — retrying is what feeds a ban. But it must not be SILENT either: the typing
5598
+ * loop was the single largest emitter (55% of outbound volume) and it called
5599
+ * `bot.api` raw with `.catch(() => {})`, so the #2923 flood circuit breaker —
5600
+ * which only learns about 429s through `createRetryApiCall`'s `onFloodWait`
5601
+ * hook — was blind to 429s from the biggest source of them.
5602
+ *
5603
+ * `maxRetries: 1` + a no-op sleep gives exactly one attempt at the API, whatever
5604
+ * the outcome: a 429 still runs `onFloodWait` (recording the window to the
5605
+ * breaker), and then the call ends — a SHORT ban falls out of the loop as
5606
+ * `max retries exceeded`, a LONG one throws #3094's `FLOOD_WAIT_ACTIVE`. Either
5607
+ * way the ping is dropped, never slept and never retried. That is the whole
5608
+ * point: a retried non-essential send is what feeds a ban.
5609
+ *
5610
+ * Composes cleanly on top of #3094 (`bbe14471`): that PR bounded the in-process
5611
+ * flood sleep and added a pre-call gate for LONG open windows. This wrapper
5612
+ * pre-dates neither mechanism nor fights them — it sits under the emitter's own
5613
+ * flood gate, which short-circuits earlier still (a typing ping during a ban
5614
+ * never even reaches the retry layer). Defense in depth, in that order.
5615
+ */
5616
+ // No `log` hook: retryApiCall's flood line reads "waiting Ns", which would be a
5617
+ // lie here — we never wait, we drop. And the error the caller finally sees names
5618
+ // neither the code nor the retry_after (retryApiCall replaces the original on
5619
+ // give-up) — and retry_after is the single number an operator needs during a
5620
+ // ban. So the honest 429 line is written HERE, in the onFloodWait hook, where
5621
+ // the real value is still in hand.
5622
+ const recordTypingFloodWait = makeFloodWaitRecorder(FLOOD_STATE_PATH)
5623
+ const nonEssentialApiCall = createRetryApiCall({
5624
+ maxRetries: 1,
5625
+ sleep: async () => {},
5626
+ onFloodWait: (retryAfterSec) => {
5627
+ recordTypingFloodWait(retryAfterSec)
5628
+ process.stderr.write(
5629
+ `telegram gateway: 429 flood-wait on a NON-ESSENTIAL send (retry_after=${retryAfterSec}s) — ` +
5630
+ `recorded to the flood breaker; the send is DROPPED, not retried (#3084)\n`,
5631
+ )
5632
+ },
5633
+ })
5634
+
5635
+ // #3084 PR 3/3 — observability + operator flood alerts (part3-design §6).
5636
+ // A low-frequency, change-gated stats line + flood-window open/close snapshots
5637
+ // to the gateway-supervisor log, and ONE operator alert per prolonged flood
5638
+ // window. All feature-flagged: when the send gate is OFF, `stats().enabled` is
5639
+ // false and both `tick()`s are pure no-ops (the interval is not even scheduled).
5640
+ const SEND_GATE_OBSERVE_INTERVAL_MS = 15_000
5641
+ const sendGateStatsLogger = createStatsLogger({
5642
+ stats: () => sendGate.stats(),
5643
+ log: (line) => process.stderr.write(line),
5644
+ clock: { now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)) },
5645
+ })
5646
+ const floodWindowObserver = createFloodWindowObserver({
5647
+ clock: { now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)) },
5648
+ log: (line) => process.stderr.write(line),
5649
+ stats: () => sendGate.stats(),
5650
+ readWindows: (now) => readFloodWindows(FLOOD_WINDOWS_PATH, now),
5651
+ markAlerted: (scopeKey, alertedAt) =>
5652
+ markFloodWindowAlerted(FLOOD_WINDOWS_PATH, scopeKey, alertedAt, Date.now()),
5653
+ operatorChatId: () => loadAccess().allowFrom[0], // resolved per-tick (allowFrom can change)
5654
+ sendAlert: async (text) => {
5655
+ const operator = loadAccess().allowFrom[0]
5656
+ if (operator === undefined) {
5657
+ process.stderr.write(
5658
+ `telegram gateway: send-gate flood alert not sent — no operator chat (allowFrom empty)\n`,
5659
+ )
5660
+ return
5661
+ }
5662
+ await robustApiCall(
5663
+ // allow-raw-bot-api: operator flood alert, routed through robustApiCall.
5664
+ () => bot.api.sendRichMessage(operator, richMessage(text), {}),
5665
+ { chat_id: String(operator), verb: 'send-gate-flood-alert', priorityClass: 'critical' },
5666
+ )
5667
+ },
5668
+ })
5669
+ if (sendGateEnabledFromEnv()) {
5670
+ const observeTimer = setInterval(() => {
5671
+ try {
5672
+ sendGateStatsLogger.tick()
5673
+ } catch {
5674
+ /* observability must never crash the gateway */
5675
+ }
5676
+ void floodWindowObserver.tick().catch(() => {
5677
+ /* best-effort — a failed observer tick must not surface */
5678
+ })
5679
+ }, SEND_GATE_OBSERVE_INTERVAL_MS)
5680
+ // Don't keep the event loop alive for observability alone.
5681
+ observeTimer.unref?.()
5682
+ }
5683
+
5278
5684
  // ─── Typing indicator ─────────────────────────────────────────────────────
5279
5685
  // All four state maps re-keyed from `chat_id` to `chatKey(chat, thread)`
5280
5686
  // in PR3 of the supergroup-mode rollout. In supergroup mode one agent
@@ -5311,33 +5717,94 @@ const CHAT_ACTION_WHITELIST = new Set([
5311
5717
  ] as const)
5312
5718
  type ChatAction = typeof CHAT_ACTION_WHITELIST extends Set<infer T> ? T : never
5313
5719
 
5314
- function startTypingLoop(
5315
- chat_id: string,
5316
- thread_id: number | null = null,
5317
- action: ChatAction = 'typing',
5318
- ): void {
5319
- stopTypingLoop(chat_id, thread_id)
5320
- const key = chatKey(chat_id, thread_id) as string
5321
- const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
5322
- const send = () => {
5323
- bot.api.sendChatAction(chat_id, action, sendOpts).then(
5720
+ /**
5721
+ * The ONE seam every chat action in this gateway goes through (#3084).
5722
+ *
5723
+ * Both typing loops (tool-use `typingIntervals` below and the turn-level
5724
+ * `turnTypingLoop`) and the one-shot inbound pings share this emitter, so the
5725
+ * per-chat-key floor holds ACROSS them: at most one `sendChatAction` per chat
5726
+ * key per ~4 s window, no matter how many times a loop is restarted. That is
5727
+ * what decouples the typing rate from the agent's TOOL-CALL rate — the
5728
+ * regression that earned a 4.6-hour flood ban. A cold start (no ping inside
5729
+ * the floor) still fires instantly, so "typing…" lands the moment a turn
5730
+ * begins; only redundant restarts are dropped — and a dropped tick arms a
5731
+ * coalesced catch-up, so the indicator never goes dark for longer than the
5732
+ * floor. While a flood window is open, typing — non-essential by definition —
5733
+ * is not emitted at all.
5734
+ *
5735
+ * (`sendChatAction` is outside `check-bot-api-wrapping`'s pattern by design —
5736
+ * chat actions take no `message_thread_id`, so they were never in the
5737
+ * THREAD_NOT_FOUND blast radius the guard polices. It's routed through the
5738
+ * retry module anyway, because that is where the flood breaker's `onFloodWait`
5739
+ * hook lives and typing is the largest 429 source there is.)
5740
+ */
5741
+ const typingEmitter = createTypingEmitter({
5742
+ chatKey: (chat_id, thread_id) => chatKey(chat_id, thread_id) as string,
5743
+ isSuppressed: () => suppressNonEssentialSendMs(FLOOD_STATE_PATH, Date.now()) > 0,
5744
+ send: (chat_id, thread_id, action) => {
5745
+ const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
5746
+ void nonEssentialApiCall(
5747
+ () => bot.api.sendChatAction(chat_id, action as ChatAction, sendOpts),
5748
+ { chat_id, verb: 'sendChatAction' },
5749
+ ).then(
5324
5750
  () => { typingBackoffMs = 0 },
5325
5751
  (err) => {
5326
5752
  const msg = err instanceof Error ? err.message : String(err)
5327
5753
  if (msg.includes('401') || msg.includes('Unauthorized')) {
5754
+ const key = chatKey(chat_id, thread_id) as string
5328
5755
  typingBackoffMs = Math.min(Math.max(typingBackoffMs * 2 || 1000, 1000), TYPING_BACKOFF_MAX)
5329
5756
  stopTypingLoop(chat_id, thread_id)
5330
5757
  const retry = setTimeout(() => {
5331
5758
  typingRetryTimers.delete(key)
5332
- startTypingLoop(chat_id, thread_id, action)
5759
+ startTypingLoop(chat_id, thread_id, action as ChatAction)
5333
5760
  }, typingBackoffMs)
5334
5761
  typingRetryTimers.set(key, retry)
5762
+ return
5335
5763
  }
5764
+ // A flood-wait already logged its retry_after (and hit the breaker) in
5765
+ // onFloodWait above. What lands here is either retryApiCall's opaque
5766
+ // give-up error (short ban) or #3094's FLOOD_WAIT_ACTIVE marker (long
5767
+ // ban / pre-call gate) — neither names the retry_after, so don't
5768
+ // double-log a strictly less informative line. Both are DROPS: a
5769
+ // typing ping is never retried, and the marker is caught here (this is
5770
+ // an onRejected handler), so it can't surface as an unhandled rejection
5771
+ // out of a fire-and-forget ping.
5772
+ if (isFloodWaitActiveError(err) || msg.includes('max retries exceeded')) return
5773
+ // Everything else is DROPPED, never retried — but logged, so the
5774
+ // largest outbound emitter is no longer silent (#3084).
5775
+ process.stderr.write(
5776
+ `telegram gateway: sendChatAction dropped (non-essential, not retried): ${msg}\n`,
5777
+ )
5336
5778
  },
5337
5779
  )
5338
- }
5780
+ },
5781
+ })
5782
+
5783
+ /** Fire one chat action for (chat, thread), subject to the shared floor. */
5784
+ function emitChatAction(
5785
+ chat_id: string,
5786
+ thread_id: number | null = null,
5787
+ action: ChatAction = 'typing',
5788
+ ): void {
5789
+ typingEmitter.emit(chat_id, thread_id, action)
5790
+ }
5791
+
5792
+ function startTypingLoop(
5793
+ chat_id: string,
5794
+ thread_id: number | null = null,
5795
+ action: ChatAction = 'typing',
5796
+ ): void {
5797
+ stopTypingLoop(chat_id, thread_id)
5798
+ const key = chatKey(chat_id, thread_id) as string
5799
+ // The immediate fire is FLOOR-GATED (it was not, and that is #3084): the
5800
+ // tool-use wrapper restarts this loop on every tool call, so an unguarded
5801
+ // immediate send made the ping rate equal the tool-call rate and the 4 s
5802
+ // interval decorative. The emitter drops the restart's ping when this key
5803
+ // already pinged inside the window, and lets it straight through on a cold
5804
+ // start — so a turn still lights up "typing…" instantly.
5805
+ const send = () => emitChatAction(chat_id, thread_id, action)
5339
5806
  send()
5340
- typingIntervals.set(key, setInterval(send, 4000))
5807
+ typingIntervals.set(key, setInterval(send, TYPING_REFRESH_MS))
5341
5808
  }
5342
5809
 
5343
5810
  function stopTypingLoop(chat_id: string, thread_id: number | null = null): void {
@@ -5357,14 +5824,19 @@ function stopTypingLoop(chat_id: string, thread_id: number | null = null): void
5357
5824
  // kill it and the chat would go dark for the rest of the turn — the exact
5358
5825
  // black-box gap this closes. The dedicated map (private to the factory) makes
5359
5826
  // the turn loop structurally immune to those stops: only the canonical turn-end
5360
- // stop clears it. The redundant `typing` pings while a reply is mid-flight are
5361
- // harmless — same action, and sendChatAction is cheap.
5827
+ // stop clears it.
5828
+ //
5829
+ // The interval map stays separate — but the SENDS do not (#3084). This loop and
5830
+ // `typingIntervals` target the SAME chat key, and the old comment here claimed
5831
+ // the redundant pings were "harmless — same action, and sendChatAction is
5832
+ // cheap". They are not cheap: they spend the per-bot flood budget the replies
5833
+ // need, and two independently-restarting loops on one key is exactly how the
5834
+ // rate compounded. Both loops now emit through `typingEmitter`, so the
5835
+ // per-chat-key floor is SHARED and neither loop can out-shout the other.
5362
5836
  const turnTypingLoop = createTurnTypingLoop({
5363
- sendChatAction: (chat_id, thread_id) => {
5364
- const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
5365
- void bot.api.sendChatAction(chat_id, 'typing', sendOpts).catch(() => {})
5366
- },
5837
+ sendChatAction: (chat_id, thread_id) => emitChatAction(chat_id, thread_id, 'typing'),
5367
5838
  chatKey: (chat_id, thread_id) => chatKey(chat_id, thread_id) as string,
5839
+ refreshMs: TYPING_REFRESH_MS,
5368
5840
  })
5369
5841
 
5370
5842
  function startTurnTypingLoop(chat_id: string, thread_id: number | null = null): void {
@@ -5373,6 +5845,11 @@ function startTurnTypingLoop(chat_id: string, thread_id: number | null = null):
5373
5845
 
5374
5846
  function stopTurnTypingLoop(chat_id: string, thread_id: number | null = null): void {
5375
5847
  turnTypingLoop.stop(chat_id, thread_id)
5848
+ // Canonical turn-end: cancel any catch-up the floor armed, so a tick dropped
5849
+ // in the last seconds of the turn can't resurrect "typing…" after the reply
5850
+ // has landed. Deliberately NOT done in `stopTypingLoop` — the tool loop stops
5851
+ // on every tool result, which is exactly the churn the catch-up covers.
5852
+ typingEmitter.cancelPending(chat_id, thread_id)
5376
5853
  }
5377
5854
 
5378
5855
  const typingWrapper = createTypingWrapper({
@@ -5381,31 +5858,6 @@ const typingWrapper = createTypingWrapper({
5381
5858
  isSurfaceTool: isTelegramSurfaceTool,
5382
5859
  })
5383
5860
 
5384
- // ─── Robust API call wrapper ──────────────────────────────────────────────
5385
- // Extracted to telegram-plugin/retry-api-call.ts so it's unit-testable in
5386
- // isolation; the gateway just composes the pure policy with its own logger.
5387
- // #2923: the shared flood-wait marker. Every observed 429 retry_after window
5388
- // is persisted here via onFloodWait, and both boot-card callsites consult the
5389
- // SAME file to suppress a restart card while a per-bot flood ban is open (so a
5390
- // restart doesn't post into the window and extend the ban). Falls back to a
5391
- // no-op recorder when TELEGRAM_STATE_DIR is unset (dev/one-shot contexts).
5392
- // STATE_DIR always resolves (env or a ~/.claude fallback), so this is live.
5393
- const FLOOD_STATE_PATH = floodStatePath(STATE_DIR)
5394
- const robustApiCall = createRetryApiCall({
5395
- log: (line) => process.stderr.write(line),
5396
- onFloodWait: makeFloodWaitRecorder(FLOOD_STATE_PATH),
5397
- })
5398
-
5399
- // Fire-and-forget wrapper for outbound surfaces that previously had
5400
- // `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
5401
- // (instead of crashing the gateway) on THREAD_NOT_FOUND, give-up, 403,
5402
- // and any non-benign error — logs a one-liner so the failure isn't
5403
- // completely silent. See #1075.
5404
- const swallowingApiCall = createSwallowingRetryApiCall(
5405
- robustApiCall,
5406
- (line) => process.stderr.write(line),
5407
- )
5408
-
5409
5861
  /**
5410
5862
  * Adapter factory for `startBootCard`'s `BotApiForBootCard` interface.
5411
5863
  *
@@ -5440,7 +5892,8 @@ function wrapBootCardApi(
5440
5892
  richMessage(text),
5441
5893
  sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
5442
5894
  ),
5443
- opts(cid),
5895
+ // #3084 PR 2: boot/config card CREATION is USEFUL (queue with TTL).
5896
+ { ...opts(cid), priorityClass: 'useful' },
5444
5897
  )
5445
5898
  return sent as { message_id: number }
5446
5899
  },
@@ -5453,7 +5906,9 @@ function wrapBootCardApi(
5453
5906
  richMessage(text),
5454
5907
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
5455
5908
  ),
5456
- opts(cid),
5909
+ // A boot-card EDIT is COSMETIC — shed under pressure; pass
5910
+ // messageId/editPayload so the per-message floor + no-op skip engage.
5911
+ { ...opts(cid), priorityClass: 'cosmetic', messageId: mid, editPayload: text },
5457
5912
  ) as Promise<unknown>,
5458
5913
  // Strict edit for the boot-card edit-in-place probe: distinguishes
5459
5914
  // "message gone" (→ 'gone', caller sends fresh) from a landed/identical
@@ -5505,7 +5960,8 @@ function wrapIssuesCardApi(
5505
5960
  richMessage(text),
5506
5961
  sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
5507
5962
  ),
5508
- opts(cid),
5963
+ // #3084 PR 2: issues-card creation is USEFUL.
5964
+ { ...opts(cid), priorityClass: 'useful' },
5509
5965
  )
5510
5966
  return sent as { message_id: number }
5511
5967
  },
@@ -5518,7 +5974,8 @@ function wrapIssuesCardApi(
5518
5974
  richMessage(text),
5519
5975
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
5520
5976
  ),
5521
- opts(cid),
5977
+ // An issues-card EDIT is COSMETIC.
5978
+ { ...opts(cid), priorityClass: 'cosmetic', messageId: mid, editPayload: text },
5522
5979
  ) as Promise<unknown>,
5523
5980
  deleteMessage: (cid, mid) =>
5524
5981
  robustApiCall(() => lockedBot.api.deleteMessage(cid, mid), opts(cid)) as Promise<unknown>,
@@ -5552,7 +6009,11 @@ const STATUS_QUERY_RE = /^\s*status\??\s*$/i
5552
6009
 
5553
6010
  // ─── Permission handling ──────────────────────────────────────────────────
5554
6011
  const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
5555
- 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 }[] }>()
6012
+ // `undeliverable` (#3084 follow-up): set when the card send failed against a
6013
+ // known-open Telegram flood window. While it is set the entry is HELD — the TTL
6014
+ // sweep skips it (never auto-deny an ask no human ever saw) and the reaper
6015
+ // re-posts the card once the window closes. Cleared on successful delivery.
6016
+ 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 }[]; undeliverable?: UndeliverableMark | null; redeliveryFailures?: number }>()
5556
6017
  // PERMISSION_TTL_MS / ttlForTool / the timed-out card builder now live in
5557
6018
  // ./permission-timeout.ts (pure + unit-testable). hostd gated verbs get a
5558
6019
  // 30-min window; everything else keeps the 10-min default.
@@ -5770,6 +6231,23 @@ function sweepStaleMentalModelCorrelations(now = Date.now()): void {
5770
6231
  pendingMentalModelCorrelations.sweep(now)
5771
6232
  }
5772
6233
 
6234
+ // #2975 Stage 2 — read-only pre-approval predicate. hostd asks (over the
6235
+ // approval-gateway socket, via `check_pre_approved`) whether an EXACT
6236
+ // (agent, diff) pair is already operator-consented so it can skip the
6237
+ // config_propose_edit rate limit for that persist. The forge-resistant,
6238
+ // read-only matching lives in the pure `isDiffPreApproved` (pre-approval-
6239
+ // check.ts) so its contract — true only for a byte-exact registered pair,
6240
+ // NEVER a mutation — is unit-testable without importing this module. Here we
6241
+ // only bind it to the live correlation stores + helpers.
6242
+ function isDiffPreApprovedLive(agentName: string, unifiedDiff: string): boolean {
6243
+ return isDiffPreApproved(agentName, unifiedDiff, {
6244
+ alwaysAllow: pendingAlwaysAllowCorrelations,
6245
+ mentalModel: pendingMentalModelCorrelations,
6246
+ extractAddedAllowRule,
6247
+ mentalModelCorrelationKey,
6248
+ })
6249
+ }
6250
+
5773
6251
  // Scoped-approval store: the 30-min window that backs the "✅ Allow" tap for
5774
6252
  // narrow non-destructive scopes (not a separate button — it IS what Allow
5775
6253
  // means for those). Operator-tapped, gateway-side ONLY (never pushed to the
@@ -6525,9 +7003,260 @@ function restorePendingApprovalCards(): number {
6525
7003
  return restored
6526
7004
  }
6527
7005
 
7006
+ /**
7007
+ * Post the Approve/Deny card for `requestId` to every permission-card target.
7008
+ *
7009
+ * Extracted from `onPermissionRequest` (#3084 follow-up) so it has TWO callers:
7010
+ * the initial delivery, and the reaper's held-card re-delivery once a flood
7011
+ * window closes. Both paths must behave identically — same routing, same
7012
+ * thread-fallback, same landed-card bookkeeping, same hold-on-flood-wait — so
7013
+ * there is exactly one implementation of "post this card".
7014
+ *
7015
+ * Everything it needs is rebuilt from the pending entry, so a re-delivery an
7016
+ * hour later renders the same card the operator would have seen at T+0.
7017
+ */
7018
+ function postPermissionCard(
7019
+ requestId: string,
7020
+ pend: NonNullable<ReturnType<typeof pendingPermissions.get>>,
7021
+ ): void {
7022
+ // Register the in-flight flag HERE, not at the call sites, so BOTH callers are
7023
+ // covered. The sweep used to add it itself, but the ipcServer initial-delivery
7024
+ // call site did not — so a reaper tick landing between window-close and a slow
7025
+ // in-flight INITIAL send settling could post a duplicate card. One registration
7026
+ // point, one release point (the `.finally` below / the sync-throw catch).
7027
+ heldCardsInFlight.add(requestId)
7028
+
7029
+ // One increment per ATTEMPT, not per failing target. `targets` is N-wide and
7030
+ // every failing target runs the same `.catch` — incrementing there ratcheted
7031
+ // the backoff ladder N× faster than the documented 1m/2m/4m…30m. Compute the
7032
+ // attempt number once, up front; every failing target stamps the SAME value.
7033
+ const attemptFailures = (pend.redeliveryFailures ?? 0) + 1
7034
+
7035
+ try {
7036
+ const text = pend.card_text
7037
+ const showAlways =
7038
+ resolveScopedAllowChoices(pend.tool_name, pend.input_preview) != null
7039
+ const keyboard = buildPermissionActionRow(requestId, showAlways)
7040
+ // Route the card to the SAME place the post-verdict resume message lands
7041
+ // (resolvePermissionCardTargets): the ORIGINATING chat+topic when there's an
7042
+ // active turn — so a supergroup agent's card appears IN the topic the
7043
+ // operator asked from (marko's "CRM (Brevo)"), not the operator DM — else the
7044
+ // configured operator DMs, thread-stripped. The old code iterated `allowFrom`
7045
+ // unconditionally, so a supergroup card could only ever reach operator DMs
7046
+ // (the topic chat id is never in `allowFrom`) (marko, 2026-06-03).
7047
+ const targets = resolvePermissionCardTargets()
7048
+
7049
+ // ONE settle across ALL targets, not one per target (reviewer F1).
7050
+ //
7051
+ // The in-flight flag (guard iii) exists so the reaper can't re-post a card whose
7052
+ // previous post hasn't settled. `resolvePermissionCardTargets()` returns N
7053
+ // targets — it ends in `allowFrom.map(...)` — and on the RE-delivery path N>1 is
7054
+ // the COMMON case: re-delivery after a multi-hour ban is by construction past the
7055
+ // 30-min origin-recovery window, so it falls through to the operator-DM fan-out.
7056
+ // Clearing the flag per-target released it the moment the FIRST target settled
7057
+ // while others were still in flight, and the next tick re-posted to all of them —
7058
+ // a double delivery. Settle once, when every target is done.
7059
+ const sends = targets.map(({ chatId, threadId }) => {
7060
+ // The rich-markdown path pairs with formatPermissionCardBody (#1790) so its
7061
+ // bold/italic render. retryWithThreadFallback: if the topic was
7062
+ // deleted/recreated (stale thread id → 400 "message thread not found"),
7063
+ // re-send thread-less into the main chat so the card still ARRIVES rather
7064
+ // than vanishing.
7065
+ // allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware send
7066
+ return retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
7067
+ robustApiCall,
7068
+ (tid) =>
7069
+ bot.api.sendRichMessage(chatId, richMessage(text), {
7070
+ reply_markup: keyboard,
7071
+ ...(tid != null ? { message_thread_id: tid } : {}),
7072
+ }),
7073
+ { threadId, chat_id: chatId, verb: 'permission_request' },
7074
+ ).then(sent => {
7075
+ // Record the live card's (chat, message) so the reaper can strip its inline
7076
+ // keyboard — a stale Approve button left tappable dispatches a verdict for a
7077
+ // dead request_id (Bug 2). The entry may already be gone (operator tapped
7078
+ // before this resolved); guard the lookup.
7079
+ const live = pendingPermissions.get(requestId)
7080
+ if (live && sent && typeof sent.message_id === 'number') {
7081
+ // #2787: record where the card ACTUALLY LANDED so the confirm sweep scopes
7082
+ // its re-delivery suspension to the real chat/topic. `sent.message_thread_id`
7083
+ // reflects reality in all three cases: topic success → tid, main-chat /
7084
+ // fallback → undefined.
7085
+ const landedThreadId = sent.message_thread_id ?? undefined
7086
+ live.cards.push({ chatId, messageId: sent.message_id, threadId: landedThreadId })
7087
+ // The card LANDED — the operator can see and tap it, so the block is over.
7088
+ // Drop the hold mark and reconcile the off-Telegram surface. (PR 3 also
7089
+ // resets `startedAt` here: the TTL measures how long the operator had to
7090
+ // answer, and until this moment they had nothing to answer.)
7091
+ if (live.undeliverable != null) {
7092
+ live.undeliverable = null
7093
+ live.redeliveryFailures = 0
7094
+ // RESET THE TTL CLOCK. Load-bearing, not cosmetic. `startedAt` is when
7095
+ // the agent asked; the TTL measures how long the operator had to
7096
+ // answer. Until this instant they had NOTHING to answer — the card did
7097
+ // not exist in any chat. Without the reset, a card held through a 4.6h
7098
+ // ban lands already-expired against a 60-min TTL and the very next
7099
+ // reaper tick auto-denies it: we would have MOVED the silent denial,
7100
+ // not removed it.
7101
+ live.startedAt = Date.now()
7102
+ reconcileBlockedApprovals()
7103
+ process.stderr.write(
7104
+ `telegram gateway: permission-card RE-DELIVERED request=${requestId} ` +
7105
+ `tool=${live.tool_name} chat=${chatId} — the operator can answer now; ` +
7106
+ `TTL clock restarted (they had zero seconds while the channel was shut)\n`,
7107
+ )
7108
+ }
7109
+ permCardStore.add({
7110
+ requestId,
7111
+ chatId,
7112
+ messageId: sent.message_id,
7113
+ startedAt: live.startedAt,
7114
+ toolName: live.tool_name,
7115
+ cardText: live.card_text,
7116
+ })
7117
+ }
7118
+ }).catch(e => {
7119
+ process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
7120
+ const live = pendingPermissions.get(requestId)
7121
+ if (live == null) return // operator already resolved it — nothing to hold
7122
+
7123
+ // A card already landed on ANOTHER target, so the ask is NOT undeliverable —
7124
+ // the operator can see and tap it (reviewer F2). Marking it held here would be
7125
+ // a PERMANENT wedge: `selectHeldForRedelivery` skips entries that have cards,
7126
+ // so the mark could never be cleared, and PR 3's TTL freeze would then keep
7127
+ // the entry alive forever with the surface stuck on "blocked".
7128
+ if (live.cards.length > 0) return
7129
+
7130
+ // The card did NOT land anywhere. Before this the error was logged and
7131
+ // dropped: the entry sat with `cards: []`, the operator saw nothing, and 60
7132
+ // minutes later the TTL sweep AUTO-DENIED an approval no human had ever been
7133
+ // shown. Now we hold it — but only for a TRANSIENT cause. `holdReasonFor`
7134
+ // decides; a permanent 400 (a card that will never format) still falls through
7135
+ // to the TTL, which PR 3 makes safe by giving the no-card timeout a
7136
+ // missed-approvals fallback.
7137
+ const reason = holdReasonFor(e)
7138
+ if (reason == null) return
7139
+
7140
+ // Bound the RATE, never the RETRY. A held entry is re-selected every tick, so a
7141
+ // target that keeps failing would otherwise re-send every 60s forever — the
7142
+ // amplifier this series exists to avoid. Back off instead (1m, 2m, 4m … 30m).
7143
+ // We never STOP retrying: a terminal give-up would strand the card even after
7144
+ // the channel recovered, and because turnInFlightForGate() holds the inbound
7145
+ // gate while a permission is pending, that would silently buffer the operator's
7146
+ // messages forever. Escalate the surface, never the verdict — and never abandon
7147
+ // the card.
7148
+ const failures = attemptFailures
7149
+ live.redeliveryFailures = failures
7150
+ const now = Date.now()
7151
+ // A flood-wait carries Telegram's own window end; everything else backs off.
7152
+ const retryableAt = isFloodWaitActiveError(e) ? e.untilTs : now + heldRetryBackoffMs(failures)
7153
+ // `since` is set ONCE — it is when the block began, not when we last retried.
7154
+ live.undeliverable = { since: live.undeliverable?.since ?? now, retryableAt, reason }
7155
+ // Reconcile the shared surface SYNCHRONOUSLY, here in the failure handler — not
7156
+ // on the 60s reaper tick. The operator is locked out of Telegram; this record is
7157
+ // the only way they learn an agent is blocked, so it must be live in under a
7158
+ // second, not up to a minute.
7159
+ reconcileBlockedApprovals()
7160
+ process.stderr.write(
7161
+ `telegram gateway: permission-card HELD (undeliverable) request=${requestId} ` +
7162
+ `tool=${live.tool_name} reason=${reason} attempt=${failures} ` +
7163
+ `retryable_at=${new Date(retryableAt).toISOString()}` +
7164
+ ` — approval is held, NOT denied; re-delivering when the channel returns ` +
7165
+ `(backoff ${Math.round(heldRetryBackoffMs(failures) / 60000)}m)\n`,
7166
+ )
7167
+ })
7168
+ })
7169
+
7170
+ // Settle ONCE, after every target. `allSettled` never rejects, so the flag is
7171
+ // always released — including when `targets` is empty (no active turn AND no
7172
+ // configured operator DM), in which case `sends` is [] and this resolves at once.
7173
+ void Promise.allSettled(sends).finally(() => {
7174
+ heldCardsInFlight.delete(requestId)
7175
+ })
7176
+ } catch (e) {
7177
+ // A SYNCHRONOUS throw (resolveScopedAllowChoices / buildPermissionActionRow /
7178
+ // resolvePermissionCardTargets) never reaches the `.finally` above — without
7179
+ // this catch the in-flight flag would leak and the request would never be
7180
+ // re-selected by the sweep. Release the flag, then rethrow so the caller
7181
+ // (the sweep's own try/catch, or the ipc handler) sees the failure.
7182
+ heldCardsInFlight.delete(requestId)
7183
+ throw e
7184
+ }
7185
+ }
7186
+
7187
+ /**
7188
+ * Request ids whose card post is mid-flight — INITIAL delivery or re-delivery.
7189
+ * Guard (iii) against double-delivery: the 60s reaper must not re-post a card
7190
+ * whose previous post hasn't settled yet (a slow send would otherwise be
7191
+ * re-issued every tick). Registered at the top of postPermissionCard (so both
7192
+ * callers are covered) and cleared in its `.finally` — or its sync-throw catch.
7193
+ */
7194
+ const heldCardsInFlight = new Set<string>()
7195
+
7196
+ /**
7197
+ * Re-deliver held permission cards once the flood window closes (#3084
7198
+ * follow-up, PR 2).
7199
+ *
7200
+ * The whole point of holding rather than auto-denying is that the ask comes
7201
+ * BACK. This is the half that brings it back.
7202
+ *
7203
+ * `selectHeldForRedelivery` owns the guards (window closed, held, no landed
7204
+ * card, not in flight, per-tick cap). `robustApiCall`'s own pre-call probe is a
7205
+ * further, independent short-circuit downstream — if the window re-opens
7206
+ * between selection and send, the send refuses itself and the entry is simply
7207
+ * re-marked with the longer window.
7208
+ *
7209
+ * The per-tick cap is the ban-safety property: a backlog of held cards must not
7210
+ * BURST the instant the window closes, because that burst is the one realistic
7211
+ * way this design could re-earn the ban it exists to survive. Deferred ids are
7212
+ * LOGGED, never silently dropped — they go out on the next tick.
7213
+ */
7214
+ function sweepHeldPermissionCards(): void {
7215
+ const remaining = probeFloodWaitRemainingMs()
7216
+ const { send, deferred } = selectHeldForRedelivery(pendingPermissions.entries(), {
7217
+ floodRemainingMs: remaining,
7218
+ inFlight: heldCardsInFlight,
7219
+ now: Date.now(),
7220
+ })
7221
+ if (send.length === 0) return
7222
+
7223
+ process.stderr.write(
7224
+ `telegram gateway: flood window closed — re-delivering ${send.length} held ` +
7225
+ `permission card(s)` +
7226
+ (deferred.length > 0
7227
+ ? `; ${deferred.length} deferred to the next tick by the per-tick cap ` +
7228
+ `(${deferred.join(', ')}) — deferred, NOT dropped`
7229
+ : '') + '\n',
7230
+ )
7231
+ for (const requestId of send) {
7232
+ const pend = pendingPermissions.get(requestId)
7233
+ if (pend == null) continue
7234
+ try {
7235
+ // postPermissionCard registers the in-flight flag itself (so the ipc
7236
+ // initial-delivery caller is covered too) and releases it once every
7237
+ // target settles — or on a sync throw, in its own catch.
7238
+ postPermissionCard(requestId, pend)
7239
+ } catch (e) {
7240
+ // A synchronous throw must not escape the setInterval callback (it would
7241
+ // kill the whole reaper tick, TTL sweep included). postPermissionCard has
7242
+ // already released the flag; delete again defensively so the entry is
7243
+ // re-selectable on the next tick, and log rather than rethrow.
7244
+ heldCardsInFlight.delete(requestId)
7245
+ process.stderr.write(
7246
+ `telegram gateway: held-card re-delivery for ${requestId} threw ` +
7247
+ `synchronously: ${e} — flag released, will retry on the next tick\n`,
7248
+ )
7249
+ }
7250
+ }
7251
+ }
7252
+
6528
7253
  // 60-second sweep drops anything past its documented TTL.
6529
7254
  const pendingStateReaper = setInterval(() => {
6530
7255
  const now = Date.now()
7256
+ // #3084 follow-up — bring held cards BACK the moment the channel reopens.
7257
+ // Runs before the TTL sweep below so a card that can be re-delivered on this
7258
+ // tick is re-delivered, not considered for expiry.
7259
+ sweepHeldPermissionCards()
6531
7260
  // OAuth-code state grouped first (pinned by secret-detect-oauth-code.test.ts).
6532
7261
  pendingReauthFlows.sweep(now)
6533
7262
  for (const [k, v] of pendingAuthAddFlows) {
@@ -6539,6 +7268,21 @@ const pendingStateReaper = setInterval(() => {
6539
7268
  for (const [k, v] of awaitingAuthCodeAt) {
6540
7269
  if (now - v > AUTH_CODE_CONTEXT_TTL_MS) awaitingAuthCodeAt.delete(k)
6541
7270
  }
7271
+ // Loopback OAuth relay flows (issue #2582) — same TTL. Kill the waiting
7272
+ // CLI child so an abandoned consent never lingers with a bound listener.
7273
+ // Placed AFTER the OAuth-code cluster above, which secret-detect-oauth-
7274
+ // code.test.ts pins as contiguous within the first 800 chars of the
7275
+ // reaper (same precedent as the Microsoft connect sweep below). A flow
7276
+ // with a submit in flight is skipped (PR #3100 review finding 3): a paste
7277
+ // near the TTL boundary must not have its child killed mid-registration —
7278
+ // the submit path owns cleanup once `submitting` is set.
7279
+ for (const [k, v] of pendingLoopbackFlows) {
7280
+ if (v.submitting) continue
7281
+ if (now - v.startedAt > REAUTH_INTERCEPT_TTL_MS) {
7282
+ cancelLoopbackFlow(v)
7283
+ pendingLoopbackFlows.delete(k)
7284
+ }
7285
+ }
6542
7286
  // Microsoft connect flows self-expire at the device code's own expiry
6543
7287
  // (~15 min) — sweep past that + grace so an abandoned card doesn't pin
6544
7288
  // its key. Setting cancelled makes any still-running poll bail. Placed
@@ -6561,11 +7305,19 @@ const pendingStateReaper = setInterval(() => {
6561
7305
  if (now >= v.expiresAt) pendingAuthRmFlows.delete(k)
6562
7306
  }
6563
7307
  pendingVaultOps.sweep(now)
6564
- for (const [k, v] of pendingPermissions) {
6565
- // hostd gated fleet-mutation verbs get a longer (30-min) human-scale
6566
- // decision window than the 10-min default (Bug 2 fix #2).
6567
- const ttl = ttlForTool(v.tool_name)
6568
- if (now - v.startedAt > ttl) {
7308
+ // THE LEASH. The sweep the guard AND the auto-deny it gates — now lives in
7309
+ // permission-ttl-sweep.ts, and the outcome test's harness drives that exact
7310
+ // function. It used to be an inline loop here while the harness kept a PRIVATE
7311
+ // copy of the guard, so deleting the real check left every behavioural assertion
7312
+ // GREEN and only a source-text grep noticed. A test that cannot fail is not a
7313
+ // test, and this is the test for `no-self-escalation`. One implementation, two
7314
+ // callers: there is no longer a gateway-side line whose deletion restores the
7315
+ // auto-deny without removing the sweep entirely.
7316
+ sweepPermissionTtl({
7317
+ entries: pendingPermissions,
7318
+ now,
7319
+ ttlForTool,
7320
+ onExpire: (k, v, ttl) => {
6569
7321
  // Don't just drop it: the claude turn is suspended INSIDE the MCP
6570
7322
  // permission call waiting for a verdict. A silent delete left it
6571
7323
  // wedged forever when the operator never tapped — permanent
@@ -6612,8 +7364,19 @@ const pendingStateReaper = setInterval(() => {
6612
7364
  // returns. Anchor to the card's own origin surface (where the operator
6613
7365
  // would have tapped), so the digest lands in the same topic — not a
6614
7366
  // fanned-out DM. Skip if the card was never posted anywhere.
7367
+ // The `cards[0]` hole (#3084 follow-up): this used to skip silently when the
7368
+ // card had never landed (`cards: []` → `origin === undefined`). That is
7369
+ // exactly the undeliverable case — so the request was auto-denied AND erased
7370
+ // from the only record that would have brought it back. The safety net had a
7371
+ // hole shaped exactly like the accident.
7372
+ //
7373
+ // Held entries no longer reach this code at all (the TTL freeze above skips
7374
+ // them), so this is belt-and-braces: ANY future path that times out a card
7375
+ // which never landed — including a PERMANENT 400, which holdReasonFor()
7376
+ // deliberately does not hold — still lands in the digest. Fall back to where
7377
+ // the card WOULD have gone.
6615
7378
  if (MISSED_APPROVAL_REOFFER_ENABLED) {
6616
- const origin = v.cards[0]
7379
+ const origin = v.cards[0] ?? resolvePermissionCardTargets()[0]
6617
7380
  if (origin != null) {
6618
7381
  missedApprovalsStore.add({
6619
7382
  requestId: k,
@@ -6632,8 +7395,9 @@ const pendingStateReaper = setInterval(() => {
6632
7395
  )
6633
7396
  pendingPermissions.delete(k)
6634
7397
  permCardStore.remove(k)
6635
- }
6636
- }
7398
+ reconcileBlockedApprovals()
7399
+ },
7400
+ })
6637
7401
  // Drop no-repeat suppression entries past the safety-cap window (the primary
6638
7402
  // bound is the operator-activity reset; this just keeps the map from growing).
6639
7403
  for (const [sig, at] of permissionTimeoutSignatures) {
@@ -7194,6 +7958,12 @@ const statusPinChatIds = new Map<string, string>()
7194
7958
  // re-pin of the same key keeps the original timestamp). Feeds the TTL gate of
7195
7959
  // the mid-session `wk:` pin reaper (#3001); cleared alongside the state.
7196
7960
  const statusPinPinnedAt = new Map<string, number>()
7961
+ // Rights-aware negative cache (#3024): chats where an auto status-pin attempt
7962
+ // failed with the permanent "not enough rights to manage pinned messages" 400.
7963
+ // Per-process only — a restart clears it so a later-granted pin right re-enables
7964
+ // auto-pin. The explicit `pin_message` MCP tool deliberately does NOT consult
7965
+ // this cache (it always attempts and surfaces the error to the agent).
7966
+ const statusPinRightsCache = new PinRightsCache()
7197
7967
 
7198
7968
  // Durable snapshot of the pin claim set on the persistent per-agent volume
7199
7969
  // (STATE_DIR = /state/agent/telegram in prod). Closes the crash hole: the
@@ -7228,6 +7998,48 @@ const activityCardStoreFs: ActivityCardStoreFsSeam = {
7228
7998
  }
7229
7999
  const activityCardPersistEnabled = !STATIC
7230
8000
 
8001
+ // Durable handle for the component-5 queued-status placeholder + #2995 busy-ack
8002
+ // card (#3002). Both track their sent message id only in the in-memory
8003
+ // `queuedStatusMsgIds` Map, so a restart between posting a card and its
8004
+ // promote/reap strands a permanent stale "⏳ Queued…" line. Persisted on POST,
8005
+ // cleared on in-process reap; a boot-time reaper (wired alongside
8006
+ // `activityCardBootReaper`, same startup-mutex ordering constraint) DELETES any
8007
+ // leftover card and clears the store — a "Queued" claim is always wrong after a
8008
+ // restart, so deletion is the honest terminal (reap-on-boot only, no
8009
+ // promote-across-restart). STATIC mode skips disk — same gate as the sibling
8010
+ // stores.
8011
+ const QUEUED_CARD_STORE_PATH = join(STATE_DIR, 'queued-cards-pending.json')
8012
+ const queuedCardStoreFs: QueuedCardStoreFsSeam = {
8013
+ readFileSync: (p: string) => readFileSync(p, 'utf8'),
8014
+ writeFileSync: (p: string, d: string) => writeFileSync(p, d),
8015
+ renameSync: (a: string, b: string) => renameSync(a, b),
8016
+ existsSync: (p: string) => existsSync(p),
8017
+ }
8018
+ const queuedCardPersistEnabled = !STATIC
8019
+
8020
+ // Write-through helpers (#3002) — mirror the in-memory `queuedStatusMsgIds`
8021
+ // set/delete onto the durable store. Best-effort + gated: no-op when the store
8022
+ // isn't usable (STATIC = no durable volume). Called from postQueuedStatus /
8023
+ // postBusyAck (post) and reapQueuedStatus (delete).
8024
+ function persistQueuedCard(
8025
+ key: string,
8026
+ chatId: string,
8027
+ threadId: number | null,
8028
+ messageId: number,
8029
+ ): void {
8030
+ if (!queuedCardPersistEnabled) return
8031
+ writeQueuedCardRecord(QUEUED_CARD_STORE_PATH, queuedCardStoreFs, {
8032
+ key,
8033
+ chatId,
8034
+ threadId,
8035
+ messageId,
8036
+ })
8037
+ }
8038
+ function clearQueuedCard(key: string, messageId?: number): void {
8039
+ if (!queuedCardPersistEnabled) return
8040
+ clearQueuedCardRecord(QUEUED_CARD_STORE_PATH, queuedCardStoreFs, key, messageId)
8041
+ }
8042
+
7231
8043
  // Slot-banner pin persistence (#421 crash-recovery). The slot banner is pinned
7232
8044
  // in the owner chat when the agent is on a non-default OAuth slot. Rather than a
7233
8045
  // parallel store + second boot hook, its pin is persisted in the SAME
@@ -7395,6 +8207,40 @@ async function activityCardBootReaper(): Promise<void> {
7395
8207
  )
7396
8208
  }
7397
8209
  }
8210
+
8211
+ /**
8212
+ * Boot-time reaper for orphaned queued-status / busy-ack cards (#3002). Thin
8213
+ * gateway wrapper over the pure `runQueuedCardBootReaper` — binds the live fs
8214
+ * seam, a robust Telegram delete, and the logger. Deletes any card persisted by
8215
+ * a prior (crashed/restarted) session and clears the store: a "⏳ Queued…" /
8216
+ * "On it" claim is always wrong after a restart, so deletion is the honest
8217
+ * terminal (reap-on-boot only, no promote-across-restart).
8218
+ *
8219
+ * MUST run ONLY after this gateway wins the startup mutex — same shared-file
8220
+ * ordering constraint as `activityCardBootReaper` / `statusPinBootCleanup`.
8221
+ */
8222
+ async function queuedCardBootReaper(): Promise<void> {
8223
+ if (!queuedCardPersistEnabled) return
8224
+ const { deleted, total } = await runQueuedCardBootReaper({
8225
+ path: QUEUED_CARD_STORE_PATH,
8226
+ fs: queuedCardStoreFs,
8227
+ deleteCard: (record) =>
8228
+ robustApiCall(
8229
+ () => lockedBot.api.deleteMessage(record.chatId, record.messageId),
8230
+ {
8231
+ chat_id: record.chatId,
8232
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
8233
+ verb: 'queued-card.boot-reap-delete',
8234
+ },
8235
+ ),
8236
+ })
8237
+ if (total > 0) {
8238
+ process.stderr.write(
8239
+ `telegram gateway: queued-card: deleted ${deleted}/${total} ` +
8240
+ `orphaned queued/busy-ack card(s) from a prior session\n`,
8241
+ )
8242
+ }
8243
+ }
7398
8244
  // ─── Mid-session stale-card reaper (#2918) ──────────────────────────────────
7399
8245
  // The boot reapers (markOrphanedWithTimeoutClassification + activityCardBoot-
7400
8246
  // Reaper) run ONCE at startup. A turn whose owning SDK subprocess dies
@@ -7651,6 +8497,16 @@ async function reconcileStatusPinInner(
7651
8497
  chatId,
7652
8498
  prevState: prev,
7653
8499
  desired,
8500
+ rightsCache: statusPinRightsCache,
8501
+ onPinRightsDisabled: (chat) => {
8502
+ // Logged ONCE per chat per process (#3024). Every subsequent auto-pin
8503
+ // attempt in this chat is skipped silently until a restart clears the
8504
+ // cache — replacing the 41x/48h `status-pin pin failed` spam marko saw.
8505
+ process.stderr.write(
8506
+ `telegram gateway: status-pin disabled for chat ${chat}: bot lacks ` +
8507
+ `pin rights; grant 'Pin messages' admin right to re-enable after restart\n`,
8508
+ )
8509
+ },
7654
8510
  onError: (phase, err) => {
7655
8511
  const msg = err instanceof Error ? err.message : String(err)
7656
8512
  process.stderr.write(
@@ -7752,6 +8608,105 @@ async function unpinAllStatusPins(): Promise<void> {
7752
8608
  }
7753
8609
  }
7754
8610
 
8611
+ // ─── DM stale-pin sweep (#3026) ─────────────────────────────────────────────
8612
+ // In a user DM the Bot API skips the boot getChat() probe (positive chat IDs
8613
+ // return `400 chat not found` until the user messages) AND getChat() exposes
8614
+ // only the NEWEST pin — DMs STACK pins and there is no list-pins method, so
8615
+ // older orphan pins are invisible to the probe-based sweep forever. The durable
8616
+ // fix: once per DM chat per boot, `unpinAllChatMessages` (safe in a DM — every
8617
+ // pin is bot-authored) then re-pin the live tracked cards. Groups keep the
8618
+ // probe path (unpin-all there would nuke human pins).
8619
+ //
8620
+ // Eligibility mirrors statusPinBootCleanup's mutex gate: set true ONLY after
8621
+ // this gateway wins the startup lock, so a losing double-boot never clears the
8622
+ // live holder's pins.
8623
+ let dmPinSweepEligible = false
8624
+ const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
8625
+ unpinAll: (chatId) =>
8626
+ robustApiCall(() => lockedBot.api.unpinAllChatMessages(chatId), {
8627
+ chat_id: chatId,
8628
+ verb: 'dm-pin-sweep.unpin-all',
8629
+ }),
8630
+ pinSilent: (chatId, messageId) =>
8631
+ robustApiCall(
8632
+ () =>
8633
+ lockedBot.api.pinChatMessage(chatId, messageId, {
8634
+ disable_notification: true,
8635
+ }),
8636
+ { chat_id: chatId, verb: 'dm-pin-sweep.repin' },
8637
+ ),
8638
+ // Pins that must survive the unpin-all: live in-memory status-pin claims
8639
+ // for this chat (fg:/wk:/tool:/banner: — non-empty for a first-inbound
8640
+ // sweep landing mid-turn) UNIONED with the deliberately-retained store
8641
+ // rows — unexpired `tool:` pins (#3001) survive statusPinBootCleanup by
8642
+ // design and must survive this sweep too. Read LIVE from the store so
8643
+ // both the boot sweep (in-memory maps still empty then) and a later
8644
+ // first-inbound sweep see them. Best-effort: a store read failure
8645
+ // degrades to in-memory-only. The sweeper dedupes.
8646
+ liveTrackedMessageIds: (chatId) => {
8647
+ const ids: number[] = []
8648
+ for (const [key, st] of statusPinState.entries()) {
8649
+ if (statusPinChatIds.get(key) === chatId) ids.push(st.messageId)
8650
+ }
8651
+ if (statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled) {
8652
+ try {
8653
+ ids.push(
8654
+ ...unexpiredStoreRepinIds(
8655
+ loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs),
8656
+ chatId,
8657
+ Date.now(),
8658
+ ),
8659
+ )
8660
+ } catch (err) {
8661
+ process.stderr.write(
8662
+ `telegram gateway: dm-pin-sweep: store repin scan failed ` +
8663
+ `(chat=${chatId}): ${(err as Error).message}\n`,
8664
+ )
8665
+ }
8666
+ }
8667
+ return ids
8668
+ },
8669
+ eligible: () => dmPinSweepEligible,
8670
+ log: (line) => process.stderr.write(line),
8671
+ })
8672
+
8673
+ /**
8674
+ * Boot-time pin cleanup + DM stale-pin sweep, sequenced under the startup
8675
+ * mutex. Collects the DM chat IDs with a prior-session pin record BEFORE the
8676
+ * store reapers empty the stores, runs the three existing boot reapers, marks
8677
+ * the DM sweep eligible (this gateway now owns the shared state), then
8678
+ * unpin-alls each recorded DM chat. Fire-and-forget from the caller — never
8679
+ * blocks boot, never rejects unhandled.
8680
+ */
8681
+ async function runBootPinCleanupAndDmSweep(): Promise<void> {
8682
+ let dmChatIds: string[] = []
8683
+ try {
8684
+ dmChatIds = collectDmChatIdsFromStores({
8685
+ statusPins:
8686
+ statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled
8687
+ ? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
8688
+ : [],
8689
+ activityCards: activityCardPersistEnabled
8690
+ ? loadActivityCards(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs)
8691
+ : [],
8692
+ queuedCards: queuedCardPersistEnabled
8693
+ ? loadQueuedCards(QUEUED_CARD_STORE_PATH, queuedCardStoreFs)
8694
+ : [],
8695
+ })
8696
+ } catch (err) {
8697
+ process.stderr.write(
8698
+ `telegram gateway: dm-pin-sweep: store scan failed: ${(err as Error).message}\n`,
8699
+ )
8700
+ }
8701
+ await statusPinBootCleanup()
8702
+ await activityCardBootReaper()
8703
+ await queuedCardBootReaper()
8704
+ // This gateway now owns the shared per-agent pin state — enable the DM
8705
+ // unpin-all path (both the boot sweep below and lazy first-inbound sweeps).
8706
+ dmPinSweepEligible = true
8707
+ for (const id of dmChatIds) await dmPinSweeper.sweep(id)
8708
+ }
8709
+
7755
8710
  // Activity feed. The gateway streams a live "what it's doing" tool-activity
7756
8711
  // feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
7757
8712
  // call (flush-independent, so it stays real-time on fast/clustered-tool
@@ -7904,8 +8859,9 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
7904
8859
  // pins from a prior (dead) session. Gated here (not at import time) so a
7905
8860
  // LOSING double-boot never unpins the live holder's legitimate pins.
7906
8861
  // Fire-and-forget: cleanup is best-effort and must not block boot.
7907
- void statusPinBootCleanup()
7908
- void activityCardBootReaper()
8862
+ // #3026: sequenced so the DM stale-pin sweep runs after the reapers and
8863
+ // only once this gateway owns the shared state.
8864
+ void runBootPinCleanupAndDmSweep()
7909
8865
  } catch (err) {
7910
8866
  process.stderr.write(
7911
8867
  `telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
@@ -7921,8 +8877,8 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
7921
8877
  // probe + 409-retry loop is still the liveness guard on this path. A
7922
8878
  // successful writePidFile here means no live holder was detected, so
7923
8879
  // running orphan cleanup is consistent with the pre-mutex behaviour.
7924
- void statusPinBootCleanup()
7925
- void activityCardBootReaper()
8880
+ // #3026: same sequenced cleanup + DM stale-pin sweep as the mutex path.
8881
+ void runBootPinCleanupAndDmSweep()
7926
8882
  } catch (writeErr) {
7927
8883
  process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
7928
8884
  }
@@ -8644,6 +9600,13 @@ pendingProgress.startTimer({
8644
9600
  chat_id: ctx.chatId,
8645
9601
  verb: 'pending-progress-edit',
8646
9602
  ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}),
9603
+ // #3084 PR 2 / L1: the progress-card edit is the #1 documented ban
9604
+ // trigger (repeated editMessageText on one message). Tag COSMETIC and
9605
+ // pass messageId/editPayload so the gate's per-message floor + no-op
9606
+ // skip + coalescing engage, and the edit sheds while a window is open.
9607
+ priorityClass: 'cosmetic',
9608
+ messageId: ctx.messageId,
9609
+ editPayload: ctx.literalText ? ctx.newText : richMessage(ctx.newText),
8647
9610
  },
8648
9611
  )
8649
9612
  },
@@ -9256,6 +10219,16 @@ const ipcServer: IpcServer = createIpcServer({
9256
10219
  // this call is safe wherever it sits relative to the cron early-return
9257
10220
  // above (#3038 review finding 5).
9258
10221
  bridgeDeadWatchdog.noteBridgeRegistered(client.agentName)
10222
+ // #3043 item 2: a REAL bridge registering is proof the boot came all the
10223
+ // way up healthy — clear start.sh's crashloop boot-attempts counter so only
10224
+ // boots that genuinely fail BEFORE the bridge registers accumulate toward
10225
+ // the 3-strike override clear. Without this, three quick operator
10226
+ // hand-bounces of a healthy agent (each <150s apart) spuriously wipe a
10227
+ // working model override. Best-effort; no-op when the file is absent.
10228
+ if (client.agentName != null) {
10229
+ const smBootDir = resolveAgentDirFromEnv()
10230
+ if (smBootDir != null) clearSessionModelBootAttempts(smBootDir)
10231
+ }
9259
10232
  client.send({ type: 'status', status: 'agent_connected' })
9260
10233
 
9261
10234
  // Phase 2b PR 3a — bridgeUp cutover. The state machine's `bridgeUp`
@@ -9480,6 +10453,16 @@ const ipcServer: IpcServer = createIpcServer({
9480
10453
  // scripts/check-plugin-references.mjs (TS2722).
9481
10454
  progressDriver?.dispose?.({ preservePending: true })
9482
10455
  },
10456
+ // #2650: the bridge died mid-turn — its turn-long `typing…` loop never
10457
+ // hits the canonical turn-end stop, leaving a stale "typing…" until the
10458
+ // next turn. Sweep every live loop here (gated to registered-agent
10459
+ // disconnect inside flushOnAgentDisconnect).
10460
+ stopTurnTypingLoops: () => {
10461
+ turnTypingLoop.stopAll()
10462
+ // Shutdown drain: also cancel every armed catch-up so no typing ping
10463
+ // fires after the gateway has stopped.
10464
+ typingEmitter.reset()
10465
+ },
9483
10466
  // When dangling activeTurnStartedAt keys were swept (setDone raced
9484
10467
  // disconnect), the module-scope `currentTurn` may also point at the
9485
10468
  // dead bridge's turn. Null it so the next inbound starts a fresh
@@ -9702,7 +10685,8 @@ const ipcServer: IpcServer = createIpcServer({
9702
10685
  // `card_text` is retained so the TTL reaper can re-edit the SAME body
9703
10686
  // with a "timed out" footer while stripping the keyboard atomically
9704
10687
  // (Bug 2 fix #1) — the reaper has no grammy ctx to read the live text.
9705
- pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now(), card_text: text, cards: [] })
10688
+ const pendEntry = { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now(), card_text: text, cards: [] }
10689
+ pendingPermissions.set(requestId, pendEntry)
9706
10690
  // Compact action row: ❌ Deny · ✅ Allow · 🔁 Always… — the scope of an
9707
10691
  // "always" grant stays hidden until the operator taps "🔁 Always…",
9708
10692
  // which swaps the row for a scope choice (this file / any file ⚠️). The
@@ -9710,67 +10694,15 @@ const ipcServer: IpcServer = createIpcServer({
9710
10694
  // rule for this tool; unknown tools get the two-button row only. "Allow"
9711
10695
  // itself auto-grants a 30-min window for narrow non-destructive scopes
9712
10696
  // (decided in the allow handler), so there is no separate time-box button.
9713
- const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
9714
- const keyboard = buildPermissionActionRow(requestId, showAlways)
9715
- // Route the card to the SAME place the post-verdict resume message
9716
- // lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
9717
- // there's an active turn — so a supergroup agent's card appears IN the
9718
- // topic the operator asked from (marko's "CRM (Brevo)"), not the
9719
- // operator DM — else the configured operator DMs, thread-stripped. The
9720
- // old code iterated `allowFrom` unconditionally, so a supergroup card
9721
- // could only ever reach operator DMs (the topic chat id is never in
9722
- // `allowFrom`) (marko, 2026-06-03).
10697
+ // Captured BEFORE the send: the status-reaction parking below needs the
10698
+ // turn that raised this card, and `currentTurn` can be re-pointed by a
10699
+ // concurrent inbound while the send is in flight.
9723
10700
  const activeTurn = currentTurn
9724
- const targets = resolvePermissionCardTargets()
9725
- for (const { chatId, threadId } of targets) {
9726
- // The rich-markdown path pairs with formatPermissionCardBody (#1790)
9727
- // so its bold/italic render. retryWithThreadFallback: if the topic was
9728
- // deleted/recreated (stale thread id → 400 "message thread not
9729
- // found"), re-send thread-less into the main chat so the card still
9730
- // ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
9731
- // allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware send
9732
- void retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
9733
- robustApiCall,
9734
- (tid) =>
9735
- bot.api.sendRichMessage(chatId, richMessage(text), {
9736
- reply_markup: keyboard,
9737
- ...(tid != null ? { message_thread_id: tid } : {}),
9738
- }),
9739
- { threadId, chat_id: chatId, verb: 'permission_request' },
9740
- ).then(sent => {
9741
- // Record the live card's (chat, message) so the TTL reaper can strip
9742
- // its inline keyboard on auto-deny — a stale Approve button left
9743
- // tappable dispatches a verdict for a dead request_id (Bug 2). The
9744
- // entry may already be gone (operator tapped before this resolved);
9745
- // guard the lookup.
9746
- const pend = pendingPermissions.get(requestId)
9747
- if (pend && sent && typeof sent.message_id === 'number') {
9748
- // #2787: record where the card ACTUALLY LANDED so the confirm sweep
9749
- // scopes its re-delivery suspension to the real chat/topic. When
9750
- // retryWithThreadFallback hit THREAD_NOT_FOUND (stale/renumbered
9751
- // topic) it re-sent thread-less into the main chat — the returned
9752
- // Message then carries no message_thread_id, so we must record the
9753
- // landed topic (undefined → suspend by bare chatId), NOT the stale
9754
- // requested `threadId`. Keying suspension on the stale topic would
9755
- // leave the main-chat card unsuspended (a re-deliver could clobber
9756
- // the live card) while needlessly suspending a topic that holds
9757
- // nothing. `sent.message_thread_id` reflects reality in all three
9758
- // cases: topic success → tid, main-chat / fallback → undefined.
9759
- const landedThreadId = sent.message_thread_id ?? undefined
9760
- pend.cards.push({ chatId, messageId: sent.message_id, threadId: landedThreadId })
9761
- permCardStore.add({
9762
- requestId,
9763
- chatId,
9764
- messageId: sent.message_id,
9765
- startedAt: pend.startedAt,
9766
- toolName: pend.tool_name,
9767
- cardText: pend.card_text,
9768
- })
9769
- }
9770
- }).catch(e => {
9771
- process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
9772
- })
9773
- }
10701
+ // #3084 follow-up — the send now lives in postPermissionCard() so the
10702
+ // reaper can RE-DRIVE it when a flood window closes. This is the first
10703
+ // delivery attempt; if it fails against an open ban the entry is marked
10704
+ // undeliverable and held, never auto-denied.
10705
+ postPermissionCard(requestId, pendEntry)
9774
10706
  // Park the turn's status reaction on 🙏 (awaiting your tap) and
9775
10707
  // suspend the stall watchdog — a turn blocked on the operator is not
9776
10708
  // stalled, so it must not degrade to 🥱/😨 while the card sits
@@ -10481,6 +11413,36 @@ const ipcServer: IpcServer = createIpcServer({
10481
11413
  }
10482
11414
  },
10483
11415
 
11416
+ // #2975 Stage 2 — read-only pre-approval query from hostd. Answer whether the
11417
+ // EXACT (agent, diff) pair is already operator-consented so hostd can skip the
11418
+ // config_propose_edit rate limit for that persist. NEVER mutates state: it
11419
+ // only peeks the correlation maps by byte-exact match (isDiffPreApproved).
11420
+ // Fail-closed by construction — any agent mismatch or missing correlation
11421
+ // answers `preApproved: false`.
11422
+ onCheckPreApproved(client: IpcClient, msg: CheckPreApprovedMessage) {
11423
+ let preApproved = false
11424
+ try {
11425
+ const self = process.env.SWITCHROOM_AGENT_NAME
11426
+ // This gateway serves exactly one agent; a query for a different agent
11427
+ // can never be pre-approved here.
11428
+ if (!self || msg.agentName === self) {
11429
+ preApproved = isDiffPreApprovedLive(msg.agentName, msg.unifiedDiff)
11430
+ }
11431
+ } catch (err) {
11432
+ process.stderr.write(
11433
+ `telegram gateway: check_pre_approved errored — ${(err as Error).message} (failing closed)\n`,
11434
+ )
11435
+ preApproved = false
11436
+ }
11437
+ try {
11438
+ client.send({ type: 'pre_approved_result', correlationId: msg.correlationId, preApproved })
11439
+ } catch (err) {
11440
+ process.stderr.write(
11441
+ `telegram gateway: check_pre_approved reply failed: ${(err as Error).message}\n`,
11442
+ )
11443
+ }
11444
+ },
11445
+
10484
11446
  // #2670 one-tap self-improvement — persist a skill-improvement proposal and
10485
11447
  // post its Approve/Dismiss card. The store transition + apply-injection on
10486
11448
  // Approve are owned by handleSkillProposalCallback (so a gateway restart
@@ -10824,10 +11786,21 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
10824
11786
 
10825
11787
  assertAllowedChat(chat_id)
10826
11788
 
10827
- const sent = await rawSendChecklist({
10828
- chat_id,
11789
+ // #2044 outbound secret scrub. Checklist title + task strings are
11790
+ // agent-authored free text sent to Telegram, same class as the reply
11791
+ // `text` path and ask_user — route both through the SAME redactor before
11792
+ // send (via the pure, unit-tested redactChecklistFields helper) so an
11793
+ // echoed token / DATABASE_URL is masked.
11794
+ const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
10829
11795
  title,
10830
11796
  tasks,
11797
+ (t) => redactOutboundText(t, 'send_checklist'),
11798
+ )
11799
+
11800
+ const sent = await rawSendChecklist({
11801
+ chat_id,
11802
+ title: redactedTitle!,
11803
+ tasks: redactedTasks!,
10831
11804
  ...(threadId != null ? { message_thread_id: threadId } : {}),
10832
11805
  ...(replyTo != null ? { reply_to_message_id: replyTo } : {}),
10833
11806
  ...(protectContent ? { protect_content: true } : {}),
@@ -10909,7 +11882,19 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
10909
11882
 
10910
11883
  assertAllowedChat(chat_id)
10911
11884
 
10912
- await rawEditMessageChecklist({ chat_id, message_id, title, tasks })
11885
+ // #2044 outbound secret scrub. update_checklist forwards the same
11886
+ // agent-authored title + task text to Telegram as send_checklist, so it
11887
+ // shares the identical leak class — redact both through the reply-path
11888
+ // redactor (via the shared redactChecklistFields helper) before the edit
11889
+ // lands. title / tasks may be undefined here (partial patch); the helper
11890
+ // passes undefined through untouched.
11891
+ const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
11892
+ title,
11893
+ tasks,
11894
+ (t) => redactOutboundText(t, 'update_checklist'),
11895
+ )
11896
+
11897
+ await rawEditMessageChecklist({ chat_id, message_id, title: redactedTitle, tasks: redactedTasks })
10913
11898
 
10914
11899
  process.stderr.write(`telegram gateway: update_checklist: updated chatId=${chat_id} messageId=${message_id}\n`)
10915
11900
  return { content: [{ type: 'text', text: `checklist updated (id: ${message_id})` }] }
@@ -11637,8 +12622,18 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
11637
12622
  .join('; ')
11638
12623
  throw new Error(`inline_keyboard validation failed: ${summary}`)
11639
12624
  }
11640
- replyButtonMeta = extractAgentButtonMeta(rawKeyboard)
11641
- replyMarkup = { inline_keyboard: wrapAgentCallbacks(rawKeyboard) }
12625
+ // #3148 fast-follow: mask any secret an agent put in a visible button
12626
+ // `text` label, its `ack_text` toast, or a `copy_text.text` clipboard
12627
+ // payload BEFORE the keyboard is sent — the same outbound scrub the reply
12628
+ // `text` body uses. `callback_data` (the routing key) is left exact.
12629
+ // Feeding BOTH the meta extraction and the callback wrap from the redacted
12630
+ // copy means the stashed toast, the tap echo (`button_text`), and the
12631
+ // "✅ You chose: <label>" annotation (#789) all read already-masked bytes.
12632
+ const redactedKeyboard = redactAgentKeyboard(rawKeyboard, (s) =>
12633
+ redactOutboundText(s, 'reply_inline_keyboard'),
12634
+ )
12635
+ replyButtonMeta = extractAgentButtonMeta(redactedKeyboard)
12636
+ replyMarkup = { inline_keyboard: wrapAgentCallbacks(redactedKeyboard) }
11642
12637
  }
11643
12638
 
11644
12639
  // on-demand voice: append a single '🔊 Listen' button that synthesizes the
@@ -11963,13 +12958,15 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
11963
12958
  robustApiCall(
11964
12959
  // allow-raw-bot-api: injected chunk-loop adapter — sendRichMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks' fallback ladder
11965
12960
  () => lockedBot.api.sendRichMessage(chat_id, body as never, opts as never),
11966
- { threadId: tid, chat_id },
12961
+ // #3084 PR 2: the final reply is CRITICAL — never shed; degraded mode
12962
+ // fails fast (structured flood_wait) instead of blocking the MCP reply.
12963
+ { threadId: tid, chat_id, priorityClass: 'critical' },
11967
12964
  ),
11968
12965
  sendLiteral: (opts, txt, tid) =>
11969
12966
  robustApiCall(
11970
12967
  // allow-raw-bot-api: injected chunk-loop adapter — literal format:'text' send routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks
11971
12968
  () => lockedBot.api.sendMessage(chat_id, txt, opts as never),
11972
- { threadId: tid, chat_id },
12969
+ { threadId: tid, chat_id, priorityClass: 'critical' },
11973
12970
  ),
11974
12971
  sendLiteralRaw: (opts, txt) =>
11975
12972
  // allow-raw-bot-api: literal last-resort fallback (plaintext parse-reject / length re-split); wrapping would re-enter the parse/length policy that just rejected the payload
@@ -11981,7 +12978,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
11981
12978
  robustApiCall(
11982
12979
  // allow-raw-bot-api: preview edit-in-place routed through robustApiCall; thread fallback handled by sendReplyChunks
11983
12980
  () => lockedBot.api.editMessageText(chat_id, mid, body as never, opts as never),
11984
- { threadId: tid, chat_id },
12981
+ // Finalizing the reply into the preview message — still CRITICAL (this
12982
+ // IS the answer). Pass messageId/editPayload so the gate's per-message
12983
+ // floor + no-op skip engage on the edit (part3-design §4/§5, PR1 L1).
12984
+ { threadId: tid, chat_id, priorityClass: 'critical', messageId: mid, editPayload: body },
11985
12985
  ),
11986
12986
  richMessage,
11987
12987
  logOutbound,
@@ -12604,6 +13604,21 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
12604
13604
  const args = validateAskUserArgs(rawArgs as unknown as AskUserArgs)
12605
13605
  assertAllowedChat(args.chatId)
12606
13606
 
13607
+ // #2044 outbound secret scrub. The question text and each option/button
13608
+ // label are agent-authored free text sent to Telegram, exactly like the
13609
+ // reply `text` path — an agent that echoes a token or DATABASE_URL it just
13610
+ // read into a question or a button label would transmit it unmasked. Route
13611
+ // both through the SAME redactor as reply (via the pure, unit-tested
13612
+ // redactAskUserFields helper), assigning back onto the validated args so
13613
+ // every downstream consumer — the send, the timeout-edit re-render, and
13614
+ // the tap-echo of the chosen label stored in pendingAskUser — sees the
13615
+ // masked value.
13616
+ const scrubbed = redactAskUserFields(args.question, args.options, (t) =>
13617
+ redactOutboundText(t, 'ask_user'),
13618
+ )
13619
+ args.question = scrubbed.question
13620
+ args.options = scrubbed.options
13621
+
12607
13622
  // Resolve thread + reply-to using the same auto-thread heuristic
12608
13623
  // executeReply uses, so an agent that omits message_thread_id still
12609
13624
  // routes into the right forum topic and quotes the user's last
@@ -13704,9 +14719,11 @@ async function executeReact(args: Record<string, unknown>): Promise<unknown> {
13704
14719
  if (!args.message_id) throw new Error('react: message_id is required')
13705
14720
  if (!args.emoji) throw new Error('react: emoji is required')
13706
14721
  assertAllowedChat(String(args.chat_id ?? ''))
13707
- await lockedBot.api.setMessageReaction(String(args.chat_id ?? ''), Number(args.message_id), [
13708
- { type: 'emoji', emoji: args.emoji as ReactionTypeEmoji['emoji'] },
13709
- ])
14722
+ await sendReaction(
14723
+ String(args.chat_id ?? ''),
14724
+ Number(args.message_id),
14725
+ args.emoji as ReactionTypeEmoji['emoji'],
14726
+ )
13710
14727
  return { content: [{ type: 'text', text: 'reacted' }] }
13711
14728
  }
13712
14729
 
@@ -13846,6 +14863,12 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
13846
14863
  () => lockedBot.api.pinChatMessage(pinChatId, pinMsgId),
13847
14864
  { chat_id: pinChatId, verb: 'pin_message' },
13848
14865
  )
14866
+ // An explicit pin succeeded here, so the bot demonstrably HAS pin rights in
14867
+ // this chat now — clear any auto-pin negative-cache entry (#3024) so the auto
14868
+ // status-pin path resumes immediately rather than waiting for a restart. The
14869
+ // explicit tool itself never consults the cache; a failure above still
14870
+ // surfaces to the agent as a normal tool error (robustApiCall rethrows).
14871
+ statusPinRightsCache.clear(pinChatId)
13849
14872
  // #3001: register the tool pin in the shared status-pin store under a
13850
14873
  // `tool:` key so it is no longer fire-and-forget. Unlike work-scoped
13851
14874
  // fg:/wk: rows a tool pin has no "work finished" event, so a restart does
@@ -14962,6 +15985,21 @@ function handleSessionEvent(ev: SessionEvent): void {
14962
15985
  liveTurn.liveness.onStreamEvent(ev.kind, durationMs, Date.now())
14963
15986
  }
14964
15987
  }
15988
+ // Idle-clear clocks (#3084 follow-up). EVERY genuine session event is
15989
+ // activity — an agent that is thinking, calling a tool, streaming text or
15990
+ // driving a sub-agent is NOT idle, whether or not the gateway currently has a
15991
+ // turn open. Stamping only at turn START (the old behaviour) is what let a
15992
+ // 3-hour working stretch be scored as zero activity and `/clear`ed the moment
15993
+ // the window elapsed. A turn ending stamps the turn-end clock too, so a turn
15994
+ // that outran the window is not wiped the instant `turnInFlight` goes false.
15995
+ // This runs for the whole event stream, including every `sub_agent_*` kind —
15996
+ // background workers keep the timer warm exactly as long as they are working.
15997
+ {
15998
+ const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
15999
+ const signal = classifyIdleEvent(ev.kind, durationMs)
16000
+ if (signal.activity) markIdleActivity()
16001
+ if (signal.turnEnded) markIdleTurnEnd()
16002
+ }
14965
16003
  switch (ev.kind) {
14966
16004
  case 'enqueue': {
14967
16005
  // Drain any orphaned typing-wrap entries left over from a crashed
@@ -15100,7 +16138,9 @@ function handleSessionEvent(ev: SessionEvent): void {
15100
16138
  // per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
15101
16139
  // the SAME statusKey the ctor's façade was constructed with just above.
15102
16140
  setCurrentTurn(next, statusKey(ev.chatId, enqThreadIdNum))
15103
- markIdleActivity() // any turn start (main session) is activity re-arm idle clear
16141
+ // (turn start already stamped the idle clock at the top of
16142
+ // handleSessionEvent, along with every other session event — see the
16143
+ // idle-clear block there.)
15104
16144
  // Early-open the "Working…" liveness card at turn start so narration /
15105
16145
  // thinking emitted BEFORE the first tool surfaces within ~a second
15106
16146
  // instead of after the old 12 s threshold (the dead-air gap). Fires the
@@ -15584,6 +16624,14 @@ function handleSessionEvent(ev: SessionEvent): void {
15584
16624
  chat_id: chatId,
15585
16625
  verb: 'answer-stream.editMessageText',
15586
16626
  ...(tid != null ? { threadId: tid } : {}),
16627
+ // #3084 PR 2 / L1: answer-stream edits are COSMETIC — a
16628
+ // dropped stream tick costs nothing (the next carries full
16629
+ // text). messageId/editPayload engage the per-message floor +
16630
+ // coalescing + no-op skip so rapid stream edits don't storm
16631
+ // the same message (the top ban trigger).
16632
+ priorityClass: 'cosmetic',
16633
+ messageId,
16634
+ editPayload: richMessage(text),
15587
16635
  },
15588
16636
  )
15589
16637
  },
@@ -15777,6 +16825,24 @@ function handleSessionEvent(ev: SessionEvent): void {
15777
16825
  return
15778
16826
  }
15779
16827
  }
16828
+ // #2094 finding 1 — turn_end gate-wedge backstop. Capture the turn
16829
+ // BEFORE the body runs (the body re-reads currentTurn as `turn`, then
16830
+ // nulls it via endCurrentTurnAtomic on every clean branch). The guarded
16831
+ // finally in withTurnEndGateBackstop forces the canonical purge iff a
16832
+ // throw in a pre-purge op (redactOutboundText, progressDriver?.
16833
+ // takeOverCard, narrative dedup, answer-stream finalize, …) skipped
16834
+ // endCurrentTurnAtomic → purgeReactionTracking, which would otherwise
16835
+ // leave activeTurnStartedAt + claudeBusyKeys populated and wedge the
16836
+ // #1556 inbound gate closed. No-op on the happy path (key already gone).
16837
+ const turnEndBackstopTurn = currentTurn
16838
+ const turnEndBackstopKey =
16839
+ turnEndBackstopTurn != null
16840
+ ? statusKey(turnEndBackstopTurn.sessionChatId, turnEndBackstopTurn.sessionThreadId)
16841
+ : null
16842
+ withTurnEndGateBackstop(
16843
+ turnEndBackstopKey,
16844
+ turnEndBackstopTurn,
16845
+ () => {
15780
16846
  // Drain any still-pending tool dispatch typing entries — covers
15781
16847
  // transcript truncation or a Claude Code crash mid-tool.
15782
16848
  typingWrapper.drainAll()
@@ -16003,6 +17069,17 @@ function handleSessionEvent(ev: SessionEvent): void {
16003
17069
  capturedText: turn.capturedText,
16004
17070
  flushEnabled: TURN_FLUSH_SAFETY_ENABLED,
16005
17071
  })
17072
+ // #1667 — resolve the turn_end answer-delivery gate once, here, via the
17073
+ // pure decision core. The three dispositions below (silent-marker,
17074
+ // turn-flush, #1664 re-prompt) delegate to this so the gateway runs the
17075
+ // exact code the regression test exercises. `finalAnswerDelivered` is read
17076
+ // at its tail value: the answer-stream materialize branch above has
17077
+ // already run (and may have set it true); the flush branch, which also
17078
+ // sets it, is not yet entered and does not affect the gate's own outcome.
17079
+ const turnEndDecision = decideTurnEndGate({
17080
+ flushDecision,
17081
+ finalAnswerDelivered: turn.finalAnswerDelivered,
17082
+ })
16006
17083
  if (flushDecision.kind === 'skip' && flushDecision.reason !== 'reply-called') {
16007
17084
  process.stderr.write(
16008
17085
  `telegram gateway: turn-flush skipped — reason=${flushDecision.reason}\n`,
@@ -16041,7 +17118,7 @@ function handleSessionEvent(ev: SessionEvent): void {
16041
17118
  // 2. NOT send any reply message to the user.
16042
17119
  // 3. Unpin the progress card so no orphaned ⚙️ Working… lingers.
16043
17120
  // 4. Log at debug level and fall through to normal state cleanup.
16044
- if (flushDecision.kind === 'skip' && flushDecision.reason === 'silent-marker') {
17121
+ if (turnEndDecision === 'silent_end') {
16045
17122
  // Don't try to distinguish NO_REPLY vs HEARTBEAT_OK in the log line:
16046
17123
  // `isSilentFlushMarker` accepts trailing punctuation (e.g. "NO_REPLY.")
16047
17124
  // and case variants, so a strict equality check would print the wrong
@@ -16131,7 +17208,7 @@ function handleSessionEvent(ev: SessionEvent): void {
16131
17208
  return
16132
17209
  }
16133
17210
 
16134
- if (flushDecision.kind === 'flush') {
17211
+ if (turnEndDecision === 'flush' && flushDecision.kind === 'flush') {
16135
17212
  let capturedText = flushDecision.text
16136
17213
  // #2798 — turn-flush delivers the model's terminal prose when it
16137
17214
  // skipped reply/stream_reply, but historically bypassed the reply
@@ -16267,9 +17344,12 @@ function handleSessionEvent(ev: SessionEvent): void {
16267
17344
  // the reaction is only finalized by the `turn_end` IPC
16268
17345
  // handler — mid-turn delivery proofs (local history,
16269
17346
  // stream finalize callbacks, executeReply post-send) no
16270
- // longer transition the emoji. This branch just purges
16271
- // the per-turn reaction tracking entry and returns.
16272
- purgeReactionTracking(statusKey(backstopChatId, backstopThreadId))
17347
+ // longer transition the emoji. This branch just returns.
17348
+ // #2094 cosmetic: the per-turn reaction tracking was ALREADY
17349
+ // purged synchronously by endCurrentTurnAtomic (before this
17350
+ // async IIFE ran). The old redundant purgeReactionTracking
17351
+ // here re-fired on an already-cleared key WITHOUT `endingTurn`,
17352
+ // emitting an inconsistent shadow trace. Removed.
16273
17353
  return
16274
17354
  }
16275
17355
  } catch {}
@@ -16405,9 +17485,13 @@ function handleSessionEvent(ev: SessionEvent): void {
16405
17485
  // #1713: backstop send failed — finalize as error so the
16406
17486
  // turn ends cleanly with 😱 rather than leaving it open.
16407
17487
  if (backstopCtrl) backstopCtrl.finalize('error')
16408
- } finally {
16409
- purgeReactionTracking(statusKey(backstopChatId, backstopThreadId))
16410
17488
  }
17489
+ // #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
17490
+ // was removed. endCurrentTurnAtomic already ran the canonical purge
17491
+ // (with the authoritative `endingTurn`) synchronously before this
17492
+ // async IIFE started, so re-purging here only re-fired on an
17493
+ // already-cleared key without `endingTurn` — an inconsistent shadow
17494
+ // trace. The #2094 finding-1 backstop covers any pre-purge throw.
16411
17495
  })()
16412
17496
  return
16413
17497
  }
@@ -16496,7 +17580,10 @@ function handleSessionEvent(ev: SessionEvent): void {
16496
17580
  // HEARTBEAT_OK silent-marker turns return earlier and never reach
16497
17581
  // this path. The turn-flush 'flush' branch also returns earlier
16498
17582
  // (and sets finalAnswerDelivered=true defensively).
16499
- if (turn.finalAnswerDelivered === false) {
17583
+ // #1667 — this is the reply-called tail; `turnEndDecision === 'reprompt'`
17584
+ // is exactly `turn.finalAnswerDelivered === false` here (silent-marker
17585
+ // and flush both returned earlier), delegated to the pure gate core.
17586
+ if (turnEndDecision === 'reprompt') {
16500
17587
  // PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
16501
17588
  // wire the represent-guard-style staleness
16502
17589
  // check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
@@ -16600,6 +17687,14 @@ function handleSessionEvent(ev: SessionEvent): void {
16600
17687
  // #549 fix — preamble flush already happened at the TOP of this
16601
17688
  // turn_end handler (before turn.answerStream is nulled). See
16602
17689
  // comment near line 3431.
17690
+ return
17691
+ }, // end withTurnEndGateBackstop body (#2094 finding 1)
17692
+ {
17693
+ hasActiveTurn: (k) => activeTurnStartedAt.has(k),
17694
+ purge: (k, endingTurn) => purgeReactionTracking(k, endingTurn),
17695
+ log: (m) => process.stderr.write(m + '\n'),
17696
+ },
17697
+ )
16603
17698
  return
16604
17699
  }
16605
17700
  }
@@ -16607,6 +17702,17 @@ function handleSessionEvent(ev: SessionEvent): void {
16607
17702
 
16608
17703
  // ─── PTY partial handler ─────────────────────────────────────────────────
16609
17704
  function handlePtyPartial(text: string): void {
17705
+ // #2044 outbound secret scrub. The PTY-tail partial is the assistant's
17706
+ // reply text extracted from Claude Code's TUI as it renders — an agent
17707
+ // that echoes a secret shows it here (in the live draft-preview stream)
17708
+ // BEFORE the reply tool ever fires, and this path forwards the text
17709
+ // straight to Telegram with no other redaction. Mask at the gateway
17710
+ // boundary through the SAME reply-path redactor so the draft stream, its
17711
+ // dedup key, and lastPtyPreviewByChat all compare redacted-against-
17712
+ // redacted — the identical in-place-mutation invariant the answer stream
17713
+ // relies on. Idempotent: replaying a buffered (already-masked) partial
17714
+ // re-masks to itself.
17715
+ text = redactOutboundText(text, 'pty_preview')
16610
17716
  // #1067: build the PtyHandlerState from a snapshot of currentTurn.
16611
17717
  // The pty-partial-handler module keeps its own state-shape contract
16612
17718
  // (currentSessionChatId / currentSessionThreadId) because it's
@@ -16641,6 +17747,13 @@ function handlePtyPartial(text: string): void {
16641
17747
  }
16642
17748
 
16643
17749
  function handlePtyActivity(text: string): void {
17750
+ // #2044 outbound secret scrub. PTY-activity is agent-derived free text
17751
+ // forwarded to Telegram via handleStreamReply — same leak class as the
17752
+ // draft preview above. Redact through the reply-path redactor before it
17753
+ // streams. (This handler is currently unwired in the gateway — the live
17754
+ // PTY path is handlePtyPartial — but the mask is kept here so the fix is
17755
+ // durable if the activity lane is ever re-armed.)
17756
+ text = redactOutboundText(text, 'pty_activity')
16644
17757
  // #1067: snapshot at entry. handleStreamReply is async and runs in
16645
17758
  // the background via void; the closure already captures `chatId` /
16646
17759
  // `threadId` locals, so the supersession is correctly scoped.
@@ -16993,9 +18106,7 @@ function maybeEarlyAckReaction(ctx: Context, from: NonNullable<Context['from']>)
16993
18106
  if (activeTurnStartedAt.has(statusKey(chatId, threadId))) return
16994
18107
  const access = loadAccess()
16995
18108
  if (!access.allowFrom.includes(String(from.id))) return
16996
- void bot.api.setMessageReaction(chatId, msgId, [
16997
- { type: 'emoji', emoji: '👀' as ReactionTypeEmoji['emoji'] },
16998
- ]).catch(() => {})
18109
+ void sendReaction(chatId, msgId, '👀' as ReactionTypeEmoji['emoji']).catch(() => {})
16999
18110
  // #2527: log the early-ack fire so operators can see how often the
17000
18111
  // fast pre-coalesce DM path triggers vs. the controller path.
17001
18112
  logStreamingEvent({ kind: 'early_ack_reaction', chatId, messageId: msgId, emoji: '👀' })
@@ -17004,7 +18115,9 @@ function maybeEarlyAckReaction(ctx: Context, from: NonNullable<Context['from']>)
17004
18115
  // model text. No fake content — Telegram clients render this natively
17005
18116
  // and it auto-expires after ~5s if not refreshed (the answer-lane
17006
18117
  // first edit will land long before then under the new defaults).
17007
- void bot.api.sendChatAction(chatId, 'typing').catch(() => {})
18118
+ // Through the shared emitter (#3084): a cold chat still lights up
18119
+ // instantly; a chat already pinged inside the floor doesn't pay twice.
18120
+ emitChatAction(chatId, null, 'typing')
17008
18121
  }
17009
18122
 
17010
18123
  /**
@@ -17063,6 +18176,16 @@ async function handleInbound(
17063
18176
  // #2862 — operator is back; re-offer any approvals that timed out meanwhile.
17064
18177
  maybePostMissedApprovalDigest('operator inbound')
17065
18178
 
18179
+ // #3026 — first inbound from a DM after boot: clear any stale STACKED pins
18180
+ // the boot getChat() probe can't see (DMs skip the probe AND getChat only
18181
+ // exposes the newest pin). unpin-all once per DM chat per boot, then re-pin
18182
+ // live tracked cards. once-guarded (dedups with the boot sweep); no-op for
18183
+ // groups and until the startup mutex is won. Fire-and-forget.
18184
+ {
18185
+ const inboundChatId = ctx.chat?.id
18186
+ if (inboundChatId != null) void dmPinSweeper.sweep(String(inboundChatId))
18187
+ }
18188
+
17066
18189
  // Capture wall-clock receive time for inbound_ack metric (#203).
17067
18190
  // Must be after gate() so early-exit paths (drop/pair) don't skew the delta.
17068
18191
  //
@@ -17224,9 +18347,7 @@ async function handleInbound(
17224
18347
  )
17225
18348
  if (inFlight) {
17226
18349
  if (msgId != null) {
17227
- void bot.api.setMessageReaction(chat_id, msgId, [
17228
- { type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
17229
- ]).catch(() => {})
18350
+ void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
17230
18351
  }
17231
18352
  await executeHaltNow('stop-keyword')
17232
18353
  }
@@ -17271,9 +18392,7 @@ async function handleInbound(
17271
18392
  `in_flight=${toolFlightTracker.inFlightCount()}\n`,
17272
18393
  )
17273
18394
  if (msgId != null) {
17274
- void bot.api.setMessageReaction(chat_id, msgId, [
17275
- { type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
17276
- ]).catch(() => {})
18395
+ void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
17277
18396
  }
17278
18397
  if (interrupt.emptyBody) {
17279
18398
  // #3020: empty `!` is a pure halt (no replacement body) — same shared
@@ -17407,9 +18526,7 @@ async function handleInbound(
17407
18526
  })
17408
18527
  if (msgId != null) {
17409
18528
  const emoji = behavior === 'allow' ? '✅' : '❌'
17410
- void bot.api.setMessageReaction(chat_id, msgId, [
17411
- { type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
17412
- ]).catch(() => {})
18529
+ void sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji']).catch(() => {})
17413
18530
  }
17414
18531
  return
17415
18532
  }
@@ -17465,7 +18582,7 @@ async function handleInbound(
17465
18582
  )
17466
18583
  }
17467
18584
  // Redact the OAuth code paste from chat history (#488).
17468
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18585
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
17469
18586
  return
17470
18587
  }
17471
18588
  // Stale — drop the pending entry but let the message fall through
@@ -17474,6 +18591,101 @@ async function handleInbound(
17474
18591
  pendingAuthAddFlows.delete(interceptKey)
17475
18592
  }
17476
18593
 
18594
+ // Loopback OAuth relay paste-back intercept (issue #2582) — sibling to
18595
+ // the /auth add intercept above. When a Google/Microsoft loopback relay is
18596
+ // pending for this chat and the operator pastes their `127.0.0.1:<port>`
18597
+ // redirect URL, validate `state` and hand the code to the waiting CLI
18598
+ // listener. The LLM never sees the code (same hygiene rationale as above).
18599
+ // Consume-gate is deliberately narrow (PR #3100 review finding 1): only a
18600
+ // message that actually parses as a loopback redirect — carrying a code,
18601
+ // or a provider `error` param — is consumed and deleted. Unrelated chatter
18602
+ // that merely mentions localhost/127.0.0.1 flows through untouched.
18603
+ const pendingLoop = pendingLoopbackFlows.get(interceptKey)
18604
+ if (pendingLoop && shouldConsumeLoopbackPaste(text)) {
18605
+ const elapsed = Date.now() - pendingLoop.startedAt
18606
+ if (elapsed < REAUTH_INTERCEPT_TTL_MS) {
18607
+ if (pendingLoop.submitting) {
18608
+ // A submit is already in flight (double-paste race). Don't call
18609
+ // submitLoopbackRedirect — it would answer a non-retryable "already
18610
+ // completed" and we'd delete the entry out from under the first
18611
+ // submit. Benign ack; still redact (the paste carries a live code).
18612
+ await switchroomReply(
18613
+ ctx,
18614
+ '_Still finishing the previous paste — one moment._',
18615
+ { html: true },
18616
+ )
18617
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18618
+ return
18619
+ }
18620
+ const result = await submitLoopbackRedirect(pendingLoop, text.trim())
18621
+ if (result.ok) {
18622
+ pendingLoopbackFlows.delete(interceptKey)
18623
+ await switchroomReply(
18624
+ ctx,
18625
+ `✓ ${pendingLoop.provider === 'google' ? 'Google' : 'Microsoft'} account ` +
18626
+ `\`${escapeHtmlForTg(pendingLoop.email)}\` registered with the auth-broker.`,
18627
+ { html: true },
18628
+ )
18629
+ // Redact the pasted redirect (carries the OAuth code) from history.
18630
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18631
+ return
18632
+ }
18633
+ if (result.retryable) {
18634
+ // Keep the flow pending so the operator can paste again.
18635
+ await switchroomReply(
18636
+ ctx,
18637
+ `**Paste not accepted:** ${escapeHtmlForTg(result.reason)}\n` +
18638
+ `Re-open the consent URL, approve, and paste the full ` +
18639
+ `\`127.0.0.1\` URL from your address bar. \`/auth ${pendingLoop.provider} cancel\` to abort.`,
18640
+ { html: true },
18641
+ )
18642
+ // Redact even a rejected paste — it may still carry a live code.
18643
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18644
+ return
18645
+ }
18646
+ // Non-retryable — the flow is spent. Kill the CLI child before
18647
+ // dropping the entry (re-review finding, PR #3100): on the attempts-
18648
+ // exhausted path the child is still alive with a bound 127.0.0.1
18649
+ // listener and nothing else would ever reap it. cancelLoopbackFlow is
18650
+ // idempotent — safe on the already-exited / timed-out paths too.
18651
+ cancelLoopbackFlow(pendingLoop)
18652
+ pendingLoopbackFlows.delete(interceptKey)
18653
+ await switchroomReply(
18654
+ ctx,
18655
+ `**/auth ${pendingLoop.provider} add failed:** ${escapeHtmlForTg(result.reason)}`,
18656
+ { html: true },
18657
+ )
18658
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18659
+ return
18660
+ }
18661
+ // Stale — the intercept window has closed. Kill the child and drop the
18662
+ // entry, then fall through to the fail-safe below. We deliberately do NOT
18663
+ // let the paste reach the agent: the pasted `code` may still be live
18664
+ // (security audit #3084, F3), so the fail-safe redacts it.
18665
+ cancelLoopbackFlow(pendingLoop)
18666
+ pendingLoopbackFlows.delete(interceptKey)
18667
+ }
18668
+
18669
+ // Fail-safe redaction (security audit #3084, F2/F3). A message that looks
18670
+ // like a loopback OAuth redirect/code — even one too malformed to parse
18671
+ // cleanly, or one that arrived just after the intercept TTL closed, or one
18672
+ // with no active flow at all — must NEVER reach the (prompt-injectable)
18673
+ // agent session or linger unredacted in chat while carrying a possibly-live
18674
+ // credential. shouldConsumeLoopbackPaste is narrow (requires a loopback host
18675
+ // reference AND a code/error param), so ordinary chatter mentioning
18676
+ // localhost flows through untouched. Redact and drop rather than forward.
18677
+ if (shouldConsumeLoopbackPaste(text)) {
18678
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18679
+ await switchroomReply(
18680
+ ctx,
18681
+ '_That looked like an OAuth redirect/code, so I removed it from chat and did not forward it. ' +
18682
+ 'If a Google/Microsoft account add is in progress, re-run the add command and paste the fresh ' +
18683
+ '`127.0.0.1` URL — the previous code may have expired._',
18684
+ { html: true },
18685
+ )
18686
+ return
18687
+ }
18688
+
17477
18689
  // Auth-code intercept
17478
18690
  const pendingReauth = pendingReauthFlows.get(interceptKey)
17479
18691
  if (pendingReauth && looksLikeAuthCode(text)) {
@@ -17498,7 +18710,7 @@ async function handleInbound(
17498
18710
  // Single-use code so a third party can't replay it after exchange,
17499
18711
  // but plaintext OAuth tokens in chat history are still poor
17500
18712
  // hygiene. The helper handles delete + 🔑 reaction silently.
17501
- redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
18713
+ redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
17502
18714
  return
17503
18715
  }
17504
18716
  pendingReauthFlows.delete(interceptKey)
@@ -17707,11 +18919,8 @@ async function handleInbound(
17707
18919
  // Typing indicator in the ORIGINATING topic — on a supergroup-topic inbound,
17708
18920
  // an un-threaded sendChatAction shows "typing" in General, not the topic the
17709
18921
  // user is in. messageThreadId is the inbound's thread (undefined in a DM).
17710
- void bot.api.sendChatAction(
17711
- chat_id,
17712
- 'typing',
17713
- messageThreadId != null ? { message_thread_id: messageThreadId } : {},
17714
- ).catch(() => {})
18922
+ // Floor-gated through the shared emitter (#3084).
18923
+ emitChatAction(chat_id, messageThreadId ?? null, 'typing')
17715
18924
 
17716
18925
  // Parse explicit prefixes first. `/steer ` / `/s ` opts IN to steering;
17717
18926
  // `/queue ` / `/q ` are legacy aliases that opt in to the new default (queued).
@@ -17964,12 +19173,12 @@ async function handleInbound(
17964
19173
  if (isSteering) {
17965
19174
  // Explicit steer: mark with 🤝 on the inbound message; leave the
17966
19175
  // existing StatusReactionController running for the in-flight turn.
17967
- void bot.api.setMessageReaction(chat_id, msgId, [{ type: 'emoji', emoji: '🤝' }]).catch(() => {})
19176
+ void sendReaction(chat_id, msgId, '🤝').catch(() => {})
17968
19177
  } else if (priorTurnInFlight) {
17969
19178
  // Queued mid-turn message (new default): don't touch the existing
17970
19179
  // controller; just ack the inbound message with 👀 so the user
17971
19180
  // knows we received it, without disrupting the in-flight reaction.
17972
- void bot.api.setMessageReaction(chat_id, msgId, [{ type: 'emoji', emoji: '👀' }]).catch(() => {})
19181
+ void sendReaction(chat_id, msgId, '👀').catch(() => {})
17973
19182
  // #203: time-to-ack metric — measure gateway-receive → ack-post delta.
17974
19183
  logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
17975
19184
  } else {
@@ -18012,9 +19221,7 @@ async function handleInbound(
18012
19221
  // msgId here and use it as the reaction-session token in log events.
18013
19222
  const ctrlTurnToken = `${chat_id}:${msgId}`
18014
19223
  const ctrl = new StatusReactionController(async (emoji) => {
18015
- await bot.api.setMessageReaction(chat_id, msgId, [
18016
- { type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
18017
- ])
19224
+ await sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji'])
18018
19225
  // #203: every status-reaction transition is a user-visible signal.
18019
19226
  signalTracker.noteSignal(key, Date.now())
18020
19227
  }, allowedReactions, {
@@ -18097,9 +19304,7 @@ async function handleInbound(
18097
19304
  }
18098
19305
  }
18099
19306
  } else if (access.ackReaction) {
18100
- void bot.api.setMessageReaction(chat_id, msgId, [
18101
- { type: 'emoji', emoji: access.ackReaction as ReactionTypeEmoji['emoji'] },
18102
- ]).catch(() => {})
19307
+ void sendReaction(chat_id, msgId, access.ackReaction as ReactionTypeEmoji['emoji']).catch(() => {})
18103
19308
  // #203: time-to-ack metric for the custom-ack-reaction path.
18104
19309
  logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
18105
19310
  }
@@ -18792,6 +19997,17 @@ function escapeHtmlForTg(text: string): string {
18792
19997
  return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
18793
19998
  }
18794
19999
 
20000
+ // #789 — button-choice-confirmation ("✅ You chose: X") annotation state.
20001
+ //
20002
+ // Two once-per-process warning dedupe sets keyed by agent slug: one for the
20003
+ // parseMode gate (annotation only supports the default 'html' parse mode),
20004
+ // one for the single_use mismatch (a re-tappable keyboard can never be
20005
+ // annotated, so a confirmation request on it silently no-ops). The decision
20006
+ // + rendered text are pure functions in inline-keyboard-callbacks.ts
20007
+ // (resolveTapAnnotation) so they can be unit-tested against the exact payload.
20008
+ const buttonConfirmParseModeWarned = new Set<string>()
20009
+ const buttonConfirmSingleUseWarned = new Set<string>()
20010
+
18795
20011
  // Wrap CLI/command output in a fenced code block (content is literal there).
18796
20012
  function preBlock(text: string): string {
18797
20013
  return '```\n' + text.replace(/```/g, '`​``') + '\n```'
@@ -19431,7 +20647,7 @@ async function sweepBeforeSelfRestart(): Promise<void> {
19431
20647
  try {
19432
20648
  await sweepActiveReactions(
19433
20649
  agentDir,
19434
- (chatId, messageId) => lockedBot.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji: '👍' as ReactionTypeEmoji['emoji'] }]),
20650
+ (chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
19435
20651
  { log: (msg) => process.stderr.write(`telegram gateway: pre-restart reaction sweep — ${msg}\n`) },
19436
20652
  )
19437
20653
  } catch (err) {
@@ -20127,6 +21343,30 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
20127
21343
  auth: authSummary,
20128
21344
  audit: buildAgentAudit(agentName),
20129
21345
  live: await buildLiveProbeRows(agentName),
21346
+ sendGate: buildSendGateStatus(),
21347
+ }
21348
+ }
21349
+
21350
+ /**
21351
+ * Build the `/status` send-gate block (#3084 PR 3, part3-design §6). Returns
21352
+ * `undefined` when the gate flag is OFF so `/status` renders exactly as it did
21353
+ * before the gate existed. Queued / shed totals come from the live counters;
21354
+ * open flood windows are read from the persisted sibling file (already pruned).
21355
+ */
21356
+ function buildSendGateStatus(): AgentMetadata['sendGate'] {
21357
+ const s = sendGate.stats()
21358
+ if (!s.enabled) return undefined
21359
+ const openWindows = readFloodWindows(FLOOD_WINDOWS_PATH, Date.now()).map((w) => ({
21360
+ scopeKey: w.scopeKey,
21361
+ untilTs: w.untilTs,
21362
+ }))
21363
+ return {
21364
+ queued: s.global.queued,
21365
+ shed: s.global.shed,
21366
+ expired: s.global.expired,
21367
+ failedFast: s.global.failedFast,
21368
+ dropped: s.global.dropped,
21369
+ openWindows,
20130
21370
  }
20131
21371
  }
20132
21372
 
@@ -21447,15 +22687,25 @@ bot.command('update', async ctx => {
21447
22687
  const skipImages = passthrough.includes('--skip-images')
21448
22688
  const rebuild = passthrough.includes('--rebuild')
21449
22689
  const updateRequestId = hostdRequestId('gw-update')
21450
- const hostdResp = await tryHostdDispatch(getMyAgentName(), {
21451
- v: 1,
21452
- op: 'update_apply',
21453
- request_id: updateRequestId,
21454
- args: {
21455
- ...(skipImages ? { skip_images: true } : {}),
21456
- ...(rebuild ? { rebuild: true } : {}),
21457
- },
21458
- })
22690
+ // #1841 forward the cached operator passphrase as the 2nd factor when
22691
+ // hostd requires operator-attest on update_apply. No-op when the vault
22692
+ // is locked / no passphrase is cached (feature-off posture unchanged).
22693
+ const updatePassphrase = vaultPassphraseCache.get(chatId)?.passphrase
22694
+ const hostdResp = await tryHostdDispatch(
22695
+ getMyAgentName(),
22696
+ withOperatorAttestation(
22697
+ {
22698
+ v: 1,
22699
+ op: 'update_apply',
22700
+ request_id: updateRequestId,
22701
+ args: {
22702
+ ...(skipImages ? { skip_images: true } : {}),
22703
+ ...(rebuild ? { rebuild: true } : {}),
22704
+ },
22705
+ },
22706
+ updatePassphrase,
22707
+ ),
22708
+ )
21459
22709
  if (hostdResp === 'not-configured') {
21460
22710
  warnLegacySpawnIfHostdDisabled('update_apply')
21461
22711
  spawnSwitchroomDetached(
@@ -21705,6 +22955,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
21705
22955
  })
21706
22956
  pendingPermissions.delete(request_id)
21707
22957
  permCardStore.remove(request_id)
22958
+ reconcileBlockedApprovals()
21708
22959
  process.stderr.write(
21709
22960
  `[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
21710
22961
  )
@@ -23008,6 +24259,84 @@ bot.command("auth", async ctx => {
23008
24259
  return
23009
24260
  }
23010
24261
 
24262
+ // `/auth google add|cancel` and `/auth microsoft add|cancel` — the
24263
+ // Telegram-native OAuth loopback relay (issue #2582). Gateway-routed for the
24264
+ // same reason as `/auth add`: they drive a child-process listener lifecycle
24265
+ // the broker client can't model. Admin-gated identically.
24266
+ if (parsed.kind === 'provider-add' || parsed.kind === 'provider-cancel') {
24267
+ if (!isAuthAdmin({ isAdmin })) {
24268
+ await switchroomReply(
24269
+ ctx,
24270
+ `**Not authorized.** \`/auth ${parsed.provider}\` is admin-only.\n` +
24271
+ `Set \`admin: true\` on this agent in switchroom.yaml to unlock.`,
24272
+ { html: true },
24273
+ )
24274
+ return
24275
+ }
24276
+ const loopKey = chatKey(chatId, ctx.message?.message_thread_id ?? null) as string
24277
+ if (parsed.kind === 'provider-cancel') {
24278
+ const existing = pendingLoopbackFlows.get(loopKey)
24279
+ if (!existing) {
24280
+ await switchroomReply(ctx, `_No pending \`/auth ${parsed.provider} add\` flow in this chat._`, { html: true })
24281
+ return
24282
+ }
24283
+ cancelLoopbackFlow(existing)
24284
+ pendingLoopbackFlows.delete(loopKey)
24285
+ await switchroomReply(ctx, 'Cancelled.', { html: true })
24286
+ return
24287
+ }
24288
+ // parsed.kind === 'provider-add'
24289
+ if (pendingLoopbackFlows.has(loopKey)) {
24290
+ await switchroomReply(
24291
+ ctx,
24292
+ `_An \`/auth ${parsed.provider} add\` flow is already in progress for this chat. ` +
24293
+ `Finish the paste, or send \`/auth ${parsed.provider} cancel\` to abort._`,
24294
+ { html: true },
24295
+ )
24296
+ return
24297
+ }
24298
+ try {
24299
+ const { consentUrl, state, port, child } = await startLoopbackFlow(
24300
+ parsed.provider,
24301
+ parsed.email,
24302
+ { replace: parsed.replace, write: parsed.write, orgMode: parsed.orgMode },
24303
+ )
24304
+ const newFlow = {
24305
+ provider: parsed.provider,
24306
+ email: parsed.email,
24307
+ state,
24308
+ port,
24309
+ consentUrl,
24310
+ child,
24311
+ startedAt: Date.now(),
24312
+ submitting: false,
24313
+ attempts: 0,
24314
+ }
24315
+ // Record child exit on the flow so a pre-paste crash fails fast
24316
+ // instead of hanging out the completion timeout (PR #3100 finding 4).
24317
+ trackFlowExit(newFlow)
24318
+ pendingLoopbackFlows.set(loopKey, newFlow)
24319
+ const providerName = parsed.provider === 'google' ? 'Google' : 'Microsoft'
24320
+ await switchroomReply(
24321
+ ctx,
24322
+ `**Adding ${providerName} account** \`${escapeHtmlForTg(parsed.email)}\`\n\n` +
24323
+ `1. Open this URL on your phone and approve:\n${consentUrl}\n\n` +
24324
+ `2. The redirect to \`127.0.0.1\` will fail to load — that's expected.\n` +
24325
+ `3. Copy the **full URL** from your browser's address bar (it holds ` +
24326
+ `\`?code=...&state=...\`) and paste it back here.\n\n` +
24327
+ `Send \`/auth ${parsed.provider} cancel\` to abort.`,
24328
+ { html: true },
24329
+ )
24330
+ } catch (err) {
24331
+ await switchroomReply(
24332
+ ctx,
24333
+ `**/auth ${parsed.provider} add failed:** ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
24334
+ { html: true },
24335
+ )
24336
+ }
24337
+ return
24338
+ }
24339
+
23011
24340
  const client = await getAuthBrokerClient(currentAgent)
23012
24341
  if (!client) {
23013
24342
  await switchroomReply(ctx, "**/auth unavailable:** auth-broker client is not loaded (post-RFC-H rewire in progress?).", { html: true })
@@ -23201,6 +24530,9 @@ const callbackQueryHandlers = createCallbackQueryHandlers({
23201
24530
  getAdminOnlyKeys: () => ADMIN_ONLY_KEYS,
23202
24531
  vaultKeyRegex: VAULT_KEY_REGEX,
23203
24532
  mentalModelProposeTtlMs: MENTAL_MODEL_PROPOSE_TTL_MS,
24533
+ // #2975 Stage 1 — loud-failure funnel for a rate-window retry that also
24534
+ // failed (cooldown + record + broadcast in one place).
24535
+ emitOperatorEvent: emitGatewayOperatorEvent,
23204
24536
  })
23205
24537
  const {
23206
24538
  handleVaultRecentDenialCallback,
@@ -23691,7 +25023,18 @@ bot.command('usage', async ctx => {
23691
25023
  // /auth snapshot does. switchroomReply routes through the rich path
23692
25024
  // (replyWithRichMessage), which accepts reply_markup. Build a grammy
23693
25025
  // InlineKeyboard so the markup type matches switchroomReply's contract.
23694
- const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
25026
+ // Defense-in-depth (mirrors the operator-private `/auth` treatment,
25027
+ // gateway.ts ~24036): outside a private chat, strip the
25028
+ // `auth:use:<label>` "Switch fleet" rows so the fleet-wide account-
25029
+ // swap button is never even offered in a group/forum. The dispatch-
25030
+ // site allowFrom gate is the load-bearing control; this just avoids
25031
+ // dangling a privileged button in front of non-operators.
25032
+ let kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
25033
+ if (ctx.chat?.type !== 'private') {
25034
+ kbRows = kbRows.filter(
25035
+ (row) => !row.some((b) => b.callbackData?.startsWith('auth:use:')),
25036
+ )
25037
+ }
23695
25038
  const keyboard = new InlineKeyboard()
23696
25039
  kbRows.forEach((row, ri) => {
23697
25040
  if (ri > 0) keyboard.row()
@@ -23819,7 +25162,19 @@ bot.on('callback_query:data', async ctx => {
23819
25162
  // Auth dashboard buttons (`auth:<verb>:<agent>[:<slot>]`). Route
23820
25163
  // through a dedicated handler that maps each action onto the
23821
25164
  // existing CLI invocations plus dashboard refresh.
25165
+ //
25166
+ // Strict allowFrom gate like every other mutating callback family
25167
+ // (`eff:`/`apv:`/`cfg:`/`cn:`/`mdl:`). Its absence was a vulnerability:
25168
+ // `auth:use:<label>` drives `client.setActive(label)`, a fleet-wide
25169
+ // OAuth account swap — on an admin forum/supergroup agent with an empty
25170
+ // group allowFrom, any member could tap it and swap the active account.
23822
25171
  if (data.startsWith('auth:')) {
25172
+ const access = loadAccess()
25173
+ const senderId = String(ctx.from?.id ?? '')
25174
+ if (!access.allowFrom.includes(senderId)) {
25175
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
25176
+ return
25177
+ }
23823
25178
  await handleAuthDashboardCallback(ctx)
23824
25179
  return
23825
25180
  }
@@ -24634,8 +25989,74 @@ bot.on('callback_query:data', async ctx => {
24634
25989
  // opts out via `single_use: false`. With no stashed meta (e.g.
24635
25990
  // gateway restarted between send and tap) the default fires too,
24636
25991
  // which is the desired UX.
24637
- const stripKeyboard = metaForMessage == null || keyboardIsSingleUse(metaForMessage)
24638
- if (stripKeyboard && cbMessageId != null) {
25992
+ const singleUse = metaForMessage == null || keyboardIsSingleUse(metaForMessage)
25993
+
25994
+ // Source message body text — photos/stickers/etc. carry no `text`.
25995
+ const cbMsg = ctx.callbackQuery?.message
25996
+ const sourceText = cbMsg && 'text' in cbMsg && typeof cbMsg.text === 'string'
25997
+ ? cbMsg.text
25998
+ : undefined
25999
+
26000
+ // #789: pure decision for the "✅ You chose: X" body annotation. Per-message
26001
+ // override (the tapped button's inline_keyboard_confirm) wins over the agent
26002
+ // default; fires only on single-use keyboards with body text + a label and
26003
+ // the default 'html' parse mode.
26004
+ const annotation = resolveTapAnnotation({
26005
+ ...(tapMeta?.inline_keyboard_confirm != null
26006
+ ? { perMessageOverride: tapMeta.inline_keyboard_confirm }
26007
+ : {}),
26008
+ singleUse,
26009
+ ...(access.button_choice_confirmation != null
26010
+ ? { config: access.button_choice_confirmation }
26011
+ : {}),
26012
+ parseMode: access.parseMode ?? 'html',
26013
+ ...(sourceText != null ? { sourceText } : {}),
26014
+ ...(buttonText != null ? { label: buttonText } : {}),
26015
+ // escapeLabel deliberately omitted → defaults to the real HTML-entity
26016
+ // escaper (escapeHtmlEntities). The GFM-markdown escaper
26017
+ // (escapeHtmlForTg, #2669) is WRONG here: it doesn't escape &/</> (a
26018
+ // label containing them would 400 the HTML editMessageText) and it
26019
+ // garbles text under HTML parse mode (`Do_it` → `Do\_it`).
26020
+ })
26021
+
26022
+ const warnKey = process.env.SWITCHROOM_AGENT_NAME ?? ''
26023
+ // Round-1 minor finding: a confirmation requested on a single_use:false
26024
+ // (re-tappable) keyboard can never fire. Warn once per agent-process.
26025
+ if (annotation.warnSingleUseMismatch && !buttonConfirmSingleUseWarned.has(warnKey)) {
26026
+ buttonConfirmSingleUseWarned.add(warnKey)
26027
+ process.stderr.write(
26028
+ `telegram gateway: button_choice_confirmation skipped — inline_keyboard_confirm requested on a single_use:false (re-tappable) keyboard; annotation only fires on single-use keyboards (#789)\n`,
26029
+ )
26030
+ }
26031
+ // Parse-mode gate (Blocker 1): annotation is emitted with HTML. Warn once
26032
+ // per agent-process when a non-html parseMode suppresses it.
26033
+ if (annotation.warnParseMode && !buttonConfirmParseModeWarned.has(warnKey)) {
26034
+ buttonConfirmParseModeWarned.add(warnKey)
26035
+ process.stderr.write(
26036
+ `telegram gateway: button_choice_confirmation skipped — agent parseMode=${access.parseMode ?? 'html'}, only 'html' supported (#789)\n`,
26037
+ )
26038
+ }
26039
+
26040
+ if (annotation.annotate && cbMessageId != null) {
26041
+ // Single API call (Blocker 2): editMessageText accepts reply_markup, so
26042
+ // we annotate the body AND strip the keyboard in one edit. If the edit
26043
+ // rejects (400 on an HTML edge case, over-long body, too-old message…),
26044
+ // applyTapAnnotationEdit falls back to a keyboard-only strip so
26045
+ // single-use protection still holds — the meta is deleted below either
26046
+ // way, so a swallowed failure must not leave the keyboard live.
26047
+ await applyTapAnnotationEdit(
26048
+ {
26049
+ editMessageText: (text, other) => ctx.editMessageText(text, other),
26050
+ editMessageReplyMarkup: other => ctx.editMessageReplyMarkup(other),
26051
+ },
26052
+ annotation.text as string,
26053
+ )
26054
+ if (metaForMessage != null) {
26055
+ agentButtonMeta.delete(`${cbChatId}:${cbMessageId}`)
26056
+ }
26057
+ } else if (singleUse && cbMessageId != null) {
26058
+ // No annotation (disabled, non-single-use, non-html parseMode, or no
26059
+ // body text) — preserve the historical keyboard-only strip.
24639
26060
  await ctx.editMessageReplyMarkup({
24640
26061
  reply_markup: { inline_keyboard: [] },
24641
26062
  }).catch(() => {})
@@ -24714,6 +26135,7 @@ bot.on('callback_query:data', async ctx => {
24714
26135
 
24715
26136
  pendingPermissions.delete(request_id)
24716
26137
  permCardStore.remove(request_id)
26138
+ reconcileBlockedApprovals()
24717
26139
 
24718
26140
  // (2) Dispatch the in-flight permission verdict IMMEDIATELY — before
24719
26141
  // any host round-trip — so the turn never blocks on persistence.
@@ -25009,6 +26431,7 @@ bot.on('callback_query:data', async ctx => {
25009
26431
  const grantAgent = selfAgentName()
25010
26432
  pendingPermissions.delete(request_id)
25011
26433
  permCardStore.remove(request_id)
26434
+ reconcileBlockedApprovals()
25012
26435
  if (timeBox && grantAgent) {
25013
26436
  recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
25014
26437
  // Write-through so the window survives a gateway restart (#2863). Absolute
@@ -25453,9 +26876,7 @@ async function handleAckOnly(
25453
26876
  const chat_id = String(ctx.chat!.id)
25454
26877
  const msgId = ctx.message?.message_id
25455
26878
  if (msgId != null) {
25456
- void bot.api.setMessageReaction(chat_id, msgId, [
25457
- { type: 'emoji', emoji: (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji'] },
25458
- ]).catch(() => {})
26879
+ void sendReaction(chat_id, msgId, (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji']).catch(() => {})
25459
26880
  }
25460
26881
  const prefix = opts.warn ? 'WARN ' : ''
25461
26882
  process.stderr.write(`telegram gateway: ${prefix}inbound ${kind} ack-only chat_id=${chat_id} from=${ctx.from?.id ?? '?'}\n`)
@@ -25488,9 +26909,7 @@ async function handleRefusal(
25488
26909
  const msgId = ctx.message?.message_id
25489
26910
  const messageThreadId = ctx.message?.message_thread_id
25490
26911
  if (msgId != null) {
25491
- void bot.api.setMessageReaction(chat_id, msgId, [
25492
- { type: 'emoji', emoji: '🚫' as ReactionTypeEmoji['emoji'] },
25493
- ]).catch(() => {})
26912
+ void sendReaction(chat_id, msgId, '🚫' as ReactionTypeEmoji['emoji']).catch(() => {})
25494
26913
  }
25495
26914
  // #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND so a
25496
26915
  // deleted topic doesn't crash the refusal handler.
@@ -26049,16 +27468,22 @@ function flushReactionBatch(batch: ReactionBatch): void {
26049
27468
  text,
26050
27469
  meta,
26051
27470
  }
26052
- const delivered = ipcServer.sendToAgent(agentName, inbound)
26053
- if (delivered) markClaudeBusyForInbound(inbound)
27471
+ // #2094 finding 3 — route the reaction flush through the SAME #1556
27472
+ // decideInboundDelivery gate every other synthetic inbound uses (via
27473
+ // deliverResumeSyntheticOrBuffer), instead of a raw ipcServer.sendToAgent.
27474
+ // A reaction that lands WHILE a turn is in flight was previously fired
27475
+ // mid-turn — the bridge typed it into the CLI composer where it stranded
27476
+ // by the turn-completion race (the #1556 composer wedge). Now a mid-turn
27477
+ // reaction buffers-until-idle (the turn-complete hook + idle-drain timer
27478
+ // flush pendingInboundBuffer the instant claude goes idle, landing cleanly
27479
+ // as a fresh turn); an idle reaction delivers now, buffering only on a
27480
+ // genuine bridge-offline miss — the #1150 buffer-on-failure guarantee,
27481
+ // preserved inside the helper.
27482
+ const delivered = deliverResumeSyntheticOrBuffer(agentName, inbound)
26054
27483
  process.stderr.write(
26055
27484
  `telegram gateway: reactions.dispatch agent=${agentName} chat=${batch.chatId} ` +
26056
27485
  `count=${batch.reactions.length} batched=${batch.batched} delivered=${delivered}\n`,
26057
27486
  )
26058
- // #1150: buffer-on-failure for reaction-triggered wake-ups too.
26059
- if (!delivered) {
26060
- pendingInboundBuffer.push(agentName, inbound)
26061
- }
26062
27487
  }
26063
27488
 
26064
27489
  // ─── Inbound message_reaction handler ────────────────────────────────────
@@ -26506,6 +27931,10 @@ async function shutdown(signal: string): Promise<void> {
26506
27931
  // Now finish the cleanup the drain didn't touch.
26507
27932
  inboundCoalescer.reset()
26508
27933
  pendingReauthFlows.clear()
27934
+ // Kill any in-flight loopback relay CLI children so they don't outlive the
27935
+ // gateway with a bound 127.0.0.1 listener (issue #2582).
27936
+ for (const [, v] of pendingLoopbackFlows) cancelLoopbackFlow(v)
27937
+ pendingLoopbackFlows.clear()
26509
27938
  pendingVaultOps.clear()
26510
27939
  pendingPermissions.clear()
26511
27940
  permissionTimeoutSignatures.clear()
@@ -26539,7 +27968,7 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
26539
27968
  if (startupAgentDir != null) {
26540
27969
  void sweepActiveReactions(
26541
27970
  startupAgentDir,
26542
- (chatId, messageId) => lockedBot.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji: '👍' as ReactionTypeEmoji['emoji'] }]),
27971
+ (chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
26543
27972
  { log: (msg) => process.stderr.write(`telegram gateway: startup reaction sweep — ${msg}\n`) },
26544
27973
  )
26545
27974
  }
@@ -26949,6 +28378,22 @@ void (async () => {
26949
28378
  )
26950
28379
  }
26951
28380
 
28381
+ // #3084 follow-up — drop a blocked-approval record orphaned by a restart
28382
+ // mid-hold. `pendingPermissions` is in-memory and empty on a fresh
28383
+ // process, so this reconciles the surface to "nothing held". Without it,
28384
+ // a gateway restart during a flood ban would leave a record on disk that
28385
+ // nothing ever clears, and the dashboard would show a permanently
28386
+ // blocked agent. The ASK itself survives: the bridge re-sends unresolved
28387
+ // permission requests on IPC reconnect, which re-raises them (and
28388
+ // re-marks them held if the channel is still shut).
28389
+ try {
28390
+ reconcileBlockedApprovals()
28391
+ } catch (err) {
28392
+ process.stderr.write(
28393
+ `telegram gateway: blocked-approval boot reconcile failed: ${(err as Error).message}\n`,
28394
+ )
28395
+ }
28396
+
26952
28397
  // Boot-time pin sweep
26953
28398
  try {
26954
28399
  const bootAccess = loadAccess()
@@ -27382,7 +28827,8 @@ void (async () => {
27382
28827
  richMessage(text),
27383
28828
  sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
27384
28829
  ),
27385
- { chat_id: cid, verb: 'worker-feed' },
28830
+ // #3084 PR 2: worker-feed card CREATION (handback) is USEFUL.
28831
+ { chat_id: cid, verb: 'worker-feed', priorityClass: 'useful' },
27386
28832
  )
27387
28833
  return sent as { message_id: number }
27388
28834
  },
@@ -27395,7 +28841,15 @@ void (async () => {
27395
28841
  richMessage(text),
27396
28842
  editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
27397
28843
  ),
27398
- { chat_id: cid, verb: 'worker-feed' },
28844
+ // Worker-feed EDITs are COSMETIC — pass messageId/editPayload
28845
+ // so the per-message floor + coalescing + no-op skip engage.
28846
+ {
28847
+ chat_id: cid,
28848
+ verb: 'worker-feed',
28849
+ priorityClass: 'cosmetic',
28850
+ messageId: mid,
28851
+ editPayload: text,
28852
+ },
27399
28853
  ),
27400
28854
  },
27401
28855
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
@@ -27423,30 +28877,41 @@ void (async () => {
27423
28877
  // Best-effort: a registry read failure (e.g. no worktree dir
27424
28878
  // on an agent that never claims one) must not affect the
27425
28879
  // primary agentCwd watch.
27426
- extraWatchCwdsProvider: () =>
27427
- // Fail-CLOSED ownership filter (unset identity nothing;
27428
- // ownerless records excluded; registry throw []). Extracted
27429
- // to telegram-plugin/worktree-watch-cwds.ts so the #1116 /
27430
- // Gap-2 ownership predicate is under direct unit test — see
27431
- // telegram-plugin/tests/worktree-watch-cwds.test.ts.
27432
- ownedWorktreeCwds({
27433
- self: process.env.SWITCHROOM_AGENT_NAME,
27434
- listRecords: listWorktreeRecords,
27435
- // Durable, non-env identity fallback (#1116 / #2893): when
27436
- // SWITCHROOM_AGENT_NAME is somehow unset, derive this
27437
- // agent's own identity from its own directory so worktree
27438
- // ownership still resolves (env is only the fast path).
27439
- // `watcherAgentDir` is guaranteed non-null in this branch
27440
- // (the whole watcher is gated on it above). Kill-switch
27441
- // SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the
27442
- // pre-fix env-only behaviour.
27443
- agentDir:
27444
- process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
27445
- ? undefined
27446
- : watcherAgentDir,
27447
- log: (msg) =>
27448
- process.stderr.write(`telegram gateway: ${msg}\n`),
27449
- }),
28880
+ // On every ~1s rescan tick the provider does BOTH:
28881
+ // 1. advances the heartbeat of every worktree THIS agent owns
28882
+ // the F1/H3 production driver that keeps `touchHeartbeat`
28883
+ // alive (it had ZERO callers, so every claim read "stale"
28884
+ // 10 min after creation and the reaper's staleness
28885
+ // guarantee collapsed); throttled to ≤1 write / 2 min per
28886
+ // record, and
28887
+ // 2. returns the fail-CLOSED set of owned worktree cwds for the
28888
+ // watcher to also watch (#1116 / Gap-2 ownership predicate).
28889
+ // Extracted to telegram-plugin/worktree-watch-cwds.ts as
28890
+ // `makeWorktreeWatchProvider` so the wiring specifically that
28891
+ // the provider ACTUALLY drives heartbeats, not just cwds — is
28892
+ // under direct unit test (see the provider behaviour test in
28893
+ // telegram-plugin/tests/worktree-watch-cwds.test.ts). Fully
28894
+ // best-effort: it never throws out of the provider.
28895
+ extraWatchCwdsProvider: makeWorktreeWatchProvider({
28896
+ self: process.env.SWITCHROOM_AGENT_NAME,
28897
+ // Durable, non-env identity fallback (#1116 / #2893): when
28898
+ // SWITCHROOM_AGENT_NAME is somehow unset, derive this agent's
28899
+ // own identity from its own directory so worktree ownership
28900
+ // still resolves (env is only the fast path). `watcherAgentDir`
28901
+ // is guaranteed non-null in this branch (the whole watcher is
28902
+ // gated on it above). Kill-switch
28903
+ // SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the pre-fix
28904
+ // env-only behaviour. One `agentDir` governs BOTH the heartbeat
28905
+ // refresh and the cwd derivation.
28906
+ agentDir:
28907
+ process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
28908
+ ? undefined
28909
+ : watcherAgentDir,
28910
+ listRecords: listWorktreeRecords,
28911
+ touchHeartbeat: touchWorktreeHeartbeat,
28912
+ log: (msg) =>
28913
+ process.stderr.write(`telegram gateway: ${msg}\n`),
28914
+ }),
27450
28915
  // Bug 0 fix: previously omitted, leaving the watcher unable to
27451
28916
  // write liveness/stall/turn_end updates to the registry DB.
27452
28917
  // Liveness writes are now persisted across the gateway lifetime.