switchroom 0.18.3 β†’ 0.18.7

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 (156) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +386 -128
  6. package/dist/host-control/main.js +4 -2
  7. package/dist/vault/approvals/kernel-server.js +3 -1
  8. package/dist/vault/broker/server.js +38 -8
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-release/SKILL.md +78 -0
  14. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
  17. package/telegram-plugin/dist/server.js +24 -0
  18. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  19. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  20. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  21. package/telegram-plugin/gateway/gateway.ts +1331 -151
  22. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  23. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  24. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  25. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  27. package/telegram-plugin/gateway/model-command.ts +212 -51
  28. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  29. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  31. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  32. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  33. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  34. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  36. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  37. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  38. package/telegram-plugin/model-label.ts +69 -0
  39. package/telegram-plugin/operator-events.ts +45 -0
  40. package/telegram-plugin/pending-work-progress.ts +42 -7
  41. package/telegram-plugin/permission-diff.ts +128 -0
  42. package/telegram-plugin/quota-bar-format.ts +360 -0
  43. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  44. package/telegram-plugin/registry/subagents.test.ts +90 -0
  45. package/telegram-plugin/session-tail.ts +28 -0
  46. package/telegram-plugin/silent-end.ts +49 -4
  47. package/telegram-plugin/subagent-watcher.ts +249 -46
  48. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  49. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  51. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  52. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  53. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  54. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  55. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  56. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  57. package/telegram-plugin/tests/model-command.test.ts +202 -42
  58. package/telegram-plugin/tests/model-label.test.ts +64 -0
  59. package/telegram-plugin/tests/operator-events.test.ts +17 -0
  60. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  61. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  62. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  63. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  64. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  65. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  66. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  68. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  69. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  70. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  71. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  72. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  73. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  74. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  75. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  76. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  77. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  78. package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
  79. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  80. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  81. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  82. package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
  83. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  84. package/telegram-plugin/tool-activity-summary.ts +22 -2
  85. package/telegram-plugin/typing-wrap.ts +72 -25
  86. package/telegram-plugin/worker-activity-feed.ts +229 -15
  87. package/profiles/default/CLAUDE.md +0 -116
  88. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  89. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -126,6 +126,14 @@ import {
126
126
  } from './permission-timeout.js'
127
127
  import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
128
128
  import { createPermissionCardStore, type PersistedPermCard } from './permission-card-store.js'
129
+ import { createPendingCardStore, type PersistedApprovalCard } from './pending-card-store.js'
130
+ import {
131
+ buildVaultAccessTimeoutInbound,
132
+ buildVaultSaveTimeoutInbound,
133
+ buildSecretRequestTimeoutInbound,
134
+ buildMentalModelProposeTimeoutInbound,
135
+ } from './approval-timeout-inbound-builders.js'
136
+ import { expirePendingCard, sweepExpiredEntries } from './pending-card-expiry.js'
129
137
  import {
130
138
  isPermissionRearmEnabled,
131
139
  permissionRearmGraceMs,
@@ -134,6 +142,11 @@ import {
134
142
  distinctRequestIds,
135
143
  } from './permission-rearm.js'
136
144
  import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
145
+ import {
146
+ createAlwaysAllowPersistQueue,
147
+ drainAlwaysAllowPersistQueue,
148
+ type AlwaysAllowDrainDeps,
149
+ } from './always-allow-persist-queue.js'
137
150
  import {
138
151
  renderMissedApprovalsDigest,
139
152
  missedApprovalsKeyboard,
@@ -143,6 +156,8 @@ import {
143
156
  import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
144
157
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
145
158
  import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
159
+ import { formatModelLabel } from '../model-label.js'
160
+ import { createSessionModelSource } from './session-model-source.js'
146
161
  import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
147
162
  import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
148
163
  import { toolLabel } from '../tool-labels.js'
@@ -185,6 +200,12 @@ import {
185
200
  import { clearStaleTelegramPollingState } from '../startup-reset.js'
186
201
  import { gatewayStartupRetry } from './startup-network-retry.js'
187
202
  import { writeQuarantineMarker } from './quarantine.js'
203
+ import {
204
+ runPersonDirectoryBootCheck,
205
+ safeResolvePersonName,
206
+ type PersonDirectory,
207
+ type RawPersonEntry,
208
+ } from './resolve-person.js'
188
209
  // RFC H Β§7.3: auth-dashboard + auth-slot-parser deleted. Three chat
189
210
  // verbs (/auth show | use | rotate) talk to switchroom-auth-broker
190
211
  // via the thin client in src/auth/broker/client.ts.
@@ -390,7 +411,7 @@ import {
390
411
  _resetHostdEnabledCache,
391
412
  } from './hostd-dispatch.js'
392
413
  import { formatUpdateStatusLine } from './update-status-line.js'
393
- import type { HostdRequest } from '../../src/host-control/protocol.js'
414
+ import type { HostdRequest, HostdResponse } from '../../src/host-control/protocol.js'
394
415
  import type { AgentAudit } from '../welcome-text.js'
395
416
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
396
417
  import { startWebhookIngestServer } from './webhook-ingest-server.js'
@@ -524,6 +545,7 @@ import type {
524
545
  InjectInboundMessage,
525
546
  SendOutboundMessage,
526
547
  QuotaWallDetectedMessage,
548
+ QueryPendingPermissionMessage,
527
549
  PostSkillProposalMessage,
528
550
  PermissionEvent,
529
551
  RolloutStatusPostMessage,
@@ -555,6 +577,7 @@ import {
555
577
  clearCleanShutdownMarker,
556
578
  shouldSuppressRecoveryBanner,
557
579
  shouldSuppressBootResume,
580
+ parseBootResumeMode,
558
581
  resolveShutdownMarker,
559
582
  DEFAULT_MAX_AGE_MS as CLEAN_SHUTDOWN_MAX_AGE_MS,
560
583
  } from './clean-shutdown-marker.js'
@@ -671,9 +694,11 @@ import {
671
694
  import {
672
695
  buildResumeInterruptedInbound,
673
696
  buildResumeWatchdogReportInbound,
674
- selectResumeBuilder,
697
+ buildResumeDeferredReportInbound,
698
+ decideBootResumeKind,
675
699
  } from './resume-inbound-builder.js'
676
- import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
700
+ import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
701
+ import type { InterruptedSubagent } from './resume-inbound-builder.js'
677
702
  import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
678
703
  import {
679
704
  resolveSubagentStatusSurface,
@@ -711,16 +736,137 @@ process.on('beforeExit', () => {
711
736
  // ─── Env + state dir ──────────────────────────────────────────────────────
712
737
  const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
713
738
  const permCardStore = createPermissionCardStore(STATE_DIR)
739
+ // Durable store for the four AGENT-INITIATED approval-card families
740
+ // (vault_request_access / vault_request_save / request_secret /
741
+ // mental_model_propose). Persists card METADATA only β€” never any secret value
742
+ // (see pending-card-store.ts secrets-hygiene note). Restored at boot so a
743
+ // post-restart tap on a still-valid card works like a pre-restart tap, and
744
+ // swept in the pendingStateReaper so an unanswered card wakes the parked agent
745
+ // on TTL instead of leaving it parked forever.
746
+ const pendingCardStore = createPendingCardStore(STATE_DIR)
714
747
  // #2862 β€” missed-approvals re-offer. Persisted list of approvals that
715
748
  // TTL-expired while the operator was away; a digest card is posted on the
716
749
  // operator's next activity. Kill switch: SWITCHROOM_MISSED_APPROVAL_REOFFER=0.
717
750
  const missedApprovalsStore = createMissedApprovalsStore(STATE_DIR)
718
751
  const MISSED_APPROVAL_REOFFER_ENABLED =
719
752
  process.env.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'
753
+ // #2973 pt.2 β€” durable retry queue for "πŸ” Always allow" persists that
754
+ // fail for a retryable reason (stale config view, rate limit, transient
755
+ // hostd error). Drained at boot (below) and on a periodic timer so a
756
+ // gateway restart mid-persist never loses the retry.
757
+ const alwaysAllowPersistQueue = createAlwaysAllowPersistQueue(STATE_DIR)
758
+
759
+ /** Read a hostd `error_envelope`'s structured `retry_after` fix (if
760
+ * present) as a millisecond delay from now. Returns undefined for any
761
+ * other fix kind / missing envelope / unparsable timestamp β€” callers
762
+ * fall back to plain exponential backoff in that case. */
763
+ function extractRetryAfterMs(resp: HostdResponse): number | undefined {
764
+ const fix = resp.error_envelope?.fix
765
+ if (fix == null || fix.kind !== 'retry_after') return undefined
766
+ const at = Date.parse(fix.retry_at)
767
+ if (Number.isNaN(at)) return undefined
768
+ return Math.max(0, at - Date.now())
769
+ }
770
+
771
+ /**
772
+ * Build a FRESH dependency set for `drainAlwaysAllowPersistQueue` β€” a new
773
+ * factory call per drain pass, not a cached singleton, so every attempt
774
+ * re-reads config from disk (the #2973 stale-container-side-view failure
775
+ * class is fixed by never reusing a snapshot across retries).
776
+ */
777
+ function alwaysAllowDrainDeps(): AlwaysAllowDrainDeps {
778
+ return {
779
+ readConfigText: () => {
780
+ const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
781
+ return readFileSync(cfgPath, 'utf8')
782
+ },
783
+ resolveAllowList: (_configText, agentName) => {
784
+ const cfg = loadSwitchroomConfig()
785
+ const rawAgent = cfg.agents?.[agentName]
786
+ if (!rawAgent) return []
787
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
788
+ return (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
789
+ },
790
+ isRulePersisted,
791
+ synthesizeDiff: (agentName, rule, configText) =>
792
+ synthesizeAllowRuleDiff({ agentName, rule, configText }),
793
+ dispatchConfigEdit: async (entry, unifiedDiff) => {
794
+ const req: HostdRequest = {
795
+ v: 1,
796
+ op: 'config_propose_edit',
797
+ request_id: hostdRequestId('gw-always-allow-retry'),
798
+ args: {
799
+ unified_diff: unifiedDiff,
800
+ reason: `Operator 'always allow' retry: ${entry.agentName} can ${entry.grantPhrase}`,
801
+ target_path: '/state/config/switchroom.yaml',
802
+ },
803
+ }
804
+ const resp = await tryHostdDispatch(entry.agentName, req, 720_000)
805
+ if (resp === 'not-configured') {
806
+ return { ok: false as const, error: 'hostd not-configured (retry queue requires host_control.enabled)' }
807
+ }
808
+ if (resp.result === 'completed') return { ok: true as const }
809
+ return {
810
+ ok: false as const,
811
+ error: resp.error ?? `hostd ${resp.result}`,
812
+ retryAfterMs: extractRetryAfterMs(resp),
813
+ }
814
+ },
815
+ // #2973 pt.3 β€” loud terminal failure. A card edit doesn't ping the
816
+ // operator (the original permission card was already edited to the
817
+ // "saving durably in background…" interim state); this posts a NEW
818
+ // message instead, via the same operator-event broadcast every other
819
+ // fleet alert uses.
820
+ notifyTerminalFailure: (entry, reason) => {
821
+ emitGatewayOperatorEvent({
822
+ kind: 'always-allow-persist-failed',
823
+ agent: entry.agentName,
824
+ detail: `"${entry.grantPhrase}" (rule \`${entry.rule}\`) β€” ${reason}`,
825
+ suggestedActions: [],
826
+ firstSeenAt: new Date(),
827
+ })
828
+ },
829
+ log: (line) => process.stderr.write(line),
830
+ }
831
+ }
832
+
833
+ /** ~10 min between periodic drain passes β€” frequent enough that a
834
+ * retryable failure (rate limit, transient hostd hiccup) resolves within
835
+ * a reasonable window, infrequent enough to never look like polling. */
836
+ const ALWAYS_ALLOW_DRAIN_INTERVAL_MS = 10 * 60_000
837
+
838
+ /**
839
+ * Boot drain (picks up anything a prior gateway process queued right
840
+ * before a restart β€” success criterion: "restarting the gateway mid-
841
+ * persist does not lose the queued entry") + a periodic timer thereafter.
842
+ * Called once at gateway startup. Every pass is independently
843
+ * fault-tolerant β€” `drainAlwaysAllowPersistQueue` never throws.
844
+ */
845
+ function scheduleAlwaysAllowPersistDrain(): void {
846
+ void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
847
+ process.stderr.write(
848
+ `telegram gateway: always-allow-persist-queue boot drain failed: ${(err as Error).message}\n`,
849
+ )
850
+ })
851
+ const timer = setInterval(() => {
852
+ void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
853
+ process.stderr.write(
854
+ `telegram gateway: always-allow-persist-queue periodic drain failed: ${(err as Error).message}\n`,
855
+ )
856
+ })
857
+ }, ALWAYS_ALLOW_DRAIN_INTERVAL_MS)
858
+ timer.unref?.()
859
+ }
720
860
  const ACCESS_FILE = join(STATE_DIR, 'access.json')
721
861
  const APPROVED_DIR = join(STATE_DIR, 'approved')
722
862
  const ENV_FILE = join(STATE_DIR, '.env')
723
863
  const INBOX_DIR = join(STATE_DIR, 'inbox')
864
+ // person_id name resolution (docs/configuration.md). Sibling to
865
+ // access.json but a COMPLETELY SEPARATE, purely config-derived file β€”
866
+ // scaffold regenerates it on every reconcile via writeFileSyncIfChanged,
867
+ // the gateway never mutates it, and (unlike access.json) it is read
868
+ // exactly ONCE at boot into PERSON_DIRECTORY below β€” no hot-reload.
869
+ const PEOPLE_FILE = join(STATE_DIR, 'people.json')
724
870
 
725
871
  /**
726
872
  * Trigger a restart of the agent + gateway pair.
@@ -1224,6 +1370,33 @@ function loadAccess(): Access {
1224
1370
  return BOOT_ACCESS ?? readAccessFile()
1225
1371
  }
1226
1372
 
1373
+ /**
1374
+ * Read `people.json` (the scaffold's plain projection of `users:` entries
1375
+ * that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
1376
+ * returns an empty array rather than throwing β€” this feature must never
1377
+ * block startup. Unlike `access.json` this file is never gateway-mutated,
1378
+ * so there's no "move corrupt file aside" concern; the scaffold owns and
1379
+ * regenerates it on every reconcile.
1380
+ */
1381
+ function readPeopleFile(): RawPersonEntry[] {
1382
+ try {
1383
+ const raw = readFileSync(PEOPLE_FILE, 'utf8')
1384
+ const parsed = JSON.parse(raw) as { entries?: unknown }
1385
+ if (!Array.isArray(parsed.entries)) return []
1386
+ return parsed.entries as RawPersonEntry[]
1387
+ } catch {
1388
+ return []
1389
+ }
1390
+ }
1391
+
1392
+ /**
1393
+ * Boot-time-only person-name directory (see resolve-person.ts module doc).
1394
+ * Populated exactly once by the boot-validation block in the startup IIFE
1395
+ * below, then never reassigned or re-read from disk again for the life of
1396
+ * this process β€” a config change requires an agent restart.
1397
+ */
1398
+ let PERSON_DIRECTORY: PersonDirectory = { byTelegramKey: {} }
1399
+
1227
1400
  function assertAllowedChat(chat_id: string | number): void {
1228
1401
  const id = String(chat_id)
1229
1402
  const access = loadAccess()
@@ -1344,71 +1517,124 @@ try {
1344
1517
  const pending = findLatestTurnIfInterrupted(turnsDb)
1345
1518
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
1346
1519
  if (pending != null && selfAgent) {
1347
- // Clean-shutdown gate: suppress auto-resume when the prior shutdown was
1348
- // operator/roll/CLI-initiated (clean). A clean-shutdown marker present and
1349
- // fresh means the agent was asked to stop; the "interrupted" turn was
1350
- // abandoned by that decision. Replaying it on every planned restart wastes
1351
- // subscription quota for no user benefit. Only unclean exits (crash/OOM/
1352
- // unexpected kill) should auto-resume.
1520
+ // Boot-resume policy (2026-07, superseding #2585). The block only runs
1521
+ // when `pending` exists β€” i.e. there IS genuinely in-flight work. The
1522
+ // product decision is that a DELIBERATE restart must not silently drop
1523
+ // that work: `session_continuity.boot_resume` (SWITCHROOM_BOOT_RESUME,
1524
+ // default 'in-flight') resumes it even after a clean shutdown. #2585's
1525
+ // quota-saving posture survives only as the opt-in 'never' mode β€” and
1526
+ // even then we deliver a passive REPORT, never silence, because the user
1527
+ // must always be told when in-flight work stopped.
1353
1528
  //
1354
1529
  // NOTE: GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH is defined lower in this file
1355
1530
  // (module-init order); we compute the path inline here using the same
1356
1531
  // formula so we can read it at boot-resume time.
1357
- // SWITCHROOM_BOOT_RESUME_ALWAYS=1 is an escape hatch that restores
1358
- // unconditional resume if needed.
1532
+ // SWITCHROOM_BOOT_RESUME_ALWAYS=1 remains a back-compat escape hatch that
1533
+ // forces unconditional resume regardless of mode.
1359
1534
  const bootResumeMarkerPath =
1360
1535
  process.env.SWITCHROOM_GATEWAY_CLEAN_SHUTDOWN_MARKER ?? join(STATE_DIR, 'clean-shutdown.json')
1361
1536
  const bootResumeCleanMarker = readCleanShutdownMarker(bootResumeMarkerPath)
1362
1537
  const bootResumeForceAlways = process.env.SWITCHROOM_BOOT_RESUME_ALWAYS === '1'
1538
+ const bootResumeMode = parseBootResumeMode(process.env.SWITCHROOM_BOOT_RESUME)
1363
1539
  const bootResumeSuppressed = shouldSuppressBootResume(bootResumeCleanMarker, Date.now(), {
1364
1540
  forceAlways: bootResumeForceAlways,
1541
+ mode: bootResumeMode,
1542
+ })
1543
+
1544
+ // 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
1545
+ // interrupted work older than RESUME_MAX_AGE_MS β€” selectResumeBuilder
1546
+ // downgrades a stale 'resume' to the passive 'report'. Env override
1547
+ // SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
1548
+ const RESUME_MAX_AGE_MS = (() => {
1549
+ const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
1550
+ return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
1551
+ })()
1552
+
1553
+ // Decide the inbound kind (pure β€” see decideBootResumeKind). Precedence:
1554
+ // 1. loop-guard β†’ 'defer-loop' (never a second resume of a resume)
1555
+ // 2. suppressed β†’ 'defer-suppressed' (boot_resume: never; notice, not silence)
1556
+ // 3. otherwise β†’ selectResumeBuilder (resume | report | none)
1557
+ const bootResumeKind = decideBootResumeKind({
1558
+ pending,
1559
+ suppressed: bootResumeSuppressed,
1560
+ ageMs: Math.max(0, Date.now() - pending.started_at),
1561
+ maxAgeMs: RESUME_MAX_AGE_MS,
1365
1562
  })
1366
- if (bootResumeSuppressed) {
1563
+
1564
+ // Sub-agents that were still in flight (running / stalled β€” non-terminal)
1565
+ // when the turn was killed. Read HERE, at module top, BEFORE the
1566
+ // subagent-watcher's boot scan + reaper run: the watcher never deletes
1567
+ // these rows (it only flips running→stalled and marks files historical
1568
+ // in-memory), and this accessor already includes 'stalled', so the data
1569
+ // survives either ordering β€” but reading pre-watcher keeps it simplest.
1570
+ // Threaded into ALL the builders below (resume, watchdog report, and the
1571
+ // deferred report) so the session gets an explicit killed-workers block β€”
1572
+ // it must not declare the task done on ghost workers, and even a
1573
+ // suppressed/loop-guarded resume must still NAME the deaths.
1574
+ let interruptedSubagents: InterruptedSubagent[] = []
1575
+ try {
1576
+ interruptedSubagents = listNonTerminalSubagentsForTurn(turnsDb, pending.turn_key).map(
1577
+ (s) => ({ agentType: s.agent_type, description: s.description, status: s.status }),
1578
+ )
1579
+ } catch (err) {
1367
1580
  process.stderr.write(
1368
- `telegram gateway: boot-resume suppressed (clean shutdown` +
1369
- `${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
1370
- `) β€” unclean exits still resume turnKey=${pending.turn_key}\n`,
1581
+ `telegram gateway: boot-resume subagent lookup failed (${(err as Error).message}) β€” continuing without worker list\n`,
1371
1582
  )
1372
- } else {
1373
- // 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
1374
- // interrupted work older than RESUME_MAX_AGE_MS β€” selectResumeBuilder
1375
- // downgrades a stale 'resume' to the passive 'report' so the user is told
1376
- // ("I was working on X ~Nh ago") but nothing replays unprompted. Env
1377
- // override SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
1378
- const RESUME_MAX_AGE_MS = (() => {
1379
- const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
1380
- return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
1381
- })()
1382
- const kind = selectResumeBuilder(pending.ended_via, {
1383
- ageMs: Math.max(0, Date.now() - pending.started_at),
1384
- maxAgeMs: RESUME_MAX_AGE_MS,
1385
- })
1386
- if (kind === 'resume') {
1387
- bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
1388
- } else if (kind === 'report') {
1389
- // idleMs: this boot's measured marker age if it just classified this
1390
- // turn; otherwise recover it from the persisted interrupt_reason (a
1391
- // later boot, marker already swept); else fall back to total runtime.
1392
- let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
1393
- if (idleMs == null && pending.interrupt_reason) {
1394
- try {
1395
- const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
1396
- if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
1397
- } catch { /* malformed snapshot β€” fall through */ }
1398
- }
1399
- if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
1400
- bootResumeInbound = {
1401
- agent: selfAgent,
1402
- msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs }),
1403
- }
1583
+ }
1584
+
1585
+ if (bootResumeKind === 'resume') {
1586
+ bootResumeInbound = {
1587
+ agent: selfAgent,
1588
+ msg: buildResumeInterruptedInbound({ turn: pending, subagents: interruptedSubagents }),
1404
1589
  }
1405
- if (bootResumeInbound != null) {
1406
- process.stderr.write(
1407
- `telegram gateway: boot-resume queued kind=${kind} turnKey=${pending.turn_key} ` +
1408
- `endedVia=${pending.ended_via ?? 'open'} chat=${pending.chat_id}\n`,
1409
- )
1590
+ } else if (bootResumeKind === 'report') {
1591
+ // idleMs: this boot's measured marker age if it just classified this
1592
+ // turn; otherwise recover it from the persisted interrupt_reason (a
1593
+ // later boot, marker already swept); else fall back to total runtime.
1594
+ let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
1595
+ if (idleMs == null && pending.interrupt_reason) {
1596
+ try {
1597
+ const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
1598
+ if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
1599
+ } catch { /* malformed snapshot β€” fall through */ }
1600
+ }
1601
+ if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
1602
+ bootResumeInbound = {
1603
+ agent: selfAgent,
1604
+ msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs, subagents: interruptedSubagents }),
1605
+ }
1606
+ } else if (bootResumeKind === 'defer-loop' || bootResumeKind === 'defer-suppressed') {
1607
+ // Passive deferred-report: work was in flight but we decline to
1608
+ // auto-resume (loop-guard, or boot_resume:never). Silence is never
1609
+ // acceptable here β€” tell the user what was in flight and ask.
1610
+ bootResumeInbound = {
1611
+ agent: selfAgent,
1612
+ msg: buildResumeDeferredReportInbound({
1613
+ turn: pending,
1614
+ reason: bootResumeKind === 'defer-loop' ? 'loop-guard' : 'clean-restart-suppressed',
1615
+ subagents: interruptedSubagents,
1616
+ }),
1410
1617
  }
1411
1618
  }
1619
+
1620
+ if (bootResumeKind === 'defer-suppressed') {
1621
+ process.stderr.write(
1622
+ `telegram gateway: boot-resume suppressed (clean shutdown` +
1623
+ `${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
1624
+ `, mode=${bootResumeMode}) β€” passive report delivered for turnKey=${pending.turn_key}\n`,
1625
+ )
1626
+ } else if (bootResumeKind === 'defer-loop') {
1627
+ process.stderr.write(
1628
+ `telegram gateway: boot-resume loop-guard tripped (interrupted turn was itself a resume) ` +
1629
+ `β€” passive report delivered instead of re-resuming turnKey=${pending.turn_key}\n`,
1630
+ )
1631
+ }
1632
+ if (bootResumeInbound != null) {
1633
+ process.stderr.write(
1634
+ `telegram gateway: boot-resume queued kind=${bootResumeKind} mode=${bootResumeMode} ` +
1635
+ `turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'} chat=${pending.chat_id}\n`,
1636
+ )
1637
+ }
1412
1638
  }
1413
1639
 
1414
1640
  // Diagnostic env file (one-shot, sourced by start.sh) β€” kept for the
@@ -1488,6 +1714,64 @@ function resolveSubagentOriginChat(
1488
1714
  }
1489
1715
  }
1490
1716
 
1717
+ /**
1718
+ * Tracks worker-feed agents whose owner-DM fallback has already been
1719
+ * logged, so `resolveWorkerFeedChat` emits the routing-decision line once
1720
+ * per agent instead of every watcher tick (~1/s). Capped FIFO at 256 β€” a
1721
+ * late duplicate log is harmless (one extra stderr line), and the cap keeps
1722
+ * the set bounded across a long gateway lifetime even if origin resolution
1723
+ * is persistently broken fleet-wide (history disabled β†’ every nested worker
1724
+ * hits the fallback). A gateway restart clears it.
1725
+ */
1726
+ const WORKER_FEED_FALLBACK_LOG_CAP = 256
1727
+ const workerFeedOwnerDmFallbackLogged = new Set<string>()
1728
+
1729
+ /**
1730
+ * Resolve a worker-feed destination chat with a guaranteed last resort.
1731
+ *
1732
+ * The universal-liveness contract is "any active work has a card, at any
1733
+ * nesting depth, with or without a parent turn open." Origin resolution
1734
+ * (`resolveSubagentOriginChat`) only succeeds when the ancestor turn row is
1735
+ * in `turnsDb` and history is enabled; a depth-2+ worker whose chain can't
1736
+ * be walked (history disabled, row reaped, ancestor never stamped) would
1737
+ * otherwise fall through `fleetChatId || allowFrom[0]` and, if those are
1738
+ * empty too, hit the `chatId.length === 0` skip in `workerActivityFeed.update`
1739
+ * β€” painting NOTHING, silently. That is the one path most likely to violate
1740
+ * "any and all active work has a card."
1741
+ *
1742
+ * This never returns `''`: the precedence is origin chat β†’ fleet chat β†’
1743
+ * first allowed chat (the owner DM). The owner DM is the durable floor β€”
1744
+ * "wrong chat" beats "no card." A one-line routing-decision log flags the
1745
+ * fallback so an operator can see the misroute without it reading as an
1746
+ * error (it isn't one β€” the card surfaced).
1747
+ */
1748
+ function resolveWorkerFeedChat(
1749
+ agentId: string,
1750
+ fleetChatId: string,
1751
+ ): { chatId: string; threadId?: number } {
1752
+ const origin = resolveSubagentOriginChat(agentId)
1753
+ if (origin != null && origin.chatId.length > 0) return origin
1754
+ if (fleetChatId.length > 0) return { chatId: fleetChatId }
1755
+ const ownerDm = loadAccess().allowFrom[0] ?? ''
1756
+ if (origin == null && fleetChatId.length === 0 && ownerDm.length > 0) {
1757
+ // Routing decision, not a warning: origin resolution failed and no
1758
+ // fleet chat is configured, so the nested worker's card lands in the
1759
+ // owner DM. Logged ONCE per agent so the misroute is auditable
1760
+ // without spamming every tick (the watcher drives onProgress ~1/s).
1761
+ if (!workerFeedOwnerDmFallbackLogged.has(agentId)) {
1762
+ workerFeedOwnerDmFallbackLogged.add(agentId)
1763
+ if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
1764
+ const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
1765
+ if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
1766
+ }
1767
+ process.stderr.write(
1768
+ `telegram gateway: worker-feed origin unresolved agent=${agentId} β€” routing card to owner DM\n`,
1769
+ )
1770
+ }
1771
+ }
1772
+ return { chatId: ownerDm, threadId: origin?.threadId }
1773
+ }
1774
+
1491
1775
  // ─── Periodic history reaper (#1073) ──────────────────────────────────────
1492
1776
  // The init-time prune in history.ts only touched the `messages` table.
1493
1777
  // `subagents` and `turns` in registry.db grew unbounded β€” every Agent()
@@ -2176,6 +2460,110 @@ function deliverResumeSyntheticOrBuffer(agent: string, inbound: InboundMessage):
2176
2460
  return delivered
2177
2461
  }
2178
2462
 
2463
+ /** Outcome of routing an agent-authored button tap through the turn-safe
2464
+ * delivery machinery. `buffered-mid-turn` and `delivered` both mean "the tap
2465
+ * will be actioned" (the mid-turn case flushes on turn-complete); only
2466
+ * `buffered-bridge-offline` needs the user-facing "agent is restarting" notice. */
2467
+ type ButtonTapDeliveryOutcome = 'delivered' | 'buffered-mid-turn' | 'buffered-bridge-offline'
2468
+
2469
+ /**
2470
+ * Deliver an agent-authored inline-keyboard button tap (`agent:` callback_data)
2471
+ * through the SAME turn-safe machinery as a normal Telegram inbound, instead of
2472
+ * the old raw `sendToAgent` + buffer-only-on-bridge-miss.
2473
+ *
2474
+ * THE BUG (#271 button path, verified 2026-07): the `agent:` callback handler
2475
+ * delivered the synthesized tap inbound with a bare `ipcServer.sendToAgent`,
2476
+ * marked busy, and buffered ONLY when the bridge was offline. It never ran the
2477
+ * #1556 turn gate β€” so a tap landing WHILE a turn is in flight fired the MCP
2478
+ * channel notification mid-turn, typed into the CLI composer, and stranded there
2479
+ * (the lawgpt/marko wedge). It also skipped the pre-send composer clear and the
2480
+ * deliver-until-acked tracking, so a stranded tap was never sweep-redelivered.
2481
+ *
2482
+ * Fix: reuse the resume-synthetic turn gate (mid-turn β†’ `buffer-until-idle`, the
2483
+ * turn-complete hook + idle-drain flush it the instant claude goes idle), the
2484
+ * pre-send composer clear, and the delivery-confirm tracking β€” exactly like the
2485
+ * `handleInbound` fresh-turn path. A button tap carries no `meta.source` and a
2486
+ * non-empty body, so `shouldTrackDelivery` enrols it; we additionally require a
2487
+ * real `meta.message_id` so the `enqueue` ack has something to match (else the
2488
+ * never-drop sweep would storm). The tap UX is unchanged β€” the ack toast, the
2489
+ * single-use keyboard strip, and the bridge-offline spool + restart notice all
2490
+ * stay at the call site; only delivery timing/safety changes.
2491
+ */
2492
+ async function deliverButtonTapInbound(
2493
+ agent: string,
2494
+ inbound: InboundMessage,
2495
+ ): Promise<ButtonTapDeliveryOutcome> {
2496
+ // #1556 turn gate β€” same authoritative "is a turn in flight?" read the
2497
+ // resume-synthetic path uses. Mid-turn β†’ hold in the pending-inbound buffer;
2498
+ // the turn-complete hook + idle-drain timer flush it when claude goes idle,
2499
+ // where it lands cleanly as a fresh turn instead of stranding in the composer.
2500
+ const { decision, reserve } = reserveInboundDelivery({
2501
+ turnInFlight: turnInFlightForGate(),
2502
+ isSteering: false,
2503
+ isInterrupt: false,
2504
+ })
2505
+ if (decision === 'buffer-until-idle') {
2506
+ pendingInboundBuffer.push(agent, inbound)
2507
+ return 'buffered-mid-turn'
2508
+ }
2509
+ // #2917 per-chat FIFO: reserve the chat's busy key SYNCHRONOUSLY β€” before the
2510
+ // composer-clear await below β€” so a concurrent same-chat inbound reaching the
2511
+ // live gate observes this in-flight delivery and buffers behind it. Released
2512
+ // in lockstep below if the send misses (bridge offline).
2513
+ let reservedBusyKey: string | null = null
2514
+ if (reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED) {
2515
+ reservedBusyKey = markClaudeBusyForInbound(inbound)
2516
+ }
2517
+ // Pre-send composer clear (the marko wedge) β€” wipe stale typed-ahead / ghost
2518
+ // text so the channel notification lands at a clean line and auto-submits.
2519
+ // Soft-fail by contract: a clear failure must NEVER block delivery.
2520
+ if (agent) {
2521
+ try {
2522
+ const { clearAgentComposer } = await import('../../src/agents/tmux.js')
2523
+ const cleared = clearAgentComposer({ agentName: agent })
2524
+ if ('error' in cleared) {
2525
+ process.stderr.write(
2526
+ `telegram gateway: button-tap pre-send composer-clear soft-failed agent=${agent}: ${cleared.error} β€” delivering anyway\n`,
2527
+ )
2528
+ }
2529
+ } catch (err) {
2530
+ process.stderr.write(
2531
+ `telegram gateway: button-tap pre-send composer-clear threw agent=${agent}: ${(err as Error).message} β€” delivering anyway\n`,
2532
+ )
2533
+ }
2534
+ }
2535
+ const delivered = ipcServer.sendToAgent(agent, inbound)
2536
+ if (delivered) {
2537
+ const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inbound)
2538
+ // Track until claude acks via `enqueue` so the deliver-until-acked sweep
2539
+ // re-delivers a tap stranded in the composer. Only when we have a real
2540
+ // message_id to match the ack against β€” otherwise the never-drop loop storms.
2541
+ if (
2542
+ DELIVERY_CONFIRM_ENABLED &&
2543
+ inbound.meta?.message_id != null &&
2544
+ inbound.meta.message_id !== '' &&
2545
+ shouldTrackDelivery({
2546
+ isSteering: false,
2547
+ isInterrupt: false,
2548
+ hasSource: inbound.meta?.source != null,
2549
+ effectiveText: inbound.text,
2550
+ })
2551
+ ) {
2552
+ trackDelivery(deliveryQueue, busyKey, inbound, Date.now(), String(inbound.messageId))
2553
+ }
2554
+ return 'delivered'
2555
+ }
2556
+ // Bridge offline: release the synchronous reservation in lockstep (else the
2557
+ // orphaned busy key gates every later inbound into the buffer), then spool the
2558
+ // tap so it replays on reconnect β€” same behaviour as before this fix.
2559
+ if (reservedBusyKey != null) {
2560
+ claudeBusyKeys.delete(reservedBusyKey)
2561
+ claudeBusyKeySince.delete(reservedBusyKey)
2562
+ }
2563
+ pendingInboundBuffer.push(agent, inbound)
2564
+ return 'buffered-bridge-offline'
2565
+ }
2566
+
2179
2567
  const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
2180
2568
 
2181
2569
  // ─── Proactive context compaction (session.max_context_tokens) ──────────
@@ -2405,6 +2793,13 @@ type CurrentTurn = {
2405
2793
  // resume protocol uses this to decide "did the previous turn actually
2406
2794
  // finish a reply, or was it interrupted before commit?".
2407
2795
  lastAssistantDone: boolean
2796
+ // Live model in use for THIS turn, sourced from the main transcript's
2797
+ // `message.model` (the exact resolved model per API call) via the session-tail
2798
+ // `model` event β€” never from config or launch-time state. Updated on change;
2799
+ // undefined until the turn's first assistant line lands. Rendered onto the
2800
+ // activity/liveness card header's metrics line (e.g. "2m Β· 14 tools Β· opus 4.8")
2801
+ // and preferred by /status's buildAgentMetadata over the in-memory override.
2802
+ currentModel?: string
2408
2803
  // Phase 1 of #332: count of tool_use events in the current turn, for
2409
2804
  // the tool_call_count column in the turns registry.
2410
2805
  toolCallCount: number
@@ -2530,6 +2925,16 @@ type CurrentTurn = {
2530
2925
  // is never written and this is exactly the old singleton.
2531
2926
  let currentTurn: CurrentTurn | null = null
2532
2927
  const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
2928
+ // Freshness-aware /status session-model source. Two writers: the session-tail
2929
+ // `model` event (each assistant line's `message.model` β€” ground truth for the
2930
+ // last API call, survives between turns) and the #2982 /model override (set
2931
+ // the instant a switch is confirmed β€” the ONLY truthful source in the
2932
+ // idle-after-switch window, before the next assistant line lands). Every write
2933
+ // is seq-stamped and `resolve()` prefers the NEWER observation, so neither
2934
+ // source can go stale behind the other (session-model-source.ts, pinned by
2935
+ // tests/session-model-source.test.ts). buildAgentMetadata reads resolve();
2936
+ // the /model command paths write via setOverride.
2937
+ const sessionModelSource = createSessionModelSource()
2533
2938
  // Captures the most-recently-started turn's sessionChatId. Unlike currentTurn,
2534
2939
  // this is NOT cleared by the silence poke (firePoke/clearTurnStarted). It lets
2535
2940
  // the Bug B fallback in executeReply route to the correct chat even when the
@@ -4070,9 +4475,26 @@ async function resolveCompactCard(
4070
4475
  { chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
4071
4476
  );
4072
4477
  } catch (err) {
4478
+ // Best-effort status-card edit β€” a transport hiccup (429 / "not
4479
+ // modified" / message gone) is not a liveness-logic error and must
4480
+ // not log a "card edit failed" warning (the warning-as-excuse-for-a-
4481
+ // stale-card anti-pattern). Only a genuine unexpected error warrants
4482
+ // a line; transport classes are silent.
4483
+ const desc = err instanceof Error ? err.message : String(err);
4484
+ const low = desc.toLowerCase();
4485
+ if (
4486
+ low.includes('not modified') ||
4487
+ low.includes('not found') ||
4488
+ low.includes("can't be edited") ||
4489
+ low.includes('cannot be edited') ||
4490
+ low.includes('not enough rights') ||
4491
+ low.includes('429') ||
4492
+ low.includes('retry after')
4493
+ ) {
4494
+ return;
4495
+ }
4073
4496
  process.stderr.write(
4074
- `telegram gateway: proactive-compact ${kind} card edit failed: ` +
4075
- `${err instanceof Error ? err.message : String(err)}\n`,
4497
+ `telegram gateway: proactive-compact ${kind} card edit failed: ${desc}\n`,
4076
4498
  );
4077
4499
  }
4078
4500
  }
@@ -5122,17 +5544,26 @@ interface PendingVaultRequestSave {
5122
5544
  why?: string
5123
5545
  /** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
5124
5546
  staged_at: number
5547
+ /** Set on entries RESTORED from disk after a gateway restart. The staged
5548
+ * secret `value` is held in memory only (never persisted β€” secrets
5549
+ * hygiene), so a restored entry has an empty value and cannot complete the
5550
+ * write. A Save tap on such a card degrades gracefully: it tells the agent
5551
+ * the value was lost to a restart instead of writing an empty secret. */
5552
+ restoredWithoutValue?: boolean
5125
5553
  }
5126
5554
  const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
5127
5555
  // Gateway-side reap window for a staged vault-save card. Tracks the operator
5128
5556
  // approval-card lifetime (config-driven, 60-min default) so the reap never
5129
5557
  // races ahead of the card the operator is still looking at.
5130
5558
  const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
5131
- function sweepPendingVaultRequestSaves(): void {
5132
- const cutoff = Date.now() - VAULT_REQUEST_SAVE_TTL_MS
5133
- for (const [k, v] of pendingVaultRequestSaves) {
5134
- if (v.staged_at < cutoff) pendingVaultRequestSaves.delete(k)
5135
- }
5559
+ function sweepPendingVaultRequestSaves(now = Date.now()): void {
5560
+ sweepExpiredEntries(
5561
+ pendingVaultRequestSaves,
5562
+ (v, n) => v.staged_at < n - VAULT_REQUEST_SAVE_TTL_MS,
5563
+ expireVaultSaveCard,
5564
+ now,
5565
+ cardExpiryLog,
5566
+ )
5136
5567
  }
5137
5568
 
5138
5569
  /**
@@ -5175,11 +5606,14 @@ const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
5175
5606
  // Gateway-side reap window for a staged vault-access card. Tracks the operator
5176
5607
  // approval-card lifetime (config-driven, 60-min default) β€” see approvalTtlMs.
5177
5608
  const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
5178
- function sweepPendingVaultRequestAccesses(): void {
5179
- const cutoff = Date.now() - VAULT_REQUEST_ACCESS_TTL_MS
5180
- for (const [k, v] of pendingVaultRequestAccesses) {
5181
- if (v.staged_at < cutoff) pendingVaultRequestAccesses.delete(k)
5182
- }
5609
+ function sweepPendingVaultRequestAccesses(now = Date.now()): void {
5610
+ sweepExpiredEntries(
5611
+ pendingVaultRequestAccesses,
5612
+ (v, n) => v.staged_at < n - VAULT_REQUEST_ACCESS_TTL_MS,
5613
+ expireVaultAccessCard,
5614
+ now,
5615
+ cardExpiryLog,
5616
+ )
5183
5617
  }
5184
5618
 
5185
5619
  /**
@@ -5211,23 +5645,14 @@ const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
5211
5645
  // posted card's keyboard away, so a stale card left in the chat can't be tapped
5212
5646
  // into a "Card expired" answer β€” the operator sees the βŒ› expiry inline instead.
5213
5647
  // Best-effort: card edits are fire-and-forget (the entry is removed regardless).
5214
- function sweepPendingMentalModelProposes(): void {
5215
- const cutoff = Date.now() - MENTAL_MODEL_PROPOSE_TTL_MS
5216
- for (const [k, v] of pendingMentalModelProposes) {
5217
- if (v.staged_at < cutoff) {
5218
- pendingMentalModelProposes.delete(k)
5219
- if (v.card_message_id != null) {
5220
- void lockedBot.api
5221
- .editMessageText(
5222
- v.chat_id,
5223
- v.card_message_id,
5224
- richMessage('βŒ› _This mental-model proposal card expired. Ask the agent to re-propose if it still stands._'),
5225
- { reply_markup: { inline_keyboard: [] } },
5226
- )
5227
- .catch(() => {})
5228
- }
5229
- }
5230
- }
5648
+ function sweepPendingMentalModelProposes(now = Date.now()): void {
5649
+ sweepExpiredEntries(
5650
+ pendingMentalModelProposes,
5651
+ (v, n) => v.staged_at < n - MENTAL_MODEL_PROPOSE_TTL_MS,
5652
+ expireMentalModelProposeCard,
5653
+ now,
5654
+ cardExpiryLog,
5655
+ )
5231
5656
  }
5232
5657
 
5233
5658
  // Sliding-window rate limit for mental-model proposals: at most
@@ -5464,6 +5889,296 @@ function isAutoFallbackCooldownActive(_agentName: string, now: number): boolean
5464
5889
  }
5465
5890
  }
5466
5891
 
5892
+ // ── Agent-initiated approval-card TTL expiry β†’ wake the parked agent ────────
5893
+ //
5894
+ // The four agent-initiated approval-card families (vault_request_access /
5895
+ // vault_request_save / request_secret / mental_model_propose) each park the
5896
+ // requesting agent (it ends its turn to wait for the operator's tap). Before
5897
+ // this, an unanswered card that TTL-expired left the agent parked FOREVER: the
5898
+ // lazy sweep just deleted the in-memory entry and nothing woke the agent. Each
5899
+ // expire* helper mirrors the permission-card timeout path (#2411 / #2862) by
5900
+ // routing through the pure `expirePendingCard` core (pending-card-expiry.ts),
5901
+ // whose ordering + fault-isolation contract is behaviorally pinned by
5902
+ // pending-card-expiry.test.ts:
5903
+ // 1. drop the in-memory entry AND its durable store record FIRST (single-
5904
+ // shot β€” a second tick can never double-fire the wake),
5905
+ // 2. edit the card to a βŒ› expired state + strip its keyboard (best-effort),
5906
+ // 3. record it in missedApprovalsStore BEFORE delivering, so a throwing
5907
+ // deliver can't lose the re-offer for the operator's return,
5908
+ // 4. inject a TIMEOUT-outcome synthetic inbound (turn-gated via
5909
+ // deliverResumeSyntheticOrBuffer, guarded β€” a half-dead IPC socket that
5910
+ // throws on write is contained, never escaping the reaper's setInterval
5911
+ // callback into an uncaughtException gateway shutdown).
5912
+ // Called from BOTH the lazy sweeps (on next stage) and the pendingStateReaper
5913
+ // (every 60s β€” the authoritative timer so an idle gateway still wakes agents).
5914
+
5915
+ async function editCardExpired(chatId: string, messageId: number | undefined, body: string): Promise<void> {
5916
+ if (messageId == null) return
5917
+ await lockedBot.api
5918
+ // allow-raw-bot-api: message-id-targeted edit (no thread to lose); best-effort card-expiry strip from the reaper (no grammy ctx). Dropping reply_markup strips the stale keyboard atomically with the text edit.
5919
+ .editMessageText(chatId, messageId, richMessage(body), { reply_markup: { inline_keyboard: [] } })
5920
+ .catch(() => {})
5921
+ }
5922
+
5923
+ function recordMissedApproval(opts: {
5924
+ stageId: string
5925
+ toolName: string
5926
+ action: string
5927
+ chatId: string
5928
+ threadId?: number
5929
+ now: number
5930
+ }): void {
5931
+ if (!MISSED_APPROVAL_REOFFER_ENABLED) return
5932
+ missedApprovalsStore.add({
5933
+ requestId: opts.stageId,
5934
+ toolName: opts.toolName,
5935
+ action: opts.action,
5936
+ chatId: opts.chatId,
5937
+ threadId: opts.threadId ?? null,
5938
+ timedOutAt: opts.now,
5939
+ })
5940
+ }
5941
+
5942
+ const cardExpiryLog = (msg: string): void => {
5943
+ process.stderr.write(`telegram gateway: ${msg}\n`)
5944
+ }
5945
+
5946
+ function expireVaultAccessCard(stageId: string, v: PendingVaultRequestAccess, now: number): void {
5947
+ const timeoutMinutes = Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)
5948
+ const { delivered } = expirePendingCard({
5949
+ remove: () => {
5950
+ pendingVaultRequestAccesses.delete(stageId)
5951
+ pendingCardStore.remove(stageId)
5952
+ },
5953
+ editCard: () => void editCardExpired(
5954
+ v.chat_id,
5955
+ v.card_message_id,
5956
+ `βŒ› _This vault access request for \`${escapeHtmlForTg(v.key)}\` timed out before you tapped. Ask **${escapeHtmlForTg(v.agent)}** to re-request if it still stands._`,
5957
+ ),
5958
+ buildInbound: () => buildVaultAccessTimeoutInbound({
5959
+ agent: v.agent,
5960
+ chatId: v.chat_id,
5961
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
5962
+ stageId,
5963
+ timeoutMinutes,
5964
+ key: v.key,
5965
+ scope: v.scope,
5966
+ }),
5967
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
5968
+ recordMiss: () => recordMissedApproval({
5969
+ stageId,
5970
+ toolName: 'vault_request_access',
5971
+ action: `grant ${v.agent} ${v.scope} access to \`${v.key}\``,
5972
+ chatId: v.chat_id,
5973
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
5974
+ now,
5975
+ }),
5976
+ log: cardExpiryLog,
5977
+ })
5978
+ process.stderr.write(
5979
+ `telegram gateway: vault_request_access TTL expired β€” wake agent=${v.agent} ` +
5980
+ `key=${v.key} stage=${stageId} delivered=${delivered}\n`,
5981
+ )
5982
+ }
5983
+
5984
+ function expireVaultSaveCard(stageId: string, v: PendingVaultRequestSave, now: number): void {
5985
+ const timeoutMinutes = Math.round(VAULT_REQUEST_SAVE_TTL_MS / 60000)
5986
+ const { delivered } = expirePendingCard({
5987
+ remove: () => {
5988
+ pendingVaultRequestSaves.delete(stageId)
5989
+ pendingCardStore.remove(stageId)
5990
+ },
5991
+ editCard: () => void editCardExpired(
5992
+ v.chat_id,
5993
+ v.card_message_id,
5994
+ `βŒ› _This vault-save card for \`${escapeHtmlForTg(v.key)}\` timed out before you tapped. The secret was NOT stored. Ask **${escapeHtmlForTg(v.agent)}** to re-issue if you still want to save._`,
5995
+ ),
5996
+ buildInbound: () => buildVaultSaveTimeoutInbound({
5997
+ agent: v.agent,
5998
+ chatId: v.chat_id,
5999
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6000
+ stageId,
6001
+ timeoutMinutes,
6002
+ key: v.key,
6003
+ }),
6004
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
6005
+ recordMiss: () => recordMissedApproval({
6006
+ stageId,
6007
+ toolName: 'vault_request_save',
6008
+ action: `save the secret \`${v.key}\` for ${v.agent}`,
6009
+ chatId: v.chat_id,
6010
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6011
+ now,
6012
+ }),
6013
+ log: cardExpiryLog,
6014
+ })
6015
+ process.stderr.write(
6016
+ `telegram gateway: vault_request_save TTL expired β€” wake agent=${v.agent} ` +
6017
+ `key=${v.key} stage=${stageId} delivered=${delivered}\n`,
6018
+ )
6019
+ }
6020
+
6021
+ function expireSecretRequestCard(stageId: string, v: PendingSecretRequest, now: number): void {
6022
+ const timeoutMinutes = Math.round(PENDING_SECRET_REQUEST_TTL_MS / 60000)
6023
+ const { delivered } = expirePendingCard({
6024
+ remove: () => {
6025
+ pendingSecretRequests.delete(stageId)
6026
+ pendingCardStore.remove(stageId)
6027
+ },
6028
+ editCard: () => void editCardExpired(
6029
+ v.chat_id,
6030
+ v.card_message_id,
6031
+ `βŒ› _This secret-request card for \`${escapeHtmlForTg(v.key)}\` timed out before you provided it. Ask **${escapeHtmlForTg(v.agent)}** to re-request if it still needs the value._`,
6032
+ ),
6033
+ buildInbound: () => buildSecretRequestTimeoutInbound({
6034
+ agent: v.agent,
6035
+ chatId: v.chat_id,
6036
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6037
+ stageId,
6038
+ timeoutMinutes,
6039
+ key: v.key,
6040
+ }),
6041
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
6042
+ recordMiss: () => recordMissedApproval({
6043
+ stageId,
6044
+ toolName: 'request_secret',
6045
+ action: `provide the secret \`${v.key}\` for ${v.agent}`,
6046
+ chatId: v.chat_id,
6047
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6048
+ now,
6049
+ }),
6050
+ log: cardExpiryLog,
6051
+ })
6052
+ process.stderr.write(
6053
+ `telegram gateway: request_secret TTL expired β€” wake agent=${v.agent} ` +
6054
+ `key=${v.key} stage=${stageId} delivered=${delivered}\n`,
6055
+ )
6056
+ }
6057
+
6058
+ function expireMentalModelProposeCard(stageId: string, v: PendingMentalModelPropose, now: number): void {
6059
+ const timeoutMinutes = Math.round(MENTAL_MODEL_PROPOSE_TTL_MS / 60000)
6060
+ const { delivered } = expirePendingCard({
6061
+ remove: () => {
6062
+ pendingMentalModelProposes.delete(stageId)
6063
+ pendingCardStore.remove(stageId)
6064
+ },
6065
+ editCard: () => void editCardExpired(
6066
+ v.chat_id,
6067
+ v.card_message_id,
6068
+ `βŒ› _This mental-model proposal card for \`${escapeHtmlForTg(v.spec.name)}\` timed out before you tapped. Ask **${escapeHtmlForTg(v.agent)}** to re-propose if it still stands._`,
6069
+ ),
6070
+ buildInbound: () => buildMentalModelProposeTimeoutInbound({
6071
+ agent: v.agent,
6072
+ chatId: v.chat_id,
6073
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6074
+ stageId,
6075
+ timeoutMinutes,
6076
+ name: v.spec.name,
6077
+ }),
6078
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
6079
+ recordMiss: () => recordMissedApproval({
6080
+ stageId,
6081
+ toolName: 'mental_model_propose',
6082
+ action: `declare the mental model \`${v.spec.name}\` for ${v.agent}`,
6083
+ chatId: v.chat_id,
6084
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6085
+ now,
6086
+ }),
6087
+ log: cardExpiryLog,
6088
+ })
6089
+ process.stderr.write(
6090
+ `telegram gateway: mental_model_propose TTL expired β€” wake agent=${v.agent} ` +
6091
+ `name=${v.spec.name} stage=${stageId} delivered=${delivered}\n`,
6092
+ )
6093
+ }
6094
+
6095
+ // Run all four agent-initiated approval-card expiry sweeps. Called from the
6096
+ // pendingStateReaper (the authoritative 60s timer). Each family sweep is
6097
+ // per-entry guarded via sweepExpiredEntries, so one throwing expiry (dead IPC
6098
+ // socket, store IO error) can't skip the remaining entries or families.
6099
+ function sweepExpiredApprovalCards(now: number): void {
6100
+ sweepPendingVaultRequestAccesses(now)
6101
+ sweepPendingVaultRequestSaves(now)
6102
+ sweepPendingMentalModelProposes(now)
6103
+ sweepSecretRequests(now)
6104
+ }
6105
+
6106
+ // Boot restore: repopulate the four in-memory approval-card maps from the
6107
+ // durable store so a post-restart tap on a still-valid card resolves normally
6108
+ // (approve β†’ grant + synthetic; deny β†’ denial synthetic) instead of hitting
6109
+ // the "Card expired" tombstone. Entries already past their TTL are left for
6110
+ // the reaper's next tick, which wakes the parked agent via the timeout path.
6111
+ // vault_request_save entries restore WITHOUT their staged value (never
6112
+ // persisted) and are flagged `restoredWithoutValue` so a Save tap degrades
6113
+ // gracefully rather than writing an empty secret.
6114
+ function restorePendingApprovalCards(): number {
6115
+ let restored = 0
6116
+ for (const e of pendingCardStore.loadAll()) {
6117
+ try {
6118
+ if (e.family === 'vault_request_access') {
6119
+ pendingVaultRequestAccesses.set(e.stageId, {
6120
+ agent: e.agent,
6121
+ chat_id: e.chatId,
6122
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6123
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6124
+ key: e.key,
6125
+ scope: e.scope,
6126
+ ...(e.reason != null ? { reason: e.reason } : {}),
6127
+ ttl_seconds: e.ttlSeconds,
6128
+ staged_at: e.stagedAt,
6129
+ })
6130
+ restored++
6131
+ } else if (e.family === 'vault_request_save') {
6132
+ pendingVaultRequestSaves.set(e.stageId, {
6133
+ agent: e.agent,
6134
+ chat_id: e.chatId,
6135
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6136
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6137
+ key: e.key,
6138
+ kind: e.kind,
6139
+ value: '', // never persisted β€” secrets hygiene
6140
+ ...(e.why != null ? { why: e.why } : {}),
6141
+ staged_at: e.stagedAt,
6142
+ restoredWithoutValue: true,
6143
+ })
6144
+ restored++
6145
+ } else if (e.family === 'request_secret') {
6146
+ pendingSecretRequests.set(e.stageId, {
6147
+ agent: e.agent,
6148
+ chat_id: e.chatId,
6149
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6150
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6151
+ key: e.key,
6152
+ ...(e.reason != null ? { reason: e.reason } : {}),
6153
+ staged_at: e.stagedAt,
6154
+ })
6155
+ restored++
6156
+ } else if (e.family === 'mental_model_propose') {
6157
+ pendingMentalModelProposes.set(e.stageId, {
6158
+ agent: e.agent,
6159
+ chat_id: e.chatId,
6160
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6161
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6162
+ spec: e.spec,
6163
+ ...(e.reason != null ? { reason: e.reason } : {}),
6164
+ staged_at: e.stagedAt,
6165
+ })
6166
+ restored++
6167
+ }
6168
+ } catch (err) {
6169
+ process.stderr.write(
6170
+ `telegram gateway: pending-card restore skipped a malformed entry: ${(err as Error).message}\n`,
6171
+ )
6172
+ }
6173
+ }
6174
+ if (restored > 0) {
6175
+ process.stderr.write(
6176
+ `telegram gateway: restored ${restored} pending approval card(s) from prior gateway session\n`,
6177
+ )
6178
+ }
6179
+ return restored
6180
+ }
6181
+
5467
6182
  // 60-second sweep drops anything past its documented TTL.
5468
6183
  const pendingStateReaper = setInterval(() => {
5469
6184
  const now = Date.now()
@@ -5597,6 +6312,23 @@ const pendingStateReaper = setInterval(() => {
5597
6312
  for (const [k, v] of deferredSecrets) {
5598
6313
  if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
5599
6314
  }
6315
+ // Agent-initiated approval cards (vault_request_access / vault_request_save /
6316
+ // request_secret / mental_model_propose): expire past-TTL entries and WAKE
6317
+ // the parked agent (timeout synthetic + missed-approvals re-offer). This is
6318
+ // the authoritative timer β€” before this the only expiry path was a lazy
6319
+ // sweep on the NEXT stage, so an agent that ended its turn to wait on one of
6320
+ // these cards could sit parked forever if no further request ever staged.
6321
+ // try/catch matches the sibling sweepStaleTurnActiveMarker guard: an escaped
6322
+ // throw inside this setInterval callback would reach uncaughtException and
6323
+ // take the WHOLE gateway down (per-entry faults are already contained inside
6324
+ // sweepExpiredEntries/expirePendingCard; this is the outer belt).
6325
+ try {
6326
+ sweepExpiredApprovalCards(now)
6327
+ } catch (err) {
6328
+ process.stderr.write(
6329
+ `telegram gateway: approval-card expiry sweep failed: ${(err as Error).message}\n`,
6330
+ )
6331
+ }
5600
6332
  // #550: sweep a stale turn-active marker. Defence-in-depth for the
5601
6333
  // case where neither the turn_end arm nor onTurnComplete fired (SDK
5602
6334
  // killed before the JSONL turn_duration record, compaction window,
@@ -7282,6 +8014,19 @@ function trackRedeliveredInbound(merged: InboundMessage): void {
7282
8014
  ) {
7283
8015
  return
7284
8016
  }
8017
+ // Button-tap anti-storm guard β€” mirrors the immediate-delivery path in
8018
+ // deliverButtonTapInbound. A tap synthesized without a source message
8019
+ // (`cbMessageId == null` β†’ `messageId: 0`, no meta.message_id) has no id
8020
+ // the `enqueue` ack can ever match, so enrolling it would make the
8021
+ // never-drop sweep re-deliver it until TTL. The immediate path skips
8022
+ // tracking for such taps; a tap that buffered mid-turn and flushed through
8023
+ // here must be skipped identically (asymmetry = a storm on one path only).
8024
+ if (
8025
+ merged.meta?.button_callback === 'true' &&
8026
+ (merged.meta.message_id == null || merged.meta.message_id === '')
8027
+ ) {
8028
+ return
8029
+ }
7285
8030
  const key = chatKey(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null)
7286
8031
  trackDelivery(
7287
8032
  deliveryQueue,
@@ -9147,6 +9892,42 @@ const ipcServer: IpcServer = createIpcServer({
9147
9892
  void fireFleetAutoFallback(msg.agentName, untilMs)
9148
9893
  },
9149
9894
 
9895
+ // Issue #2971 β€” read-only wedge-watchdog probe: is there a live pending
9896
+ // permission request (Telegram approval card) for this agent right now?
9897
+ // Sourced directly from `pendingPermissions` β€” no mutation, no card
9898
+ // posting, just a snapshot read. The watchdog uses this to decide whether
9899
+ // to Esc a shape-persistent permission-prompt TUI or defer to the card /
9900
+ // the #2724 TTL reaper. Always answered synchronously and on the SAME
9901
+ // connection so the watchdog's short (~2s) budget can resolve promptly.
9902
+ onQueryPendingPermission(client: IpcClient, msg: QueryPendingPermissionMessage) {
9903
+ const self = process.env.SWITCHROOM_AGENT_NAME
9904
+ if (self && msg.agentName !== self) {
9905
+ process.stderr.write(
9906
+ `telegram gateway: query_pending_permission rejected β€” agent mismatch (${msg.agentName} != ${self})\n`,
9907
+ )
9908
+ try {
9909
+ client.send({ type: 'pending_permission_status', correlationId: msg.correlationId, pending: false })
9910
+ } catch { /* best effort */ }
9911
+ return
9912
+ }
9913
+ // Any LIVE entry answers the question β€” this gateway serves exactly one
9914
+ // agent, so `pendingPermissions` is already scoped to `msg.agentName`.
9915
+ const [requestId] = pendingPermissions.keys()
9916
+ const pending = pendingPermissions.size > 0
9917
+ try {
9918
+ client.send({
9919
+ type: 'pending_permission_status',
9920
+ correlationId: msg.correlationId,
9921
+ pending,
9922
+ ...(pending && requestId ? { requestId } : {}),
9923
+ })
9924
+ } catch (err) {
9925
+ process.stderr.write(
9926
+ `telegram gateway: query_pending_permission reply failed: ${(err as Error).message}\n`,
9927
+ )
9928
+ }
9929
+ },
9930
+
9150
9931
  // #2670 one-tap self-improvement β€” persist a skill-improvement proposal and
9151
9932
  // post its Approve/Dismiss card. The store transition + apply-injection on
9152
9933
  // Approve are owned by handleSkillProposalCallback (so a gateway restart
@@ -11634,6 +12415,21 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
11634
12415
  { threadId, chat_id, verb: 'vault_request_save.card' },
11635
12416
  )
11636
12417
  pending.card_message_id = sent.message_id
12418
+ // Persist card METADATA (never the staged `value` β€” secrets hygiene) so a
12419
+ // gateway restart doesn't strand the parked agent. A restored Save tap can't
12420
+ // complete (value is gone) and degrades to a "value lost to restart" wake-up.
12421
+ pendingCardStore.add({
12422
+ family: 'vault_request_save',
12423
+ stageId,
12424
+ agent: pending.agent,
12425
+ chatId: pending.chat_id,
12426
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
12427
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
12428
+ key: pending.key,
12429
+ kind: pending.kind,
12430
+ ...(pending.why != null ? { why: pending.why } : {}),
12431
+ stagedAt: pending.staged_at,
12432
+ })
11637
12433
 
11638
12434
  return {
11639
12435
  content: [
@@ -11677,11 +12473,17 @@ const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
11677
12473
  const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
11678
12474
  const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
11679
12475
 
11680
- function sweepSecretRequests(): void {
11681
- const now = Date.now()
11682
- for (const [k, v] of pendingSecretRequests) {
11683
- if (now - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS) pendingSecretRequests.delete(k)
11684
- }
12476
+ function sweepSecretRequests(now = Date.now()): void {
12477
+ sweepExpiredEntries(
12478
+ pendingSecretRequests,
12479
+ (v, n) => n - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS,
12480
+ expireSecretRequestCard,
12481
+ now,
12482
+ cardExpiryLog,
12483
+ )
12484
+ // armedSecretCaptures is a TRANSIENT post-tap window (never persisted): it's
12485
+ // only set after the operator taps [Provide securely], and the request is
12486
+ // no longer parked-on-a-card. Just drop stale ones β€” no wake needed.
11685
12487
  for (const [k, v] of armedSecretCaptures) {
11686
12488
  if (now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) armedSecretCaptures.delete(k)
11687
12489
  }
@@ -11731,7 +12533,10 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
11731
12533
  // Dedupe: one open request per (chat, key). Drop any prior stage for
11732
12534
  // the same target so the operator never sees stacked cards.
11733
12535
  for (const [sid, p] of pendingSecretRequests) {
11734
- if (p.chat_id === chat_id && p.key === key) pendingSecretRequests.delete(sid)
12536
+ if (p.chat_id === chat_id && p.key === key) {
12537
+ pendingSecretRequests.delete(sid)
12538
+ pendingCardStore.remove(sid)
12539
+ }
11735
12540
  }
11736
12541
 
11737
12542
  const stageId = randomBytes(4).toString('hex')
@@ -11753,6 +12558,21 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
11753
12558
  { threadId, chat_id, verb: 'request_secret.card' },
11754
12559
  )
11755
12560
  pending.card_message_id = sent.message_id
12561
+ // Persist card metadata so a gateway restart doesn't strand the parked
12562
+ // agent. request_secret holds NO value at staging time (the value arrives
12563
+ // after the operator taps [Provide securely]), so nothing sensitive lands
12564
+ // on disk here.
12565
+ pendingCardStore.add({
12566
+ family: 'request_secret',
12567
+ stageId,
12568
+ agent: pending.agent,
12569
+ chatId: pending.chat_id,
12570
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
12571
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
12572
+ key: pending.key,
12573
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
12574
+ stagedAt: pending.staged_at,
12575
+ })
11756
12576
 
11757
12577
  return {
11758
12578
  content: [
@@ -11801,6 +12621,7 @@ async function captureProvidedSecret(
11801
12621
  armedSecretCaptures.delete(chat_id)
11802
12622
  const pending = pendingSecretRequests.get(armed.stageId)
11803
12623
  pendingSecretRequests.delete(armed.stageId)
12624
+ pendingCardStore.remove(armed.stageId)
11804
12625
 
11805
12626
  // Delete the raw message FIRST β€” surfaces a warning if it fails.
11806
12627
  if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'provided secret value')
@@ -11916,6 +12737,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
11916
12737
 
11917
12738
  if (action === 'decline') {
11918
12739
  pendingSecretRequests.delete(stageId)
12740
+ pendingCardStore.remove(stageId)
11919
12741
  armedSecretCaptures.delete(pending.chat_id)
11920
12742
  await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
11921
12743
  if (pending.card_message_id != null) {
@@ -12083,12 +12905,27 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
12083
12905
  { threadId, chat_id, verb: 'vault_request_access.card' },
12084
12906
  )
12085
12907
  pending.card_message_id = sent.message_id
12908
+ // Persist card metadata (no secret material β€” this flow stages only the ACL
12909
+ // request) so a gateway restart doesn't strand the parked agent.
12910
+ pendingCardStore.add({
12911
+ family: 'vault_request_access',
12912
+ stageId,
12913
+ agent: pending.agent,
12914
+ chatId: pending.chat_id,
12915
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
12916
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
12917
+ key: pending.key,
12918
+ scope: pending.scope,
12919
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
12920
+ ttlSeconds: pending.ttl_seconds,
12921
+ stagedAt: pending.staged_at,
12922
+ })
12086
12923
 
12087
12924
  return {
12088
12925
  content: [
12089
12926
  {
12090
12927
  type: 'text',
12091
- text: `vault_request_access: card sent (stage_id=${stageId}, key=${key}, scope=${scopeRaw}). Wait for the operator to tap Approve or Deny β€” do not retry the vault read until you see a confirmation message. If the card times out (10 min) you can re-request.`,
12928
+ text: `vault_request_access: card sent (stage_id=${stageId}, key=${key}, scope=${scopeRaw}). Wait for the operator to tap Approve or Deny β€” do not retry the vault read until you see a confirmation message. If the card times out (${Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)} min) you can re-request.`,
12092
12929
  },
12093
12930
  ],
12094
12931
  }
@@ -12252,6 +13089,19 @@ async function executeMentalModelPropose(args: Record<string, unknown>): Promise
12252
13089
  { threadId, chat_id, verb: 'mental_model_propose.card' },
12253
13090
  )
12254
13091
  pending.card_message_id = sent.message_id
13092
+ // Persist card metadata (the proposed DECLARATION is not secret material) so
13093
+ // a gateway restart doesn't strand the parked agent.
13094
+ pendingCardStore.add({
13095
+ family: 'mental_model_propose',
13096
+ stageId,
13097
+ agent: pending.agent,
13098
+ chatId: pending.chat_id,
13099
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
13100
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
13101
+ spec: pending.spec,
13102
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
13103
+ stagedAt: pending.staged_at,
13104
+ })
12255
13105
  // Only count a proposal against the rate budget once its card actually
12256
13106
  // posted (validation errors / dupes don't consume the budget).
12257
13107
  mentalModelProposeTimes.push(Date.now())
@@ -12657,6 +13507,7 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
12657
13507
  elapsedMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
12658
13508
  toolCount: turn.labeledToolCount,
12659
13509
  state: final ? 'done' : 'running',
13510
+ model: turn.currentModel,
12660
13511
  }
12661
13512
  return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
12662
13513
  }
@@ -12896,7 +13747,22 @@ async function drainActivitySummary(
12896
13747
  turn.activityLastSentRender = target
12897
13748
  } catch (err) {
12898
13749
  const msg = err instanceof Error ? err.message : String(err)
12899
- if (!msg.includes('message is not modified')) {
13750
+ const low = msg.toLowerCase()
13751
+ // Transport-class failures (429, message gone, "not modified") must
13752
+ // NOT inflate `activityDrainFailures` β€” that counter flags a turn as
13753
+ // DEGRADED on turn-end, and a transient rate-limit or an
13754
+ // already-deleted message is not a logic defect. Counting them would
13755
+ // false-flag a healthy turn. "not modified" is success; gone/429 are
13756
+ // retried or harmless. Only a genuine send/open failure counts.
13757
+ const isTransport =
13758
+ low.includes('not modified') ||
13759
+ low.includes('not found') ||
13760
+ low.includes("can't be edited") ||
13761
+ low.includes('cannot be edited') ||
13762
+ low.includes('not enough rights') ||
13763
+ low.includes('429') ||
13764
+ low.includes('retry after')
13765
+ if (!isTransport) {
12900
13766
  turn.activityDrainFailures += 1
12901
13767
  // Surface the failing anchor + topic: the resume-400 bug fed a
12902
13768
  // fabricated 13-digit message_id as the reply anchor here, so every
@@ -12959,6 +13825,7 @@ function openLivenessFeedIfDue(turn: CurrentTurn): void {
12959
13825
  const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working…']
12960
13826
  const livenessHeader: SessionActivityHeader = {
12961
13827
  label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
13828
+ model: turn.currentModel,
12962
13829
  }
12963
13830
  // Liveness card is a single "step" whose start is the turn start, so `age`
12964
13831
  // IS the step's own elapsed. formatStepSuffix keeps the `β†’` line timer-free
@@ -13089,6 +13956,7 @@ function feedHeartbeatTick(): void {
13089
13956
  const age = Date.now() - turn.startedAt
13090
13957
  const livenessHeader: SessionActivityHeader = {
13091
13958
  label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
13959
+ model: turn.currentModel,
13092
13960
  }
13093
13961
  const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working in background…']
13094
13962
  // `subagentAt` is the worker's last ADVANCE β€” the current step's start β€”
@@ -13246,7 +14114,23 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
13246
14114
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
13247
14115
  )
13248
14116
  } catch (err) {
13249
- process.stderr.write(`telegram gateway: activity-summary delete failed: ${err}\n`)
14117
+ // Best-effort teardown of a status card. A transport-class failure
14118
+ // (message already deleted, chat gone, 429) is not a liveness-logic
14119
+ // error β€” "message to delete not found" is the desired end state
14120
+ // here, and a 429 on a teardown delete is retried by robustApiCall.
14121
+ // Stay silent on those; warn only on a genuinely unexpected error.
14122
+ const msg = err instanceof Error ? err.message : String(err)
14123
+ const low = msg.toLowerCase()
14124
+ if (
14125
+ low.includes('not modified') ||
14126
+ low.includes('not found') ||
14127
+ low.includes('not enough rights') ||
14128
+ low.includes('429') ||
14129
+ low.includes('retry after')
14130
+ ) {
14131
+ return
14132
+ }
14133
+ process.stderr.write(`telegram gateway: activity-summary delete failed: ${msg}\n`)
13250
14134
  }
13251
14135
  return
13252
14136
  }
@@ -13262,6 +14146,7 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
13262
14146
  const livenessElapsed = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
13263
14147
  const livenessHeader: SessionActivityHeader = {
13264
14148
  label: 'Agent', elapsedMs: livenessElapsed, toolCount: turn.labeledToolCount, state: 'done',
14149
+ model: turn.currentModel,
13265
14150
  }
13266
14151
  finalHtml = renderActivityFeedWithNested(['Working…'], [], true, '', undefined, livenessHeader)
13267
14152
  }
@@ -13272,10 +14157,26 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
13272
14157
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
13273
14158
  )
13274
14159
  } catch (err) {
14160
+ // Same transport-class discipline as the delete path: the card
14161
+ // finalize is a best-effort liveness edit. "not modified" = the card
14162
+ // already shows the finalized body (success); "not found" / "not
14163
+ // enough rights" = the message is gone (nothing to finalize); 429 is
14164
+ // retried by robustApiCall. None of those are liveness-logic errors
14165
+ // and none warrant a stderr warning. Warn only on the unexpected.
13275
14166
  const msg = err instanceof Error ? err.message : String(err)
13276
- if (!msg.includes('message is not modified')) {
13277
- process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
14167
+ const low = msg.toLowerCase()
14168
+ if (
14169
+ low.includes('not modified') ||
14170
+ low.includes('not found') ||
14171
+ low.includes("can't be edited") ||
14172
+ low.includes('cannot be edited') ||
14173
+ low.includes('not enough rights') ||
14174
+ low.includes('429') ||
14175
+ low.includes('retry after')
14176
+ ) {
14177
+ return
13278
14178
  }
14179
+ process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
13279
14180
  }
13280
14181
  })
13281
14182
  }
@@ -13736,6 +14637,22 @@ function handleSessionEvent(ev: SessionEvent): void {
13736
14637
  return
13737
14638
  }
13738
14639
  case 'dequeue': return
14640
+ case 'model': {
14641
+ // Live model capture for the main turn. The session-tail projection
14642
+ // already filtered sentinels (`<synthetic>` compaction lines), so any
14643
+ // value reaching here is a real resolved model id. Record it on the turn
14644
+ // (update-on-change) so the activity/liveness card header and /status
14645
+ // render the model actually serving this turn's API calls β€” transcript-
14646
+ // sourced, never config. Also note it on the freshness-aware session-model
14647
+ // source so a /status query between turns still reflects the last model
14648
+ // (and a fresh assistant line reclaims the source from a /model override).
14649
+ const turn = currentTurn
14650
+ if (turn != null) {
14651
+ turn.currentModel = ev.model
14652
+ }
14653
+ sessionModelSource.noteTranscriptModel(ev.model)
14654
+ return
14655
+ }
13739
14656
  case 'thinking': {
13740
14657
  // #1067: snapshot the turn atom at handler entry. Even though this
13741
14658
  // handler is sync, the principle is uniform across all event arms
@@ -16650,12 +17567,28 @@ async function handleInbound(
16650
17567
  TOPIC_FRAMING_ENABLED && messageThreadId != null
16651
17568
  ? 'This message belongs to the current topic only β€” answer ONLY this question, in this topic. Do not also answer a pending message from another topic.'
16652
17569
  : undefined
17570
+ // person_id name resolution (docs/configuration.md, resolve-person.ts):
17571
+ // chat-scoped, boot-time-static lookup β€” falls back to today's raw
17572
+ // id/username behavior (fail-open) whenever unresolved or when group
17573
+ // membership can't be positively confirmed from access.json's allowFrom.
17574
+ // Never touches access.json itself and never blocks/denies anything.
17575
+ const rawUser = from.username ?? String(from.id)
17576
+ const displayUser = safeResolvePersonName(
17577
+ PERSON_DIRECTORY,
17578
+ {
17579
+ telegramId: String(from.id),
17580
+ username: from.username,
17581
+ isDm: isDmChatId(chat_id),
17582
+ groupAllowFrom: access.groups[chat_id]?.allowFrom,
17583
+ },
17584
+ rawUser,
17585
+ )
16653
17586
  const inboundMsg: InboundMessage = {
16654
17587
  type: 'inbound',
16655
17588
  chatId: chat_id,
16656
17589
  ...(messageThreadId != null ? { threadId: messageThreadId } : {}),
16657
17590
  messageId: msgId ?? 0,
16658
- user: from.username ?? String(from.id),
17591
+ user: displayUser,
16659
17592
  userId: from.id,
16660
17593
  ts: ctx.message?.date ?? Math.floor(Date.now() / 1000),
16661
17594
  text: effectiveText,
@@ -16670,7 +17603,7 @@ async function handleInbound(
16670
17603
  meta: {
16671
17604
  chat_id,
16672
17605
  ...(msgId != null ? { message_id: String(msgId) } : {}),
16673
- user: from.username ?? String(from.id),
17606
+ user: displayUser,
16674
17607
  user_id: String(from.id),
16675
17608
  ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
16676
17609
  ...(messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {}),
@@ -18286,13 +19219,12 @@ function buildAgentAudit(agentName: string): AgentAudit | undefined {
18286
19219
  // broker's fleet-wide `ListStateData` payload via
18287
19220
  // `buildAuthSummaryFromBroker`, with billingType pulled from the
18288
19221
  // agent's `.claude.json` (the broker doesn't track plan tier).
18289
- /**
18290
- * Live session-model override set by the `/model` picker (session-only). Held
18291
- * in gateway memory so it clears on restart, the same point at which claude's
18292
- * session reverts to the configured model β€” keeping `/status` honest without
18293
- * a persisted store. Null when no session switch is active.
18294
- */
18295
- let activeSessionModelOverride: string | null = null
19222
+ // The live session-model override set by the `/model` picker (session-only)
19223
+ // lives on `sessionModelSource` (setOverride/getOverride, declared beside the
19224
+ // currentTurn globals). Held in gateway memory so it clears on restart, the
19225
+ // same point at which claude's session reverts to the configured model β€”
19226
+ // keeping `/status` honest without a persisted store. `resolve()` arbitrates
19227
+ // freshness against the transcript-observed model (#2982 idle-switch window).
18296
19228
 
18297
19229
  async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
18298
19230
  type AgentListResp = {
@@ -18322,7 +19254,18 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
18322
19254
  return {
18323
19255
  agentName,
18324
19256
  model: a?.model ?? null,
18325
- sessionModel: activeSessionModelOverride,
19257
+ // The FRESHEST session-model observation wins (session-model-source.ts):
19258
+ // the transcript's `message.model` (ground truth for the last API call)
19259
+ // vs the #2982 /model override (the only truthful source in the idle-
19260
+ // after-switch window, before the next assistant line). Both rendered
19261
+ // through formatModelLabel for a consistent short form; an override value
19262
+ // that isn't model-shaped (an already-friendly "Opus 4.8" label) passes
19263
+ // through verbatim. Never sourced from config.
19264
+ sessionModel: (() => {
19265
+ const resolved = sessionModelSource.resolve()
19266
+ if (resolved == null) return null
19267
+ return formatModelLabel(resolved.model) ?? resolved.model
19268
+ })(),
18326
19269
  extendsProfile: (a?.extends ?? a?.template) ?? null,
18327
19270
  topicName: a?.topic_name ?? null,
18328
19271
  topicEmoji: a?.topic_emoji ?? null,
@@ -18553,7 +19496,7 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18553
19496
  },
18554
19497
  escapeHtml: escapeHtmlForTg,
18555
19498
  preBlock,
18556
- getActiveSessionModel: () => activeSessionModelOverride,
19499
+ getActiveSessionModel: () => sessionModelSource.getOverride(),
18557
19500
  /**
18558
19501
  * Graceful restart for sr-* β†’ Claude model switch. Same mechanism as
18559
19502
  * the /restart command: writes a restart marker (so the post-restart
@@ -18563,9 +19506,18 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18563
19506
  scheduleRestart: async (reason: string) => {
18564
19507
  const name = getMyAgentName()
18565
19508
  // Debounce: mirror the /restart command's 15 s guard to prevent
18566
- // double-dispatch on a rapid double-tap of /model <claude-alias>.
19509
+ // double-dispatch on a rapid double-tap of /model <claude-alias>. This
19510
+ // path previously `return`ed silently β€” indistinguishable from a
19511
+ // successful dispatch β€” so a model switch caught in the window was a
19512
+ // silent no-op while the caller reported success. THROW a tagged error
19513
+ // so scheduleModelRelaunch can react (keep the in-flight restart's carrier,
19514
+ // tell the operator honestly) instead of falsely claiming the switch stuck.
18567
19515
  const existing = readRestartMarker()
18568
- if (existing && Date.now() - existing.ts < 15_000) return
19516
+ if (existing && Date.now() - existing.ts < 15_000) {
19517
+ const e = new Error('a restart is already in flight β€” try again in ~15s')
19518
+ ;(e as { code?: string }).code = 'restart_in_flight'
19519
+ throw e
19520
+ }
18569
19521
  if (restartCtx) {
18570
19522
  writeRestartMarker({
18571
19523
  chat_id: restartCtx.chatId,
@@ -18615,9 +19567,25 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18615
19567
  if (!agentDir) throw new Error('agent dir unresolvable β€” cannot write session-model carrier')
18616
19568
  // Carrier: single line, token + newline, no quoting (start.sh strips
18617
19569
  // whitespace and shape-gates). One-shot β€” consumed on the next boot.
19570
+ const prevOverride = sessionModelSource.getOverride()
18618
19571
  writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
18619
- activeSessionModelOverride = model
18620
- await deps.scheduleRestart(reason)
19572
+ sessionModelSource.setOverride(model)
19573
+ try {
19574
+ await deps.scheduleRestart(reason)
19575
+ } catch (err) {
19576
+ const carrierPath = join(agentDir, '.session-model-override')
19577
+ // A restart already in flight OWNS the carrier we just wrote β€” it will
19578
+ // consume our token at boot, so the switch is queued, not lost: keep the
19579
+ // carrier + override and let the caller tell the operator "~15s". Any
19580
+ // OTHER dispatch failure means no restart is coming, so roll BOTH back β€”
19581
+ // a lingering carrier/override would lie to /status and mis-launch the
19582
+ // NEXT ordinary restart.
19583
+ if ((err as { code?: string })?.code !== 'restart_in_flight') {
19584
+ try { rmSync(carrierPath, { force: true }) } catch { /* best-effort */ }
19585
+ sessionModelSource.setOverride(prevOverride)
19586
+ }
19587
+ throw err
19588
+ }
18621
19589
  },
18622
19590
  }
18623
19591
  return deps
@@ -18646,6 +19614,15 @@ bot.command('model', async ctx => {
18646
19614
  return
18647
19615
  }
18648
19616
  const reply = await handleModelCommand(parsed, deps)
19617
+ // Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
19618
+ // actually running β€” the SAME in-memory override the menu callback path sets
19619
+ // (buildAgentMetadata resolves it via sessionModelSource). Only
19620
+ // set on the confirmed inject path; the sr-*/relaunch paths already set the
19621
+ // override inside scheduleModelRelaunch, and an unverified switch carries no
19622
+ // selectedModel so /status is never lied to.
19623
+ if (reply.selectedModel) {
19624
+ sessionModelSource.setOverride(reply.selectedModel)
19625
+ }
18649
19626
  await switchroomReply(ctx, reply.text, { html: reply.html })
18650
19627
  })
18651
19628
 
@@ -20941,6 +21918,7 @@ async function performVaultAccessApproval(
20941
21918
  const visible = await listViaBroker()
20942
21919
  if (visible !== null && visible.includes(pending.key)) {
20943
21920
  pendingVaultRequestAccesses.delete(stageId)
21921
+ pendingCardStore.remove(stageId)
20944
21922
  if (pending.card_message_id != null) {
20945
21923
  await ctx.api
20946
21924
  .editMessageText(
@@ -21039,6 +22017,7 @@ async function performVaultAccessApproval(
21039
22017
  // the agent to re-issue, or the broker error message will tell
21040
22018
  // them the next step.
21041
22019
  pendingVaultRequestAccesses.delete(stageId)
22020
+ pendingCardStore.remove(stageId)
21042
22021
  if (pending.card_message_id != null) {
21043
22022
  await ctx.api
21044
22023
  .editMessageText(
@@ -21070,6 +22049,7 @@ async function performVaultAccessApproval(
21070
22049
  }
21071
22050
 
21072
22051
  pendingVaultRequestAccesses.delete(stageId)
22052
+ pendingCardStore.remove(stageId)
21073
22053
  if (pending.card_message_id != null) {
21074
22054
  const days = Math.round(pending.ttl_seconds / 86400)
21075
22055
  const footer =
@@ -21278,23 +22258,17 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
21278
22258
  // this, a card left untapped past its TTL is still resolvable if no fresh
21279
22259
  // proposal has run the sweep β€” an operator could approve a stale proposal.
21280
22260
  if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
21281
- pendingMentalModelProposes.delete(stageId)
21282
- await ctx.answerCallbackQuery({ text: 'Card expired β€” ask the agent to re-propose.' }).catch(() => {})
21283
- if (pending.card_message_id != null) {
21284
- await ctx.api
21285
- .editMessageText(
21286
- pending.chat_id,
21287
- pending.card_message_id,
21288
- richMessage('βŒ› _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
21289
- { reply_markup: { inline_keyboard: [] } },
21290
- )
21291
- .catch(() => {})
21292
- }
22261
+ // Expired between post and tap: route through the shared expiry path so the
22262
+ // parked agent is WOKEN (timeout synthetic + missed-approvals re-offer) and
22263
+ // the durable store entry is cleared β€” not just a silent map delete.
22264
+ expireMentalModelProposeCard(stageId, pending, Date.now())
22265
+ await ctx.answerCallbackQuery({ text: 'Card expired β€” the agent was notified.' }).catch(() => {})
21293
22266
  return
21294
22267
  }
21295
22268
  // Single-shot: remove the pending entry immediately so a double-tap can't
21296
22269
  // resolve twice.
21297
22270
  pendingMentalModelProposes.delete(stageId)
22271
+ pendingCardStore.remove(stageId)
21298
22272
 
21299
22273
  const proposal: MentalModelPendingProposal = {
21300
22274
  agent: pending.agent,
@@ -21436,6 +22410,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
21436
22410
 
21437
22411
  if (action === 'deny') {
21438
22412
  pendingVaultRequestAccesses.delete(stageId)
22413
+ pendingCardStore.remove(stageId)
21439
22414
  await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
21440
22415
  if (pending.card_message_id != null) {
21441
22416
  await ctx.api
@@ -21658,6 +22633,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21658
22633
 
21659
22634
  if (action === 'discard') {
21660
22635
  pendingVaultRequestSaves.delete(stageId)
22636
+ pendingCardStore.remove(stageId)
21661
22637
  await ctx.answerCallbackQuery({ text: '🚫 Discarded' }).catch(() => {})
21662
22638
  if (pending.card_message_id != null) {
21663
22639
  await ctx.api
@@ -21728,6 +22704,43 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21728
22704
  // stale "spinning" state on the button while we run the write.
21729
22705
  await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
21730
22706
 
22707
+ // Restored-after-restart guard: the staged secret VALUE is held in gateway
22708
+ // memory only and is never persisted (secrets hygiene). If this card was
22709
+ // restored from disk after a gateway restart, the value is gone β€” we CANNOT
22710
+ // complete the write. Degrade gracefully: strip the card, wake the agent
22711
+ // with a save-failed (value-lost) synthetic so it re-requests, and stop.
22712
+ if (pending.restoredWithoutValue || pending.value.length === 0) {
22713
+ pendingVaultRequestSaves.delete(stageId)
22714
+ pendingCardStore.remove(stageId)
22715
+ if (pending.card_message_id != null) {
22716
+ await ctx.api
22717
+ .editMessageText(
22718
+ pending.chat_id,
22719
+ pending.card_message_id,
22720
+ richMessage(`⚠️ _The staged value for \`${escapeHtmlForTg(pending.key)}\` was lost to a gateway restart β€” nothing was saved. Ask **${escapeHtmlForTg(pending.agent)}** to re-issue \`vault_request_save\` if you still want to store it._`),
22721
+ { reply_markup: { inline_keyboard: [] } },
22722
+ )
22723
+ .catch(() => {})
22724
+ }
22725
+ const lostInbound = buildVaultSaveFailedInbound({
22726
+ ctx: {
22727
+ agent: pending.agent,
22728
+ key: pending.key,
22729
+ chat_id: pending.chat_id,
22730
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
22731
+ },
22732
+ stageId,
22733
+ operatorId: senderId,
22734
+ reason: 'staged value lost to a gateway restart β€” re-request the save',
22735
+ })
22736
+ const lDelivered = deliverResumeSyntheticOrBuffer(pending.agent, lostInbound)
22737
+ process.stderr.write(
22738
+ `telegram gateway: vault_request_save value lost to restart β€” wake agent=${pending.agent} ` +
22739
+ `key=${pending.key} stage=${stageId} delivered=${lDelivered}\n`,
22740
+ )
22741
+ return
22742
+ }
22743
+
21731
22744
  // #1115 follow-up: the save-approve flow now mirrors the access-
21732
22745
  // approve flow under telegram-id mode β€” broker `put` accepts
21733
22746
  // `attest_via_posture: true` (server.ts:1448-1500), so the
@@ -21763,6 +22776,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21763
22776
  .catch(() => {})
21764
22777
  }
21765
22778
  pendingVaultRequestSaves.delete(stageId)
22779
+ pendingCardStore.remove(stageId)
21766
22780
  return
21767
22781
  }
21768
22782
  // defaultVaultWrite spawns `switchroom vault set <key>` with the
@@ -21794,6 +22808,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21794
22808
  // retry by re-invoking the same MCP tool, but the value will be
21795
22809
  // re-staged with a new ID. Drop the current stage.
21796
22810
  pendingVaultRequestSaves.delete(stageId)
22811
+ pendingCardStore.remove(stageId)
21797
22812
  // Wake the waiting agent with the failure (symmetric with the
21798
22813
  // success/discard paths) so it doesn't assume vault:<key> exists.
21799
22814
  const failReason =
@@ -21819,6 +22834,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21819
22834
 
21820
22835
  // Success β€” mask the value in the card for visual confirmation.
21821
22836
  pendingVaultRequestSaves.delete(stageId)
22837
+ pendingCardStore.remove(stageId)
21822
22838
  if (pending.card_message_id != null) {
21823
22839
  await ctx.api
21824
22840
  .editMessageText(
@@ -22841,7 +23857,15 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
22841
23857
  tz,
22842
23858
  now: renderNow,
22843
23859
  demo: refreshDemo,
22844
- ...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: renderNow.getTime() }),
23860
+ // Honesty backstop (same as /usage): a TOTAL probe failure (zero
23861
+ // result rows, nothing served from cache) renders an explicit
23862
+ // "probe failed" marker instead of a false "Live" footer next to
23863
+ // no-data rows.
23864
+ ...(staleCachedAtMs != null
23865
+ ? { staleCachedAtMs }
23866
+ : probeResp.results.length > 0
23867
+ ? { liveProbedAtMs: renderNow.getTime() }
23868
+ : { probeFailed: true }),
22845
23869
  })
22846
23870
  const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
22847
23871
  const inline_keyboard = kbRows.map((row) =>
@@ -23307,21 +24331,49 @@ bot.command('usage', async ctx => {
23307
24331
  // flight; a TTL-hit or failed-probe fallback is tagged served:"cache",
23308
24332
  // which we surface as a "⚠ cached Nm ago" footer instead of a false
23309
24333
  // live stamp.
24334
+ const renderNow = new Date()
23310
24335
  const probeResp = await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
23311
24336
  const { quotas, staleCachedAtMs } = zipProbeResults(
23312
24337
  state.accounts.map((a) => a.label),
23313
24338
  probeResp.results,
23314
24339
  )
23315
- const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
24340
+ const { buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
23316
24341
  '../auth-snapshot-format.js'
23317
24342
  )
23318
- const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
24343
+ const { renderUsageCard } = await import('../quota-bar-format.js')
23319
24344
  const snapshots = buildSnapshotsFromState(state, quotas)
23320
- const text = renderAuthSnapshotFormat2(snapshots, {
23321
- tz,
23322
- now: new Date(),
24345
+ // /usage renders the compact quota-bar block (at-a-glance headroom +
24346
+ // pace tick per window) plus a two-line footer β€” the actionable
24347
+ // cross-account recommendation verdict and the freshness marker (`⚠
24348
+ // cached Nm ago` when the probe was served stale from cache, else
24349
+ // `Live Β· refreshed Nm ago`). The old Format 2 table
24350
+ // (renderAuthSnapshotFormat2) is no longer appended here (operator
24351
+ // call, 2026-07-10): the two views were redundant and the table
24352
+ // doubled the message length. Every per-window reset the table
24353
+ // carried has an equivalent in the bar rows' `pct% / <time-left>`
24354
+ // segment, just relative instead of absolute β€” no info lost; the
24355
+ // recommendation + cached/live footer the table used to carry are
24356
+ // preserved by renderUsageCard.
24357
+ const exhaustedByLabel = new Map<string, boolean>(
24358
+ state.accounts.map((a) => [a.label, a.exhausted]),
24359
+ )
24360
+ const text = renderUsageCard(snapshots, exhaustedByLabel, {
24361
+ now: renderNow,
23323
24362
  demo,
23324
- ...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: Date.now() }),
24363
+ // #2495 Change 2 β€” a TTL-hit / failed-probe fallback is tagged
24364
+ // served:"cache"; surface it as `⚠ cached Nm ago` instead of a
24365
+ // false live stamp. Otherwise stamp the live refresh time.
24366
+ // Honesty backstop: a TOTAL probe failure (the .catch above
24367
+ // returned `{results: []}` and nothing was served from cache)
24368
+ // must render an explicit "probe failed" marker, NOT a false
24369
+ // "Live" stamp next to "⚠️ no data" rows. Without this the
24370
+ // footer claimed "Live Β· refreshed 0s ago" while every account
24371
+ // row said "no data β€” probe failed" (#2959 review finding).
24372
+ ...(staleCachedAtMs != null
24373
+ ? { staleCachedAtMs }
24374
+ : probeResp.results.length > 0
24375
+ ? { liveProbedAtMs: renderNow.getTime() }
24376
+ : { probeFailed: true }),
23325
24377
  })
23326
24378
  // Preserve the Switch/Refresh/usage/Add inline keyboard on the
23327
24379
  // rich-message render β€” the table card carries the same actions the
@@ -23708,13 +24760,13 @@ bot.on('callback_query:data', async ctx => {
23708
24760
  }
23709
24761
  const didInterimSrEdit = false
23710
24762
  try {
23711
- const prevSessionModel = activeSessionModelOverride
24763
+ const prevSessionModel = sessionModelSource.getOverride()
23712
24764
  const outcome = await handleModelMenuCallback(data, modelDeps)
23713
24765
  // Record a successful session switch so /status reflects what's
23714
24766
  // actually running. In-memory only β†’ clears when the gateway (and thus
23715
24767
  // claude's session) restarts, exactly matching the session-only scope.
23716
24768
  if (outcome.selectedModel) {
23717
- activeSessionModelOverride = outcome.selectedModel
24769
+ sessionModelSource.setOverride(outcome.selectedModel)
23718
24770
  }
23719
24771
  // toastOnly: leave the menu untouched β€” but only if we haven't already
23720
24772
  // cleared its buttons with the interim sr-* edit. If we have, fall
@@ -23736,6 +24788,25 @@ bot.on('callback_query:data', async ctx => {
23736
24788
  { reply_markup: { inline_keyboard: [] } },
23737
24789
  )
23738
24790
  .catch(() => {})
24791
+ // Carry the requested Claude model across the restart via the SAME
24792
+ // `.session-model-override` carrier a Claude β†’ sr-* switch uses β€” otherwise
24793
+ // boot launches the CONFIGURED default and the tapped model is silently
24794
+ // dropped. `selectedModelToken` is a real `claude --model` token (alias or
24795
+ // full claude-* id); a "Default"-row tap yields no token β†’ boot the
24796
+ // configured default (correct). start.sh's LiteLLM-down guard only drops
24797
+ // sr-* overrides, so a Claude token is never dropped.
24798
+ {
24799
+ const agentDir = resolveAgentDirFromEnv()
24800
+ const token = outcome.selectedModelToken
24801
+ if (agentDir && token) {
24802
+ try {
24803
+ writeFileSync(join(agentDir, '.session-model-override'), `${token}\n`, 'utf8')
24804
+ sessionModelSource.setOverride(token)
24805
+ } catch (e) {
24806
+ process.stderr.write(`telegram gateway: sr-to-claude carrier write failed: ${(e as Error)?.message ?? String(e)}\n`)
24807
+ }
24808
+ }
24809
+ }
23739
24810
  // Write the restart marker so the post-restart boot card edits into this chat.
23740
24811
  writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
23741
24812
  stampUserRestartReason('user: sr-to-claude model switch (menu)')
@@ -24277,17 +25348,17 @@ bot.on('callback_query:data', async ctx => {
24277
25348
  process.stderr.write(
24278
25349
  `telegram gateway: button_callback chatId=${cbChatId} user=${ctx.from.id} data=${JSON.stringify(agentCb.raw)} btnText=${JSON.stringify(buttonText ?? null)}\n`,
24279
25350
  )
24280
- // Registered-keyed delivery + buffer-on-miss (same fix as the
24281
- // normal-inbound path above): broadcast()/clientCount() lost the
24282
- // tap whenever the bridge was mid-reconnect (clientCount() counts
24283
- // unregistered sockets, so the notice was suppressed AND nothing
24284
- // was actually queued). sendToAgent β†’ pendingInboundBuffer (drained
24285
- // by onClientRegistered) makes the "queued" promise real.
25351
+ // #271 turn-safety: route the tap through the SAME turn-safe delivery
25352
+ // machinery as a normal inbound (deliverButtonTapInbound) instead of a raw
25353
+ // sendToAgent. A tap landing mid-turn now buffers until idle (never strands
25354
+ // in the composer, #1556), a delivered tap is composer-cleared first and
25355
+ // tracked so the redelivery sweep rescues a strand, and a bridge-offline tap
25356
+ // still spools + shows the restart notice below (unchanged UX). The old raw
25357
+ // path (sendToAgent β†’ pendingInboundBuffer, drained by onClientRegistered)
25358
+ // fixed only the bridge-mid-reconnect drop; it never gated on turn state.
24286
25359
  const selfAgentBtn = process.env.SWITCHROOM_AGENT_NAME ?? ''
24287
- const btnDelivered = ipcServer.sendToAgent(selfAgentBtn, inboundMsg)
24288
- if (btnDelivered) markClaudeBusyForInbound(inboundMsg)
24289
- if (!btnDelivered) {
24290
- pendingInboundBuffer.push(selfAgentBtn, inboundMsg)
25360
+ const btnOutcome = await deliverButtonTapInbound(selfAgentBtn, inboundMsg)
25361
+ if (btnOutcome === 'buffered-bridge-offline') {
24291
25362
  // No registered bridge β€” the agent's mid-restart. Tell the user
24292
25363
  // so they don't think the button silently swallowed their tap;
24293
25364
  // the tap is genuinely buffered now and replays on reconnect.
@@ -24516,6 +25587,36 @@ bot.on('callback_query:data', async ctx => {
24516
25587
  process.stderr.write(
24517
25588
  `telegram gateway: always-allow hostd FAILED: ${failReason} (request_id=${request_id})\n`,
24518
25589
  )
25590
+ // #2973 pt.2 β€” enqueue for the durable retry queue UNLESS the
25591
+ // failure is non-retryable (config edits locked β€” retrying
25592
+ // won't help until the operator flips the flag; that case
25593
+ // keeps today's honest "did NOT save" card only). Everything
25594
+ // else (stale config view, transient hostd error, rate limit)
25595
+ // gets picked up by the boot/periodic drain instead of quietly
25596
+ // requiring the operator to notice and re-tap.
25597
+ if (!editLockHint) {
25598
+ try {
25599
+ await alwaysAllowPersistQueue.enqueue({
25600
+ agentName,
25601
+ rule: chosen.rule,
25602
+ grantPhrase,
25603
+ chatId: ctx.chat?.id != null ? String(ctx.chat.id) : undefined,
25604
+ threadId: (ctx.callbackQuery?.message as { message_thread_id?: number } | undefined)?.message_thread_id,
25605
+ error: failReason,
25606
+ })
25607
+ } catch (enqueueErr) {
25608
+ // The retry queue's own write failed (disk full, perms, …) β€”
25609
+ // don't pretend this landed. Fold it into the operator-facing
25610
+ // failReason so the "did NOT save" card is honest about the
25611
+ // retry mechanism ALSO having failed, not just the original
25612
+ // dispatch (#2973 adversarial review pt.2).
25613
+ const enqueueMsg = (enqueueErr as Error).message
25614
+ process.stderr.write(
25615
+ `telegram gateway: always-allow enqueue for retry FAILED: ${enqueueMsg} (request_id=${request_id})\n`,
25616
+ )
25617
+ failReason = `${failReason} (retry queue also failed to persist: ${enqueueMsg})`
25618
+ }
25619
+ }
24519
25620
  }
24520
25621
  }
24521
25622
 
@@ -26380,6 +27481,38 @@ void (async () => {
26380
27481
 
26381
27482
  if (!didOneTimeSetup) {
26382
27483
  didOneTimeSetup = true
27484
+
27485
+ // person_id name resolution (docs/configuration.md, resolve-person.ts):
27486
+ // ONE-TIME boot validation, NOT periodic. `PERSON_DIRECTORY` is
27487
+ // built exactly once, here, from the static in-memory `people.json`
27488
+ // the scaffold projected from `switchroom.yaml`'s `users:` block β€”
27489
+ // a config change requires an agent restart, this never re-reads.
27490
+ // The orchestration (per-entry validation + dead-man's-switch) is
27491
+ // in the pure, unit-tested `runPersonDirectoryBootCheck` β€” this
27492
+ // block only performs the resulting I/O (stderr log + the EXISTING
27493
+ // fleet-alert path at low/config severity, kind: 'config-warning',
27494
+ // which must never page like a real outage).
27495
+ {
27496
+ const bootCheck = runPersonDirectoryBootCheck(readPeopleFile)
27497
+ PERSON_DIRECTORY = bootCheck.directory
27498
+ process.stderr.write(bootCheck.logLine + '\n')
27499
+ if (bootCheck.alertDetail != null) {
27500
+ emitGatewayOperatorEvent({
27501
+ kind: 'config-warning',
27502
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? '-',
27503
+ detail: bootCheck.alertDetail,
27504
+ suggestedActions: [],
27505
+ firstSeenAt: new Date(),
27506
+ })
27507
+ }
27508
+ }
27509
+
27510
+ // #2973 pt.2 β€” drain any always-allow persists left queued by a
27511
+ // prior gateway process (e.g. one that restarted mid-persist),
27512
+ // then keep draining periodically for the rest of this process's
27513
+ // lifetime.
27514
+ scheduleAlwaysAllowPersistDrain()
27515
+
26383
27516
  void registerSwitchroomBotCommands().catch(() => {})
26384
27517
 
26385
27518
  // #613 fix: pre-warm the chatAvailableReactions cache for every
@@ -26493,6 +27626,18 @@ void (async () => {
26493
27626
  }
26494
27627
  }
26495
27628
 
27629
+ // Restore the four agent-initiated approval-card families from the
27630
+ // durable store so a post-restart tap on a still-valid card resolves
27631
+ // normally instead of hitting the "Card expired" tombstone (and an
27632
+ // already-expired entry gets woken by the reaper's next tick).
27633
+ try {
27634
+ restorePendingApprovalCards()
27635
+ } catch (err) {
27636
+ process.stderr.write(
27637
+ `telegram gateway: pending approval-card restore failed: ${(err as Error).message}\n`,
27638
+ )
27639
+ }
27640
+
26496
27641
  // Boot-time pin sweep
26497
27642
  try {
26498
27643
  const bootAccess = loadAccess()
@@ -26722,8 +27867,9 @@ void (async () => {
26722
27867
  // a phantom session override.
26723
27868
  return resolveMainModel(raw ?? undefined)
26724
27869
  })()
26725
- activeSessionModelOverride =
26726
- launched.length > 0 && launched !== configured ? launched : null
27870
+ sessionModelSource.setOverride(
27871
+ launched.length > 0 && launched !== configured ? launched : null,
27872
+ )
26727
27873
  } catch { /* leave override as-is on a bad read */ }
26728
27874
  }
26729
27875
 
@@ -27037,7 +28183,7 @@ void (async () => {
27037
28183
  // Gated to background completions: foreground sub-agents
27038
28184
  // need nothing here, and 'orphan' is a stale historical-at-
27039
28185
  // boot row, not a fresh completion the user is waiting on.
27040
- onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
28186
+ onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
27041
28187
  // Reaction promotion: if the parent turn already ended
27042
28188
  // with this (or another) worker still running, its πŸ‘ was
27043
28189
  // deferred (held on ✍️/⚑). Now that a worker finished,
@@ -27068,13 +28214,32 @@ void (async () => {
27068
28214
  // (worker-feed-dispatch.ts, pinned by its test). Best-effort:
27069
28215
  // a DB hiccup keeps the watcher's generic label rather than
27070
28216
  // throwing out of the terminal handler.
27071
- let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
28217
+ let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description, entryBackground)
27072
28218
  if (turnsDb != null) {
27073
28219
  try {
27074
- dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
28220
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description, entryBackground)
27075
28221
  } catch { /* best-effort */ }
27076
28222
  }
27077
- const isBackground = dispatch.isBackground
28223
+ let isBackground = dispatch.isBackground
28224
+ // Fix #1(+#2): the registry row never linked AND the watcher
28225
+ // entry's own cached background flag was never observed
28226
+ // either (both `resolveWorkerFeedDispatch` fallbacks came up
28227
+ // empty) β€” this is the "DB row is unlinked" bug's worst
28228
+ // case. A finished worker with actual narrative result text
28229
+ // is far more likely a dropped background handback than a
28230
+ // legitimate foreground no-op (a foreground sub-agent's
28231
+ // result returns inline as the Task tool result β€” the
28232
+ // gateway wouldn't otherwise need to route anything here).
28233
+ // Degrade to background so the result is delivered instead
28234
+ // of silently lost. Idempotency: this only flips the
28235
+ // dispatch classification for THIS single onFinish call β€”
28236
+ // all the existing dedup/idempotency guards below
28237
+ // (decideSubagentHandback's spool key, completionNotified,
28238
+ // etc.) still apply unchanged, so this cannot cause a
28239
+ // double-handback.
28240
+ if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
28241
+ isBackground = true
28242
+ }
27078
28243
  // NESTED (depth-2+) worker terminal: its live status surfaced
27079
28244
  // via the worker feed (see onProgress), so finalize that card
27080
28245
  // cleanly β€” never leave it frozen mid-"β†’ step". But NO user
@@ -27091,6 +28256,10 @@ void (async () => {
27091
28256
  latestSummary: resultText,
27092
28257
  elapsedMs: durationMs,
27093
28258
  state: outcome === 'failed' ? 'failed' : 'done',
28259
+ // Persisted (registry) model β€” the last one the watcher
28260
+ // recorded from the worker's transcript β€” so the terminal
28261
+ // card keeps the model tag even with no live entry.
28262
+ model: dispatch.feedModel ?? undefined,
27094
28263
  })
27095
28264
  reconcileWorkerPin(agentId, null, false)
27096
28265
  }
@@ -27168,6 +28337,7 @@ void (async () => {
27168
28337
  latestSummary: resultText,
27169
28338
  elapsedMs: durationMs,
27170
28339
  state: outcome === 'failed' ? 'failed' : 'done',
28340
+ model: dispatch.feedModel ?? undefined,
27171
28341
  })
27172
28342
  // Status-pin: worker done β€” drop its pin.
27173
28343
  reconcileWorkerPin(agentId, null, false)
@@ -27187,6 +28357,7 @@ void (async () => {
27187
28357
  latestSummary: resultText,
27188
28358
  elapsedMs: durationMs,
27189
28359
  state: outcome === 'failed' ? 'failed' : 'done',
28360
+ model: dispatch.feedModel ?? undefined,
27190
28361
  })
27191
28362
  // Status-pin: worker done β€” drop its pin.
27192
28363
  reconcileWorkerPin(agentId, null, false)
@@ -27273,7 +28444,7 @@ void (async () => {
27273
28444
  // suppresses stale-after-restart delivery (a 4-h-old
27274
28445
  // "still working (5m)" would be a lie). Sweep on handback
27275
28446
  // lives in the `onFinish` block just above.
27276
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
28447
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
27277
28448
  let fleetChatId = ''
27278
28449
  try {
27279
28450
  const fleets = progressDriver?.peekAllFleets() ?? []
@@ -27307,6 +28478,13 @@ void (async () => {
27307
28478
  // never grew past "starting…" (the frozen-card symptom). The
27308
28479
  // foreground nest path below already used this precedence.
27309
28480
  const stepLine = (progressLine != null && progressLine.length > 0) ? progressLine : latestSummary
28481
+ // Live model for the worker card: prefer the transcript-sourced
28482
+ // model on the entry (threaded via onProgress) and fall back to
28483
+ // the dispatch-time model persisted on the registry row
28484
+ // (tool_input.model) until the worker's first assistant line
28485
+ // lands. Undefined when neither is known β€” the card omits it,
28486
+ // never guessing from config.
28487
+ const feedModel = model ?? dispatch.feedModel ?? undefined
27310
28488
  if (!isBackground) {
27311
28489
  // Model A β€” a foreground sub-agent runs inside the parent's
27312
28490
  // turn, so its live narrative nests under the parent's
@@ -27334,8 +28512,8 @@ void (async () => {
27334
28512
  orphanStatusEnabled,
27335
28513
  })
27336
28514
  if (surface === 'worker-feed') {
27337
- const origin = resolveSubagentOriginChat(agentId)
27338
- const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
28515
+ const wk = resolveWorkerFeedChat(agentId, fleetChatId)
28516
+ const wkChat = wk.chatId
27339
28517
  void workerActivityFeed?.update(
27340
28518
  agentId,
27341
28519
  wkChat,
@@ -27346,8 +28524,9 @@ void (async () => {
27346
28524
  latestSummary: stepLine,
27347
28525
  elapsedMs,
27348
28526
  state: 'running',
28527
+ model: feedModel,
27349
28528
  },
27350
- origin?.threadId,
28529
+ wk.threadId,
27351
28530
  )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
27352
28531
  return
27353
28532
  }
@@ -27472,8 +28651,8 @@ void (async () => {
27472
28651
  // resolved (the pinned-card fleet that used to carry the chat
27473
28652
  // is gone β€” see resolveSubagentOriginChat).
27474
28653
  if (workerFeedEnabled) {
27475
- const origin = resolveSubagentOriginChat(agentId)
27476
- const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
28654
+ const wk = resolveWorkerFeedChat(agentId, fleetChatId)
28655
+ const wkChat = wk.chatId
27477
28656
  void workerActivityFeed?.update(
27478
28657
  agentId,
27479
28658
  wkChat,
@@ -27487,8 +28666,9 @@ void (async () => {
27487
28666
  latestSummary: stepLine,
27488
28667
  elapsedMs,
27489
28668
  state: 'running',
28669
+ model: feedModel,
27490
28670
  },
27491
- origin?.threadId,
28671
+ wk.threadId,
27492
28672
  )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
27493
28673
  return
27494
28674
  }