switchroom 0.18.6 β†’ 0.18.8

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 (116) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1172 -812
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +146 -50
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-runtime/SKILL.md +2 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  15. package/telegram-plugin/dist/gateway/gateway.js +2965 -862
  16. package/telegram-plugin/dist/server.js +24 -0
  17. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  18. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  19. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  20. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  21. package/telegram-plugin/gateway/boot-card.ts +27 -0
  22. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  23. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  24. package/telegram-plugin/gateway/gateway.ts +1618 -198
  25. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  26. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  27. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  28. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  29. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  30. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  31. package/telegram-plugin/gateway/model-command.ts +227 -54
  32. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  33. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  35. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  36. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  37. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  38. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  39. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  40. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  41. package/telegram-plugin/hooks/hooks.json +10 -10
  42. package/telegram-plugin/hooks/run-hook.sh +84 -0
  43. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  44. package/telegram-plugin/model-label.ts +69 -0
  45. package/telegram-plugin/model-unavailable.ts +26 -0
  46. package/telegram-plugin/operator-events.ts +24 -0
  47. package/telegram-plugin/permission-diff.ts +128 -0
  48. package/telegram-plugin/pty-partial-handler.ts +39 -0
  49. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  50. package/telegram-plugin/registry/subagents.test.ts +90 -0
  51. package/telegram-plugin/render/rich-render.ts +79 -1
  52. package/telegram-plugin/retry-api-call.ts +62 -0
  53. package/telegram-plugin/session-tail.ts +28 -0
  54. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  55. package/telegram-plugin/silence-poke.ts +14 -0
  56. package/telegram-plugin/silent-end.ts +49 -4
  57. package/telegram-plugin/stream-controller.ts +156 -38
  58. package/telegram-plugin/subagent-watcher.ts +222 -37
  59. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  60. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  61. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  62. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  63. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  64. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  65. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  66. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  67. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  68. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  69. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
  70. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  71. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  72. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  73. package/telegram-plugin/tests/model-command.test.ts +203 -43
  74. package/telegram-plugin/tests/model-label.test.ts +64 -0
  75. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  76. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  77. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  78. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  79. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  80. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  81. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  82. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  83. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  84. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  85. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  86. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  87. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  88. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  89. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  90. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  92. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  93. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  94. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  96. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  97. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  98. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  99. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  100. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  101. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  102. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  103. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  104. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  105. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  106. package/telegram-plugin/tool-activity-summary.ts +22 -2
  107. package/telegram-plugin/typing-wrap.ts +72 -25
  108. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  109. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  110. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  111. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  112. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  113. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  114. package/telegram-plugin/voice-ondemand.ts +25 -1
  115. package/telegram-plugin/voice-send.ts +154 -0
  116. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -41,6 +41,7 @@ import {
41
41
  resolveInterruptMaxWaitMs,
42
42
  resolveSafeBoundaryEnabled,
43
43
  } from './interrupt-defer.js'
44
+ import { shouldPostBusyAck, formatBusyAckText, BUSY_ACK_STEP_AGE_THRESHOLD_MS } from './busy-ack.js'
44
45
  import {
45
46
  resolveStickerSendArgs,
46
47
  resolveGifSendArgs,
@@ -62,6 +63,7 @@ import {
62
63
  buildListenKeyboard,
63
64
  mayInjectListenButton,
64
65
  } from '../voice-ondemand.js'
66
+ import { sendVoiceReusingFileId } from '../voice-send.js'
65
67
  import {
66
68
  PreSynthQueue,
67
69
  sweepVoiceCacheDir,
@@ -126,6 +128,14 @@ import {
126
128
  } from './permission-timeout.js'
127
129
  import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
128
130
  import { createPermissionCardStore, type PersistedPermCard } from './permission-card-store.js'
131
+ import { createPendingCardStore, type PersistedApprovalCard } from './pending-card-store.js'
132
+ import {
133
+ buildVaultAccessTimeoutInbound,
134
+ buildVaultSaveTimeoutInbound,
135
+ buildSecretRequestTimeoutInbound,
136
+ buildMentalModelProposeTimeoutInbound,
137
+ } from './approval-timeout-inbound-builders.js'
138
+ import { expirePendingCard, sweepExpiredEntries } from './pending-card-expiry.js'
129
139
  import {
130
140
  isPermissionRearmEnabled,
131
141
  permissionRearmGraceMs,
@@ -134,6 +144,11 @@ import {
134
144
  distinctRequestIds,
135
145
  } from './permission-rearm.js'
136
146
  import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
147
+ import {
148
+ createAlwaysAllowPersistQueue,
149
+ drainAlwaysAllowPersistQueue,
150
+ type AlwaysAllowDrainDeps,
151
+ } from './always-allow-persist-queue.js'
137
152
  import {
138
153
  renderMissedApprovalsDigest,
139
154
  missedApprovalsKeyboard,
@@ -143,6 +158,8 @@ import {
143
158
  import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
144
159
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
145
160
  import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
161
+ import { formatModelLabel } from '../model-label.js'
162
+ import { createSessionModelSource } from './session-model-source.js'
146
163
  import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
147
164
  import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
148
165
  import { toolLabel } from '../tool-labels.js'
@@ -160,6 +177,7 @@ import {
160
177
  isMessageTooLongError,
161
178
  } from '../retry-api-call.js'
162
179
  import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
180
+ import { floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
163
181
  import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
164
182
  import { logStreamingEvent } from '../streaming-metrics.js'
165
183
  import * as signalTracker from '../turn-signal-tracker.js'
@@ -360,10 +378,22 @@ import {
360
378
  MODEL_CALLBACK_PAGE_EXTERNAL,
361
379
  MODEL_CALLBACK_PAGE_MAIN,
362
380
  srFriendlyLabel,
381
+ expandSrAlias,
382
+ isSrModel,
363
383
  type ModelMenuDeps,
364
384
  type ModelCommandDeps,
365
385
  type ModelMenuReply,
366
386
  } from './model-command.js'
387
+ import {
388
+ writeSessionModelFile,
389
+ readSessionModelFileRaw,
390
+ restoreSessionModelFileRaw,
391
+ clearSessionModelFile,
392
+ readConfiguredDefaultModel,
393
+ writeRelaunchModelIntent,
394
+ clearRelaunchModelIntent,
395
+ intentForRestartReason,
396
+ } from './session-model-file.js'
367
397
  import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
368
398
  import { resolveMainModel } from '../../src/agents/scaffold.js'
369
399
  import {
@@ -396,7 +426,7 @@ import {
396
426
  _resetHostdEnabledCache,
397
427
  } from './hostd-dispatch.js'
398
428
  import { formatUpdateStatusLine } from './update-status-line.js'
399
- import type { HostdRequest } from '../../src/host-control/protocol.js'
429
+ import type { HostdRequest, HostdResponse } from '../../src/host-control/protocol.js'
400
430
  import type { AgentAudit } from '../welcome-text.js'
401
431
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
402
432
  import { startWebhookIngestServer } from './webhook-ingest-server.js'
@@ -432,6 +462,10 @@ import {
432
462
  restartOrphanCardFinalizeText,
433
463
  type ActivityCardStoreFsSeam,
434
464
  } from './activity-card-store.js'
465
+ import {
466
+ decideWorkerPinReaps,
467
+ WORKER_PIN_TTL_MS_DEFAULT,
468
+ } from './worker-pin-reaper.js'
435
469
  import { driveEscalation } from './escalation-drive.js'
436
470
  import { shouldSuppressRepresent } from './represent-guard.js'
437
471
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
@@ -530,6 +564,7 @@ import type {
530
564
  InjectInboundMessage,
531
565
  SendOutboundMessage,
532
566
  QuotaWallDetectedMessage,
567
+ QueryPendingPermissionMessage,
533
568
  PostSkillProposalMessage,
534
569
  PermissionEvent,
535
570
  RolloutStatusPostMessage,
@@ -561,6 +596,7 @@ import {
561
596
  clearCleanShutdownMarker,
562
597
  shouldSuppressRecoveryBanner,
563
598
  shouldSuppressBootResume,
599
+ parseBootResumeMode,
564
600
  resolveShutdownMarker,
565
601
  DEFAULT_MAX_AGE_MS as CLEAN_SHUTDOWN_MAX_AGE_MS,
566
602
  } from './clean-shutdown-marker.js'
@@ -677,9 +713,11 @@ import {
677
713
  import {
678
714
  buildResumeInterruptedInbound,
679
715
  buildResumeWatchdogReportInbound,
680
- selectResumeBuilder,
716
+ buildResumeDeferredReportInbound,
717
+ decideBootResumeKind,
681
718
  } from './resume-inbound-builder.js'
682
- import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
719
+ import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
720
+ import type { InterruptedSubagent } from './resume-inbound-builder.js'
683
721
  import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
684
722
  import {
685
723
  resolveSubagentStatusSurface,
@@ -717,12 +755,127 @@ process.on('beforeExit', () => {
717
755
  // ─── Env + state dir ──────────────────────────────────────────────────────
718
756
  const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
719
757
  const permCardStore = createPermissionCardStore(STATE_DIR)
758
+ // Durable store for the four AGENT-INITIATED approval-card families
759
+ // (vault_request_access / vault_request_save / request_secret /
760
+ // mental_model_propose). Persists card METADATA only β€” never any secret value
761
+ // (see pending-card-store.ts secrets-hygiene note). Restored at boot so a
762
+ // post-restart tap on a still-valid card works like a pre-restart tap, and
763
+ // swept in the pendingStateReaper so an unanswered card wakes the parked agent
764
+ // on TTL instead of leaving it parked forever.
765
+ const pendingCardStore = createPendingCardStore(STATE_DIR)
720
766
  // #2862 β€” missed-approvals re-offer. Persisted list of approvals that
721
767
  // TTL-expired while the operator was away; a digest card is posted on the
722
768
  // operator's next activity. Kill switch: SWITCHROOM_MISSED_APPROVAL_REOFFER=0.
723
769
  const missedApprovalsStore = createMissedApprovalsStore(STATE_DIR)
724
770
  const MISSED_APPROVAL_REOFFER_ENABLED =
725
771
  process.env.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'
772
+ // #2973 pt.2 β€” durable retry queue for "πŸ” Always allow" persists that
773
+ // fail for a retryable reason (stale config view, rate limit, transient
774
+ // hostd error). Drained at boot (below) and on a periodic timer so a
775
+ // gateway restart mid-persist never loses the retry.
776
+ const alwaysAllowPersistQueue = createAlwaysAllowPersistQueue(STATE_DIR)
777
+
778
+ /** Read a hostd `error_envelope`'s structured `retry_after` fix (if
779
+ * present) as a millisecond delay from now. Returns undefined for any
780
+ * other fix kind / missing envelope / unparsable timestamp β€” callers
781
+ * fall back to plain exponential backoff in that case. */
782
+ function extractRetryAfterMs(resp: HostdResponse): number | undefined {
783
+ const fix = resp.error_envelope?.fix
784
+ if (fix == null || fix.kind !== 'retry_after') return undefined
785
+ const at = Date.parse(fix.retry_at)
786
+ if (Number.isNaN(at)) return undefined
787
+ return Math.max(0, at - Date.now())
788
+ }
789
+
790
+ /**
791
+ * Build a FRESH dependency set for `drainAlwaysAllowPersistQueue` β€” a new
792
+ * factory call per drain pass, not a cached singleton, so every attempt
793
+ * re-reads config from disk (the #2973 stale-container-side-view failure
794
+ * class is fixed by never reusing a snapshot across retries).
795
+ */
796
+ function alwaysAllowDrainDeps(): AlwaysAllowDrainDeps {
797
+ return {
798
+ readConfigText: () => {
799
+ const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
800
+ return readFileSync(cfgPath, 'utf8')
801
+ },
802
+ resolveAllowList: (_configText, agentName) => {
803
+ const cfg = loadSwitchroomConfig()
804
+ const rawAgent = cfg.agents?.[agentName]
805
+ if (!rawAgent) return []
806
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
807
+ return (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
808
+ },
809
+ isRulePersisted,
810
+ synthesizeDiff: (agentName, rule, configText) =>
811
+ synthesizeAllowRuleDiff({ agentName, rule, configText }),
812
+ dispatchConfigEdit: async (entry, unifiedDiff) => {
813
+ const req: HostdRequest = {
814
+ v: 1,
815
+ op: 'config_propose_edit',
816
+ request_id: hostdRequestId('gw-always-allow-retry'),
817
+ args: {
818
+ unified_diff: unifiedDiff,
819
+ reason: `Operator 'always allow' retry: ${entry.agentName} can ${entry.grantPhrase}`,
820
+ target_path: '/state/config/switchroom.yaml',
821
+ },
822
+ }
823
+ const resp = await tryHostdDispatch(entry.agentName, req, 720_000)
824
+ if (resp === 'not-configured') {
825
+ return { ok: false as const, error: 'hostd not-configured (retry queue requires host_control.enabled)' }
826
+ }
827
+ if (resp.result === 'completed') return { ok: true as const }
828
+ return {
829
+ ok: false as const,
830
+ error: resp.error ?? `hostd ${resp.result}`,
831
+ retryAfterMs: extractRetryAfterMs(resp),
832
+ }
833
+ },
834
+ // #2973 pt.3 β€” loud terminal failure. A card edit doesn't ping the
835
+ // operator (the original permission card was already edited to the
836
+ // "saving durably in background…" interim state); this posts a NEW
837
+ // message instead, via the same operator-event broadcast every other
838
+ // fleet alert uses.
839
+ notifyTerminalFailure: (entry, reason) => {
840
+ emitGatewayOperatorEvent({
841
+ kind: 'always-allow-persist-failed',
842
+ agent: entry.agentName,
843
+ detail: `"${entry.grantPhrase}" (rule \`${entry.rule}\`) β€” ${reason}`,
844
+ suggestedActions: [],
845
+ firstSeenAt: new Date(),
846
+ })
847
+ },
848
+ log: (line) => process.stderr.write(line),
849
+ }
850
+ }
851
+
852
+ /** ~10 min between periodic drain passes β€” frequent enough that a
853
+ * retryable failure (rate limit, transient hostd hiccup) resolves within
854
+ * a reasonable window, infrequent enough to never look like polling. */
855
+ const ALWAYS_ALLOW_DRAIN_INTERVAL_MS = 10 * 60_000
856
+
857
+ /**
858
+ * Boot drain (picks up anything a prior gateway process queued right
859
+ * before a restart β€” success criterion: "restarting the gateway mid-
860
+ * persist does not lose the queued entry") + a periodic timer thereafter.
861
+ * Called once at gateway startup. Every pass is independently
862
+ * fault-tolerant β€” `drainAlwaysAllowPersistQueue` never throws.
863
+ */
864
+ function scheduleAlwaysAllowPersistDrain(): void {
865
+ void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
866
+ process.stderr.write(
867
+ `telegram gateway: always-allow-persist-queue boot drain failed: ${(err as Error).message}\n`,
868
+ )
869
+ })
870
+ const timer = setInterval(() => {
871
+ void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
872
+ process.stderr.write(
873
+ `telegram gateway: always-allow-persist-queue periodic drain failed: ${(err as Error).message}\n`,
874
+ )
875
+ })
876
+ }, ALWAYS_ALLOW_DRAIN_INTERVAL_MS)
877
+ timer.unref?.()
878
+ }
726
879
  const ACCESS_FILE = join(STATE_DIR, 'access.json')
727
880
  const APPROVED_DIR = join(STATE_DIR, 'approved')
728
881
  const ENV_FILE = join(STATE_DIR, '.env')
@@ -774,6 +927,16 @@ function triggerSelfRestart(
774
927
  )
775
928
  return false
776
929
  }
930
+ // Session-model stickiness (reference/rfcs/session-model-stickiness.md):
931
+ // boot default is REVERT, so every switchroom-managed bounce must stamp
932
+ // its intent BEFORE the SIGTERM is even scheduled (write-before-kill
933
+ // invariant β€” pinned by gateway-session-model-relaunch.test.ts). The
934
+ // per-reason table classifies recovery/model-switch bounces as "keep"
935
+ // and the deliberate inline restart button as "revert".
936
+ {
937
+ const smDir = resolveAgentDirFromEnv()
938
+ if (smDir) writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)
939
+ }
777
940
  process.stderr.write(
778
941
  `telegram gateway: restart-via-SIGTERM-PID1 agent=${targetAgent} reason=${reason} (docker)\n`,
779
942
  )
@@ -785,6 +948,10 @@ function triggerSelfRestart(
785
948
  return true
786
949
  }
787
950
  // Legacy systemd path.
951
+ if (targetAgent === selfAgent) {
952
+ const smDir = resolveAgentDirFromEnv()
953
+ if (smDir) writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)
954
+ }
788
955
  process.stderr.write(
789
956
  `telegram gateway: restart-via-systemctl agent=${targetAgent} reason=${reason}\n`,
790
957
  )
@@ -1383,70 +1550,123 @@ try {
1383
1550
  const pending = findLatestTurnIfInterrupted(turnsDb)
1384
1551
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
1385
1552
  if (pending != null && selfAgent) {
1386
- // Clean-shutdown gate: suppress auto-resume when the prior shutdown was
1387
- // operator/roll/CLI-initiated (clean). A clean-shutdown marker present and
1388
- // fresh means the agent was asked to stop; the "interrupted" turn was
1389
- // abandoned by that decision. Replaying it on every planned restart wastes
1390
- // subscription quota for no user benefit. Only unclean exits (crash/OOM/
1391
- // unexpected kill) should auto-resume.
1553
+ // Boot-resume policy (2026-07, superseding #2585). The block only runs
1554
+ // when `pending` exists β€” i.e. there IS genuinely in-flight work. The
1555
+ // product decision is that a DELIBERATE restart must not silently drop
1556
+ // that work: `session_continuity.boot_resume` (SWITCHROOM_BOOT_RESUME,
1557
+ // default 'in-flight') resumes it even after a clean shutdown. #2585's
1558
+ // quota-saving posture survives only as the opt-in 'never' mode β€” and
1559
+ // even then we deliver a passive REPORT, never silence, because the user
1560
+ // must always be told when in-flight work stopped.
1392
1561
  //
1393
1562
  // NOTE: GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH is defined lower in this file
1394
1563
  // (module-init order); we compute the path inline here using the same
1395
1564
  // formula so we can read it at boot-resume time.
1396
- // SWITCHROOM_BOOT_RESUME_ALWAYS=1 is an escape hatch that restores
1397
- // unconditional resume if needed.
1565
+ // SWITCHROOM_BOOT_RESUME_ALWAYS=1 remains a back-compat escape hatch that
1566
+ // forces unconditional resume regardless of mode.
1398
1567
  const bootResumeMarkerPath =
1399
1568
  process.env.SWITCHROOM_GATEWAY_CLEAN_SHUTDOWN_MARKER ?? join(STATE_DIR, 'clean-shutdown.json')
1400
1569
  const bootResumeCleanMarker = readCleanShutdownMarker(bootResumeMarkerPath)
1401
1570
  const bootResumeForceAlways = process.env.SWITCHROOM_BOOT_RESUME_ALWAYS === '1'
1571
+ const bootResumeMode = parseBootResumeMode(process.env.SWITCHROOM_BOOT_RESUME)
1402
1572
  const bootResumeSuppressed = shouldSuppressBootResume(bootResumeCleanMarker, Date.now(), {
1403
1573
  forceAlways: bootResumeForceAlways,
1574
+ mode: bootResumeMode,
1404
1575
  })
1405
- if (bootResumeSuppressed) {
1576
+
1577
+ // 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
1578
+ // interrupted work older than RESUME_MAX_AGE_MS β€” selectResumeBuilder
1579
+ // downgrades a stale 'resume' to the passive 'report'. Env override
1580
+ // SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
1581
+ const RESUME_MAX_AGE_MS = (() => {
1582
+ const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
1583
+ return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
1584
+ })()
1585
+
1586
+ // Decide the inbound kind (pure β€” see decideBootResumeKind). Precedence:
1587
+ // 1. loop-guard β†’ 'defer-loop' (never a second resume of a resume)
1588
+ // 2. suppressed β†’ 'defer-suppressed' (boot_resume: never; notice, not silence)
1589
+ // 3. otherwise β†’ selectResumeBuilder (resume | report | none)
1590
+ const bootResumeKind = decideBootResumeKind({
1591
+ pending,
1592
+ suppressed: bootResumeSuppressed,
1593
+ ageMs: Math.max(0, Date.now() - pending.started_at),
1594
+ maxAgeMs: RESUME_MAX_AGE_MS,
1595
+ })
1596
+
1597
+ // Sub-agents that were still in flight (running / stalled β€” non-terminal)
1598
+ // when the turn was killed. Read HERE, at module top, BEFORE the
1599
+ // subagent-watcher's boot scan + reaper run: the watcher never deletes
1600
+ // these rows (it only flips running→stalled and marks files historical
1601
+ // in-memory), and this accessor already includes 'stalled', so the data
1602
+ // survives either ordering β€” but reading pre-watcher keeps it simplest.
1603
+ // Threaded into ALL the builders below (resume, watchdog report, and the
1604
+ // deferred report) so the session gets an explicit killed-workers block β€”
1605
+ // it must not declare the task done on ghost workers, and even a
1606
+ // suppressed/loop-guarded resume must still NAME the deaths.
1607
+ let interruptedSubagents: InterruptedSubagent[] = []
1608
+ try {
1609
+ interruptedSubagents = listNonTerminalSubagentsForTurn(turnsDb, pending.turn_key).map(
1610
+ (s) => ({ agentType: s.agent_type, description: s.description, status: s.status }),
1611
+ )
1612
+ } catch (err) {
1406
1613
  process.stderr.write(
1407
- `telegram gateway: boot-resume suppressed (clean shutdown` +
1408
- `${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
1409
- `) β€” unclean exits still resume turnKey=${pending.turn_key}\n`,
1614
+ `telegram gateway: boot-resume subagent lookup failed (${(err as Error).message}) β€” continuing without worker list\n`,
1410
1615
  )
1411
- } else {
1412
- // 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
1413
- // interrupted work older than RESUME_MAX_AGE_MS β€” selectResumeBuilder
1414
- // downgrades a stale 'resume' to the passive 'report' so the user is told
1415
- // ("I was working on X ~Nh ago") but nothing replays unprompted. Env
1416
- // override SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
1417
- const RESUME_MAX_AGE_MS = (() => {
1418
- const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
1419
- return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
1420
- })()
1421
- const kind = selectResumeBuilder(pending.ended_via, {
1422
- ageMs: Math.max(0, Date.now() - pending.started_at),
1423
- maxAgeMs: RESUME_MAX_AGE_MS,
1424
- })
1425
- if (kind === 'resume') {
1426
- bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
1427
- } else if (kind === 'report') {
1428
- // idleMs: this boot's measured marker age if it just classified this
1429
- // turn; otherwise recover it from the persisted interrupt_reason (a
1430
- // later boot, marker already swept); else fall back to total runtime.
1431
- let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
1432
- if (idleMs == null && pending.interrupt_reason) {
1433
- try {
1434
- const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
1435
- if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
1436
- } catch { /* malformed snapshot β€” fall through */ }
1437
- }
1438
- if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
1439
- bootResumeInbound = {
1440
- agent: selfAgent,
1441
- msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs }),
1442
- }
1616
+ }
1617
+
1618
+ if (bootResumeKind === 'resume') {
1619
+ bootResumeInbound = {
1620
+ agent: selfAgent,
1621
+ msg: buildResumeInterruptedInbound({ turn: pending, subagents: interruptedSubagents }),
1443
1622
  }
1444
- if (bootResumeInbound != null) {
1445
- process.stderr.write(
1446
- `telegram gateway: boot-resume queued kind=${kind} turnKey=${pending.turn_key} ` +
1447
- `endedVia=${pending.ended_via ?? 'open'} chat=${pending.chat_id}\n`,
1448
- )
1623
+ } else if (bootResumeKind === 'report') {
1624
+ // idleMs: this boot's measured marker age if it just classified this
1625
+ // turn; otherwise recover it from the persisted interrupt_reason (a
1626
+ // later boot, marker already swept); else fall back to total runtime.
1627
+ let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
1628
+ if (idleMs == null && pending.interrupt_reason) {
1629
+ try {
1630
+ const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
1631
+ if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
1632
+ } catch { /* malformed snapshot β€” fall through */ }
1449
1633
  }
1634
+ if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
1635
+ bootResumeInbound = {
1636
+ agent: selfAgent,
1637
+ msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs, subagents: interruptedSubagents }),
1638
+ }
1639
+ } else if (bootResumeKind === 'defer-loop' || bootResumeKind === 'defer-suppressed') {
1640
+ // Passive deferred-report: work was in flight but we decline to
1641
+ // auto-resume (loop-guard, or boot_resume:never). Silence is never
1642
+ // acceptable here β€” tell the user what was in flight and ask.
1643
+ bootResumeInbound = {
1644
+ agent: selfAgent,
1645
+ msg: buildResumeDeferredReportInbound({
1646
+ turn: pending,
1647
+ reason: bootResumeKind === 'defer-loop' ? 'loop-guard' : 'clean-restart-suppressed',
1648
+ subagents: interruptedSubagents,
1649
+ }),
1650
+ }
1651
+ }
1652
+
1653
+ if (bootResumeKind === 'defer-suppressed') {
1654
+ process.stderr.write(
1655
+ `telegram gateway: boot-resume suppressed (clean shutdown` +
1656
+ `${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
1657
+ `, mode=${bootResumeMode}) β€” passive report delivered for turnKey=${pending.turn_key}\n`,
1658
+ )
1659
+ } else if (bootResumeKind === 'defer-loop') {
1660
+ process.stderr.write(
1661
+ `telegram gateway: boot-resume loop-guard tripped (interrupted turn was itself a resume) ` +
1662
+ `β€” passive report delivered instead of re-resuming turnKey=${pending.turn_key}\n`,
1663
+ )
1664
+ }
1665
+ if (bootResumeInbound != null) {
1666
+ process.stderr.write(
1667
+ `telegram gateway: boot-resume queued kind=${bootResumeKind} mode=${bootResumeMode} ` +
1668
+ `turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'} chat=${pending.chat_id}\n`,
1669
+ )
1450
1670
  }
1451
1671
  }
1452
1672
 
@@ -1687,7 +1907,22 @@ const activeReactionMsgIds = new Map<string, { chatId: string; messageId: number
1687
1907
  // by chatKey(chat_id, bufferedThread). Delete-on-answer: never a dangling
1688
1908
  // placeholder. Reaped on answer (executeReply/stream), on turn-flush, and
1689
1909
  // in purgeReactionTracking cleanup so an abnormal turn-end can't strand it.
1690
- const queuedStatusMsgIds = new Map<string, { chatId: string; threadId: number; messageId: number }>()
1910
+ // #2995: threadId is null for DM cards β€” the mid-flight busy-ack extends
1911
+ // this lifecycle to DMs and same-topic surfaces (the original component-5
1912
+ // card was cross-topic only).
1913
+ const queuedStatusMsgIds = new Map<string, { chatId: string; threadId: number | null; messageId: number }>()
1914
+ // #2995 mid-flight busy-ack dedupe: statusKeys that already got a busy-ack
1915
+ // card during the CURRENT turn β€” at most one card per turn per chat/topic
1916
+ // even after the card itself is reaped. The ending turn's key is deleted in
1917
+ // purgeReactionTracking (per-key, not a global clear β€” a purge for topic A
1918
+ // must not reset topic B's dedupe).
1919
+ const busyAckPostedKeys = new Set<string>()
1920
+ // #2995 deferred re-check: a ping that arrives while the blocking step is
1921
+ // still YOUNG (< threshold) must not be silently forgotten β€” the step may
1922
+ // run for minutes more (the original #2995 silence). One timer per key,
1923
+ // armed for (threshold βˆ’ stepAge); it re-evaluates live state when it
1924
+ // fires and is cancelled by purgeReactionTracking when the turn ends.
1925
+ const busyAckRecheckTimers = new Map<string, ReturnType<typeof setTimeout>>()
1691
1926
  // Reactions whose terminal πŸ‘ is deferred because a background sub-agent
1692
1927
  // worker was still running when the parent's `turn_end` fired. Painting πŸ‘
1693
1928
  // then would read as "done / nothing happening" while the worker keeps
@@ -2123,6 +2358,16 @@ const TOPIC_FRAMING_ENABLED =
2123
2358
  // β†’ no placeholder (the πŸ‘€ ack reaction still fires). Delete-on-answer.
2124
2359
  const QUEUED_STATUS_UX_ENABLED =
2125
2360
  process.env.SWITCHROOM_QUEUED_STATUS_UX !== '0'
2361
+ // #2995 mid-flight busy ack. When a mid-turn inbound is buffered (or lands
2362
+ // as a steer) while the running turn sits inside ONE long tool call (step
2363
+ // age past the threshold in busy-ack.ts), post a silent deterministic
2364
+ // "⏳ Queued β€” currently inside `<tool>` …" card into the inbound's own
2365
+ // chat/topic β€” model-free, zero tokens. Reuses the queuedStatusMsgIds
2366
+ // edit/delete lifecycle (delete-on-answer, reap on abnormal turn-end),
2367
+ // extending it to DMs and same-topic surfaces. At most one card per turn
2368
+ // per chat/topic. Kill switch off (=0) β†’ legacy silence (πŸ‘€ only).
2369
+ const MIDFLIGHT_BUSY_ACK_ENABLED =
2370
+ process.env.SWITCHROOM_MIDFLIGHT_BUSY_ACK !== '0'
2126
2371
  // Feed-reopen-after-ack. When a tool label arrives for a turn already
2127
2372
  // marked finalAnswerDelivered, the model is still WORKING β€” so the earlier
2128
2373
  // "final" reply was an interim ACK (an ack-first reply pings or runs β‰₯200
@@ -2273,6 +2518,110 @@ function deliverResumeSyntheticOrBuffer(agent: string, inbound: InboundMessage):
2273
2518
  return delivered
2274
2519
  }
2275
2520
 
2521
+ /** Outcome of routing an agent-authored button tap through the turn-safe
2522
+ * delivery machinery. `buffered-mid-turn` and `delivered` both mean "the tap
2523
+ * will be actioned" (the mid-turn case flushes on turn-complete); only
2524
+ * `buffered-bridge-offline` needs the user-facing "agent is restarting" notice. */
2525
+ type ButtonTapDeliveryOutcome = 'delivered' | 'buffered-mid-turn' | 'buffered-bridge-offline'
2526
+
2527
+ /**
2528
+ * Deliver an agent-authored inline-keyboard button tap (`agent:` callback_data)
2529
+ * through the SAME turn-safe machinery as a normal Telegram inbound, instead of
2530
+ * the old raw `sendToAgent` + buffer-only-on-bridge-miss.
2531
+ *
2532
+ * THE BUG (#271 button path, verified 2026-07): the `agent:` callback handler
2533
+ * delivered the synthesized tap inbound with a bare `ipcServer.sendToAgent`,
2534
+ * marked busy, and buffered ONLY when the bridge was offline. It never ran the
2535
+ * #1556 turn gate β€” so a tap landing WHILE a turn is in flight fired the MCP
2536
+ * channel notification mid-turn, typed into the CLI composer, and stranded there
2537
+ * (the lawgpt/marko wedge). It also skipped the pre-send composer clear and the
2538
+ * deliver-until-acked tracking, so a stranded tap was never sweep-redelivered.
2539
+ *
2540
+ * Fix: reuse the resume-synthetic turn gate (mid-turn β†’ `buffer-until-idle`, the
2541
+ * turn-complete hook + idle-drain flush it the instant claude goes idle), the
2542
+ * pre-send composer clear, and the delivery-confirm tracking β€” exactly like the
2543
+ * `handleInbound` fresh-turn path. A button tap carries no `meta.source` and a
2544
+ * non-empty body, so `shouldTrackDelivery` enrols it; we additionally require a
2545
+ * real `meta.message_id` so the `enqueue` ack has something to match (else the
2546
+ * never-drop sweep would storm). The tap UX is unchanged β€” the ack toast, the
2547
+ * single-use keyboard strip, and the bridge-offline spool + restart notice all
2548
+ * stay at the call site; only delivery timing/safety changes.
2549
+ */
2550
+ async function deliverButtonTapInbound(
2551
+ agent: string,
2552
+ inbound: InboundMessage,
2553
+ ): Promise<ButtonTapDeliveryOutcome> {
2554
+ // #1556 turn gate β€” same authoritative "is a turn in flight?" read the
2555
+ // resume-synthetic path uses. Mid-turn β†’ hold in the pending-inbound buffer;
2556
+ // the turn-complete hook + idle-drain timer flush it when claude goes idle,
2557
+ // where it lands cleanly as a fresh turn instead of stranding in the composer.
2558
+ const { decision, reserve } = reserveInboundDelivery({
2559
+ turnInFlight: turnInFlightForGate(),
2560
+ isSteering: false,
2561
+ isInterrupt: false,
2562
+ })
2563
+ if (decision === 'buffer-until-idle') {
2564
+ pendingInboundBuffer.push(agent, inbound)
2565
+ return 'buffered-mid-turn'
2566
+ }
2567
+ // #2917 per-chat FIFO: reserve the chat's busy key SYNCHRONOUSLY β€” before the
2568
+ // composer-clear await below β€” so a concurrent same-chat inbound reaching the
2569
+ // live gate observes this in-flight delivery and buffers behind it. Released
2570
+ // in lockstep below if the send misses (bridge offline).
2571
+ let reservedBusyKey: string | null = null
2572
+ if (reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED) {
2573
+ reservedBusyKey = markClaudeBusyForInbound(inbound)
2574
+ }
2575
+ // Pre-send composer clear (the marko wedge) β€” wipe stale typed-ahead / ghost
2576
+ // text so the channel notification lands at a clean line and auto-submits.
2577
+ // Soft-fail by contract: a clear failure must NEVER block delivery.
2578
+ if (agent) {
2579
+ try {
2580
+ const { clearAgentComposer } = await import('../../src/agents/tmux.js')
2581
+ const cleared = clearAgentComposer({ agentName: agent })
2582
+ if ('error' in cleared) {
2583
+ process.stderr.write(
2584
+ `telegram gateway: button-tap pre-send composer-clear soft-failed agent=${agent}: ${cleared.error} β€” delivering anyway\n`,
2585
+ )
2586
+ }
2587
+ } catch (err) {
2588
+ process.stderr.write(
2589
+ `telegram gateway: button-tap pre-send composer-clear threw agent=${agent}: ${(err as Error).message} β€” delivering anyway\n`,
2590
+ )
2591
+ }
2592
+ }
2593
+ const delivered = ipcServer.sendToAgent(agent, inbound)
2594
+ if (delivered) {
2595
+ const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inbound)
2596
+ // Track until claude acks via `enqueue` so the deliver-until-acked sweep
2597
+ // re-delivers a tap stranded in the composer. Only when we have a real
2598
+ // message_id to match the ack against β€” otherwise the never-drop loop storms.
2599
+ if (
2600
+ DELIVERY_CONFIRM_ENABLED &&
2601
+ inbound.meta?.message_id != null &&
2602
+ inbound.meta.message_id !== '' &&
2603
+ shouldTrackDelivery({
2604
+ isSteering: false,
2605
+ isInterrupt: false,
2606
+ hasSource: inbound.meta?.source != null,
2607
+ effectiveText: inbound.text,
2608
+ })
2609
+ ) {
2610
+ trackDelivery(deliveryQueue, busyKey, inbound, Date.now(), String(inbound.messageId))
2611
+ }
2612
+ return 'delivered'
2613
+ }
2614
+ // Bridge offline: release the synchronous reservation in lockstep (else the
2615
+ // orphaned busy key gates every later inbound into the buffer), then spool the
2616
+ // tap so it replays on reconnect β€” same behaviour as before this fix.
2617
+ if (reservedBusyKey != null) {
2618
+ claudeBusyKeys.delete(reservedBusyKey)
2619
+ claudeBusyKeySince.delete(reservedBusyKey)
2620
+ }
2621
+ pendingInboundBuffer.push(agent, inbound)
2622
+ return 'buffered-bridge-offline'
2623
+ }
2624
+
2276
2625
  const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
2277
2626
 
2278
2627
  // ─── Proactive context compaction (session.max_context_tokens) ──────────
@@ -2502,6 +2851,13 @@ type CurrentTurn = {
2502
2851
  // resume protocol uses this to decide "did the previous turn actually
2503
2852
  // finish a reply, or was it interrupted before commit?".
2504
2853
  lastAssistantDone: boolean
2854
+ // Live model in use for THIS turn, sourced from the main transcript's
2855
+ // `message.model` (the exact resolved model per API call) via the session-tail
2856
+ // `model` event β€” never from config or launch-time state. Updated on change;
2857
+ // undefined until the turn's first assistant line lands. Rendered onto the
2858
+ // activity/liveness card header's metrics line (e.g. "2m Β· 14 tools Β· opus 4.8")
2859
+ // and preferred by /status's buildAgentMetadata over the in-memory override.
2860
+ currentModel?: string
2505
2861
  // Phase 1 of #332: count of tool_use events in the current turn, for
2506
2862
  // the tool_call_count column in the turns registry.
2507
2863
  toolCallCount: number
@@ -2627,6 +2983,16 @@ type CurrentTurn = {
2627
2983
  // is never written and this is exactly the old singleton.
2628
2984
  let currentTurn: CurrentTurn | null = null
2629
2985
  const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
2986
+ // Freshness-aware /status session-model source. Two writers: the session-tail
2987
+ // `model` event (each assistant line's `message.model` β€” ground truth for the
2988
+ // last API call, survives between turns) and the #2982 /model override (set
2989
+ // the instant a switch is confirmed β€” the ONLY truthful source in the
2990
+ // idle-after-switch window, before the next assistant line lands). Every write
2991
+ // is seq-stamped and `resolve()` prefers the NEWER observation, so neither
2992
+ // source can go stale behind the other (session-model-source.ts, pinned by
2993
+ // tests/session-model-source.test.ts). buildAgentMetadata reads resolve();
2994
+ // the /model command paths write via setOverride.
2995
+ const sessionModelSource = createSessionModelSource()
2630
2996
  // Captures the most-recently-started turn's sessionChatId. Unlike currentTurn,
2631
2997
  // this is NOT cleared by the silence poke (firePoke/clearTurnStarted). It lets
2632
2998
  // the Bug B fallback in executeReply route to the correct chat even when the
@@ -3164,7 +3530,9 @@ function postQueuedStatus(chatId: string, bufferedThread: number, inFlightThread
3164
3530
  */
3165
3531
  function promoteQueuedStatus(chatId: string, thread: number | undefined): void {
3166
3532
  if (!QUEUED_STATUS_UX_ENABLED) return
3167
- if (thread == null) return
3533
+ // #2995: thread == null is a DM (or General-topic) card β€” the busy-ack
3534
+ // extended this lifecycle to DMs, so promote those too (statusKey keys
3535
+ // a null thread identically at post and promote time).
3168
3536
  const key = statusKey(chatId, thread)
3169
3537
  const entry = queuedStatusMsgIds.get(key)
3170
3538
  if (entry == null) return
@@ -3175,7 +3543,7 @@ function promoteQueuedStatus(chatId: string, thread: number | undefined): void {
3175
3543
  void swallowingApiCall(
3176
3544
  () =>
3177
3545
  bot.api.editMessageText(chatId, entry.messageId, '✍️ On it β€” replying now.', {}),
3178
- { chat_id: chatId, verb: 'queued-status.promote', threadId: thread },
3546
+ { chat_id: chatId, verb: 'queued-status.promote', ...(thread != null ? { threadId: thread } : {}) },
3179
3547
  )
3180
3548
  }
3181
3549
 
@@ -3195,6 +3563,115 @@ function reapQueuedStatus(chatId: string, thread: number | undefined): void {
3195
3563
  )
3196
3564
  }
3197
3565
 
3566
+ /**
3567
+ * #2995 β€” mid-flight busy ack (decision + dispatch). Called when a mid-turn
3568
+ * inbound was buffered (`buffer-until-idle`) or delivered as a steer. If the
3569
+ * running turn is sitting inside one LONG tool step (see busy-ack.ts for the
3570
+ * threshold rationale), post a silent deterministic card into the inbound's
3571
+ * own chat/topic naming the blocking activity β€” so a quick question asked
3572
+ * behind a `--watch`-style blocking call doesn't read as ignored for
3573
+ * minutes. Model-free, zero tokens. The card is stored in
3574
+ * `queuedStatusMsgIds`, so it inherits the component-5 lifecycle: promoted
3575
+ * ("On it") when the buffered turn starts, deleted when the answer lands or
3576
+ * the turn ends. Dedupe is per turn per chat/topic via `busyAckPostedKeys`.
3577
+ */
3578
+ function maybePostBusyAck(
3579
+ gateDecision: 'buffer-until-idle' | 'steer',
3580
+ chatId: string,
3581
+ threadId: number | undefined,
3582
+ ): void {
3583
+ if (!MIDFLIGHT_BUSY_ACK_ENABLED) return
3584
+ const key = statusKey(chatId, threadId)
3585
+ const inFlight = currentTurn
3586
+ const inFlightKey =
3587
+ inFlight != null ? statusKey(inFlight.sessionChatId, inFlight.sessionThreadId) : key
3588
+ const now = Date.now()
3589
+ const step = silencePoke.longestInFlightTool(inFlightKey, now)
3590
+ const midToolCall = toolFlightTracker.isMidToolCall()
3591
+ const stepAgeMs = step?.durationMs ?? null
3592
+ const alreadyAcked = queuedStatusMsgIds.has(key) || busyAckPostedKeys.has(key)
3593
+ const fire = shouldPostBusyAck({ gateDecision, midToolCall, stepAgeMs, alreadyAcked })
3594
+ if (!fire) {
3595
+ // Deferred re-check: the ONLY miss reason we re-arm for is a step that
3596
+ // is genuinely open but still young β€” it may run for minutes more, and
3597
+ // a one-shot evaluation would re-create the original #2995 silence. Arm
3598
+ // one timer for (threshold βˆ’ stepAge); everything is re-read live at
3599
+ // fire time, and the timer is pinned to THIS turn (turnId guard) plus
3600
+ // cancelled outright by purgeReactionTracking when the turn ends.
3601
+ if (
3602
+ midToolCall &&
3603
+ !alreadyAcked &&
3604
+ stepAgeMs != null &&
3605
+ stepAgeMs < BUSY_ACK_STEP_AGE_THRESHOLD_MS &&
3606
+ !busyAckRecheckTimers.has(key)
3607
+ ) {
3608
+ const turnIdAtSchedule = inFlight?.turnId ?? null
3609
+ const delayMs = BUSY_ACK_STEP_AGE_THRESHOLD_MS - stepAgeMs + 250
3610
+ busyAckRecheckTimers.set(key, setTimeout(() => {
3611
+ busyAckRecheckTimers.delete(key)
3612
+ // The ping is only still waiting if the SAME turn is still running
3613
+ // (a new/ended turn means the buffered inbound is being handled β€”
3614
+ // a "Queued" card then would be a lie).
3615
+ if (turnIdAtSchedule == null || currentTurn?.turnId !== turnIdAtSchedule) return
3616
+ maybePostBusyAck(gateDecision, chatId, threadId)
3617
+ }, delayMs))
3618
+ }
3619
+ return
3620
+ }
3621
+ const pendingRecheck = busyAckRecheckTimers.get(key)
3622
+ if (pendingRecheck != null) {
3623
+ clearTimeout(pendingRecheck)
3624
+ busyAckRecheckTimers.delete(key)
3625
+ }
3626
+ busyAckPostedKeys.add(key)
3627
+ const text = formatBusyAckText({
3628
+ gateDecision,
3629
+ toolName: step?.name ?? null,
3630
+ toolLabel: step?.label ?? null,
3631
+ })
3632
+ process.stderr.write(
3633
+ `telegram gateway: mid-flight busy ack chat=${chatId} thread=${threadId ?? '-'} ` +
3634
+ `decision=${gateDecision} step=${step?.name ?? '-'} step_age_ms=${step?.durationMs ?? '-'}\n`,
3635
+ )
3636
+ postBusyAck(chatId, threadId, text)
3637
+ }
3638
+
3639
+ /**
3640
+ * #2995 β€” send the busy-ack card. Mirrors `postQueuedStatus` (idempotent
3641
+ * key, silent send, post-race orphan cleanup) but is thread-OPTIONAL so it
3642
+ * covers DMs and same-topic queues β€” the surfaces the cross-topic queued
3643
+ * status deliberately suppressed. Shares `queuedStatusMsgIds`, so
3644
+ * promote/reap clean it up exactly like the queued-status placeholder.
3645
+ */
3646
+ function postBusyAck(chatId: string, threadId: number | undefined, text: string): void {
3647
+ const key = statusKey(chatId, threadId)
3648
+ if (queuedStatusMsgIds.has(key)) return
3649
+ void (async () => {
3650
+ const sent = await swallowingApiCall(
3651
+ () =>
3652
+ // Deterministic busy-ack placeholder, not the user's answer β€”
3653
+ // silent by contract (no device ping for a status card).
3654
+ bot.api.sendMessage(chatId, text, {
3655
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
3656
+ disable_notification: true,
3657
+ }),
3658
+ { chat_id: chatId, verb: 'busy-ack.post', ...(threadId != null ? { threadId } : {}) },
3659
+ )
3660
+ const messageId = (sent as { message_id?: number } | undefined)?.message_id
3661
+ if (typeof messageId !== 'number') return
3662
+ // Re-check after the await (same race pattern as postQueuedStatus):
3663
+ // if a placeholder landed for this key in the gap, delete this orphan.
3664
+ if (queuedStatusMsgIds.has(key)) {
3665
+ void swallowingApiCall(
3666
+ () => bot.api.deleteMessage(chatId, messageId),
3667
+ { chat_id: chatId, verb: 'busy-ack.post-race-cleanup', ...(threadId != null ? { threadId } : {}) },
3668
+ )
3669
+ return
3670
+ }
3671
+ queuedStatusMsgIds.set(key, { chatId, threadId: threadId ?? null, messageId })
3672
+ })()
3673
+ }
3674
+
3198
3675
  // Problem B β€” deferred safe-boundary interrupt.
3199
3676
  //
3200
3677
  // `toolFlightTracker` mirrors the session-event stream to know whether a
@@ -3528,6 +4005,17 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
3528
4005
  const pqThread = pqThreadPart === '_' || pqThreadPart === '' ? null : Number(pqThreadPart)
3529
4006
  reapQueuedStatus(pqChatId, Number.isFinite(pqThread) ? (pqThread as number) : undefined)
3530
4007
  }
4008
+ // #2995 β€” reset the ending turn's OWN busy-ack dedupe + cancel its pending
4009
+ // deferred re-check. Per-key, not a global clear: a purge for topic A must
4010
+ // not reset topic B's dedupe or kill B's armed re-check. A buffered topic's
4011
+ // entry clears when ITS turn eventually runs and ends (this same path), and
4012
+ // the re-check timer self-guards on turnId anyway (defense-in-depth).
4013
+ busyAckPostedKeys.delete(key)
4014
+ const busyAckRecheck = busyAckRecheckTimers.get(key)
4015
+ if (busyAckRecheck != null) {
4016
+ clearTimeout(busyAckRecheck)
4017
+ busyAckRecheckTimers.delete(key)
4018
+ }
3531
4019
  // PR3b: clear the parallel-turns fleet-gate entry. Symmetric with
3532
4020
  // the markClaudeBusyForInbound on the delivery path. Safe no-op
3533
4021
  // when the key was never marked (synthetic purge from a sweep).
@@ -4579,8 +5067,16 @@ const typingWrapper = createTypingWrapper({
4579
5067
  // ─── Robust API call wrapper ──────────────────────────────────────────────
4580
5068
  // Extracted to telegram-plugin/retry-api-call.ts so it's unit-testable in
4581
5069
  // isolation; the gateway just composes the pure policy with its own logger.
5070
+ // #2923: the shared flood-wait marker. Every observed 429 retry_after window
5071
+ // is persisted here via onFloodWait, and both boot-card callsites consult the
5072
+ // SAME file to suppress a restart card while a per-bot flood ban is open (so a
5073
+ // restart doesn't post into the window and extend the ban). Falls back to a
5074
+ // no-op recorder when TELEGRAM_STATE_DIR is unset (dev/one-shot contexts).
5075
+ // STATE_DIR always resolves (env or a ~/.claude fallback), so this is live.
5076
+ const FLOOD_STATE_PATH = floodStatePath(STATE_DIR)
4582
5077
  const robustApiCall = createRetryApiCall({
4583
5078
  log: (line) => process.stderr.write(line),
5079
+ onFloodWait: makeFloodWaitRecorder(FLOOD_STATE_PATH),
4584
5080
  })
4585
5081
 
4586
5082
  // Fire-and-forget wrapper for outbound surfaces that previously had
@@ -5236,17 +5732,26 @@ interface PendingVaultRequestSave {
5236
5732
  why?: string
5237
5733
  /** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
5238
5734
  staged_at: number
5735
+ /** Set on entries RESTORED from disk after a gateway restart. The staged
5736
+ * secret `value` is held in memory only (never persisted β€” secrets
5737
+ * hygiene), so a restored entry has an empty value and cannot complete the
5738
+ * write. A Save tap on such a card degrades gracefully: it tells the agent
5739
+ * the value was lost to a restart instead of writing an empty secret. */
5740
+ restoredWithoutValue?: boolean
5239
5741
  }
5240
5742
  const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
5241
5743
  // Gateway-side reap window for a staged vault-save card. Tracks the operator
5242
5744
  // approval-card lifetime (config-driven, 60-min default) so the reap never
5243
5745
  // races ahead of the card the operator is still looking at.
5244
5746
  const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
5245
- function sweepPendingVaultRequestSaves(): void {
5246
- const cutoff = Date.now() - VAULT_REQUEST_SAVE_TTL_MS
5247
- for (const [k, v] of pendingVaultRequestSaves) {
5248
- if (v.staged_at < cutoff) pendingVaultRequestSaves.delete(k)
5249
- }
5747
+ function sweepPendingVaultRequestSaves(now = Date.now()): void {
5748
+ sweepExpiredEntries(
5749
+ pendingVaultRequestSaves,
5750
+ (v, n) => v.staged_at < n - VAULT_REQUEST_SAVE_TTL_MS,
5751
+ expireVaultSaveCard,
5752
+ now,
5753
+ cardExpiryLog,
5754
+ )
5250
5755
  }
5251
5756
 
5252
5757
  /**
@@ -5289,11 +5794,14 @@ const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
5289
5794
  // Gateway-side reap window for a staged vault-access card. Tracks the operator
5290
5795
  // approval-card lifetime (config-driven, 60-min default) β€” see approvalTtlMs.
5291
5796
  const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
5292
- function sweepPendingVaultRequestAccesses(): void {
5293
- const cutoff = Date.now() - VAULT_REQUEST_ACCESS_TTL_MS
5294
- for (const [k, v] of pendingVaultRequestAccesses) {
5295
- if (v.staged_at < cutoff) pendingVaultRequestAccesses.delete(k)
5296
- }
5797
+ function sweepPendingVaultRequestAccesses(now = Date.now()): void {
5798
+ sweepExpiredEntries(
5799
+ pendingVaultRequestAccesses,
5800
+ (v, n) => v.staged_at < n - VAULT_REQUEST_ACCESS_TTL_MS,
5801
+ expireVaultAccessCard,
5802
+ now,
5803
+ cardExpiryLog,
5804
+ )
5297
5805
  }
5298
5806
 
5299
5807
  /**
@@ -5325,23 +5833,14 @@ const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
5325
5833
  // posted card's keyboard away, so a stale card left in the chat can't be tapped
5326
5834
  // into a "Card expired" answer β€” the operator sees the βŒ› expiry inline instead.
5327
5835
  // Best-effort: card edits are fire-and-forget (the entry is removed regardless).
5328
- function sweepPendingMentalModelProposes(): void {
5329
- const cutoff = Date.now() - MENTAL_MODEL_PROPOSE_TTL_MS
5330
- for (const [k, v] of pendingMentalModelProposes) {
5331
- if (v.staged_at < cutoff) {
5332
- pendingMentalModelProposes.delete(k)
5333
- if (v.card_message_id != null) {
5334
- void lockedBot.api
5335
- .editMessageText(
5336
- v.chat_id,
5337
- v.card_message_id,
5338
- richMessage('βŒ› _This mental-model proposal card expired. Ask the agent to re-propose if it still stands._'),
5339
- { reply_markup: { inline_keyboard: [] } },
5340
- )
5341
- .catch(() => {})
5342
- }
5343
- }
5344
- }
5836
+ function sweepPendingMentalModelProposes(now = Date.now()): void {
5837
+ sweepExpiredEntries(
5838
+ pendingMentalModelProposes,
5839
+ (v, n) => v.staged_at < n - MENTAL_MODEL_PROPOSE_TTL_MS,
5840
+ expireMentalModelProposeCard,
5841
+ now,
5842
+ cardExpiryLog,
5843
+ )
5345
5844
  }
5346
5845
 
5347
5846
  // Sliding-window rate limit for mental-model proposals: at most
@@ -5578,6 +6077,296 @@ function isAutoFallbackCooldownActive(_agentName: string, now: number): boolean
5578
6077
  }
5579
6078
  }
5580
6079
 
6080
+ // ── Agent-initiated approval-card TTL expiry β†’ wake the parked agent ────────
6081
+ //
6082
+ // The four agent-initiated approval-card families (vault_request_access /
6083
+ // vault_request_save / request_secret / mental_model_propose) each park the
6084
+ // requesting agent (it ends its turn to wait for the operator's tap). Before
6085
+ // this, an unanswered card that TTL-expired left the agent parked FOREVER: the
6086
+ // lazy sweep just deleted the in-memory entry and nothing woke the agent. Each
6087
+ // expire* helper mirrors the permission-card timeout path (#2411 / #2862) by
6088
+ // routing through the pure `expirePendingCard` core (pending-card-expiry.ts),
6089
+ // whose ordering + fault-isolation contract is behaviorally pinned by
6090
+ // pending-card-expiry.test.ts:
6091
+ // 1. drop the in-memory entry AND its durable store record FIRST (single-
6092
+ // shot β€” a second tick can never double-fire the wake),
6093
+ // 2. edit the card to a βŒ› expired state + strip its keyboard (best-effort),
6094
+ // 3. record it in missedApprovalsStore BEFORE delivering, so a throwing
6095
+ // deliver can't lose the re-offer for the operator's return,
6096
+ // 4. inject a TIMEOUT-outcome synthetic inbound (turn-gated via
6097
+ // deliverResumeSyntheticOrBuffer, guarded β€” a half-dead IPC socket that
6098
+ // throws on write is contained, never escaping the reaper's setInterval
6099
+ // callback into an uncaughtException gateway shutdown).
6100
+ // Called from BOTH the lazy sweeps (on next stage) and the pendingStateReaper
6101
+ // (every 60s β€” the authoritative timer so an idle gateway still wakes agents).
6102
+
6103
+ async function editCardExpired(chatId: string, messageId: number | undefined, body: string): Promise<void> {
6104
+ if (messageId == null) return
6105
+ await lockedBot.api
6106
+ // 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.
6107
+ .editMessageText(chatId, messageId, richMessage(body), { reply_markup: { inline_keyboard: [] } })
6108
+ .catch(() => {})
6109
+ }
6110
+
6111
+ function recordMissedApproval(opts: {
6112
+ stageId: string
6113
+ toolName: string
6114
+ action: string
6115
+ chatId: string
6116
+ threadId?: number
6117
+ now: number
6118
+ }): void {
6119
+ if (!MISSED_APPROVAL_REOFFER_ENABLED) return
6120
+ missedApprovalsStore.add({
6121
+ requestId: opts.stageId,
6122
+ toolName: opts.toolName,
6123
+ action: opts.action,
6124
+ chatId: opts.chatId,
6125
+ threadId: opts.threadId ?? null,
6126
+ timedOutAt: opts.now,
6127
+ })
6128
+ }
6129
+
6130
+ const cardExpiryLog = (msg: string): void => {
6131
+ process.stderr.write(`telegram gateway: ${msg}\n`)
6132
+ }
6133
+
6134
+ function expireVaultAccessCard(stageId: string, v: PendingVaultRequestAccess, now: number): void {
6135
+ const timeoutMinutes = Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)
6136
+ const { delivered } = expirePendingCard({
6137
+ remove: () => {
6138
+ pendingVaultRequestAccesses.delete(stageId)
6139
+ pendingCardStore.remove(stageId)
6140
+ },
6141
+ editCard: () => void editCardExpired(
6142
+ v.chat_id,
6143
+ v.card_message_id,
6144
+ `βŒ› _This vault access request for \`${escapeHtmlForTg(v.key)}\` timed out before you tapped. Ask **${escapeHtmlForTg(v.agent)}** to re-request if it still stands._`,
6145
+ ),
6146
+ buildInbound: () => buildVaultAccessTimeoutInbound({
6147
+ agent: v.agent,
6148
+ chatId: v.chat_id,
6149
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6150
+ stageId,
6151
+ timeoutMinutes,
6152
+ key: v.key,
6153
+ scope: v.scope,
6154
+ }),
6155
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
6156
+ recordMiss: () => recordMissedApproval({
6157
+ stageId,
6158
+ toolName: 'vault_request_access',
6159
+ action: `grant ${v.agent} ${v.scope} access to \`${v.key}\``,
6160
+ chatId: v.chat_id,
6161
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6162
+ now,
6163
+ }),
6164
+ log: cardExpiryLog,
6165
+ })
6166
+ process.stderr.write(
6167
+ `telegram gateway: vault_request_access TTL expired β€” wake agent=${v.agent} ` +
6168
+ `key=${v.key} stage=${stageId} delivered=${delivered}\n`,
6169
+ )
6170
+ }
6171
+
6172
+ function expireVaultSaveCard(stageId: string, v: PendingVaultRequestSave, now: number): void {
6173
+ const timeoutMinutes = Math.round(VAULT_REQUEST_SAVE_TTL_MS / 60000)
6174
+ const { delivered } = expirePendingCard({
6175
+ remove: () => {
6176
+ pendingVaultRequestSaves.delete(stageId)
6177
+ pendingCardStore.remove(stageId)
6178
+ },
6179
+ editCard: () => void editCardExpired(
6180
+ v.chat_id,
6181
+ v.card_message_id,
6182
+ `βŒ› _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._`,
6183
+ ),
6184
+ buildInbound: () => buildVaultSaveTimeoutInbound({
6185
+ agent: v.agent,
6186
+ chatId: v.chat_id,
6187
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6188
+ stageId,
6189
+ timeoutMinutes,
6190
+ key: v.key,
6191
+ }),
6192
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
6193
+ recordMiss: () => recordMissedApproval({
6194
+ stageId,
6195
+ toolName: 'vault_request_save',
6196
+ action: `save the secret \`${v.key}\` for ${v.agent}`,
6197
+ chatId: v.chat_id,
6198
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6199
+ now,
6200
+ }),
6201
+ log: cardExpiryLog,
6202
+ })
6203
+ process.stderr.write(
6204
+ `telegram gateway: vault_request_save TTL expired β€” wake agent=${v.agent} ` +
6205
+ `key=${v.key} stage=${stageId} delivered=${delivered}\n`,
6206
+ )
6207
+ }
6208
+
6209
+ function expireSecretRequestCard(stageId: string, v: PendingSecretRequest, now: number): void {
6210
+ const timeoutMinutes = Math.round(PENDING_SECRET_REQUEST_TTL_MS / 60000)
6211
+ const { delivered } = expirePendingCard({
6212
+ remove: () => {
6213
+ pendingSecretRequests.delete(stageId)
6214
+ pendingCardStore.remove(stageId)
6215
+ },
6216
+ editCard: () => void editCardExpired(
6217
+ v.chat_id,
6218
+ v.card_message_id,
6219
+ `βŒ› _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._`,
6220
+ ),
6221
+ buildInbound: () => buildSecretRequestTimeoutInbound({
6222
+ agent: v.agent,
6223
+ chatId: v.chat_id,
6224
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6225
+ stageId,
6226
+ timeoutMinutes,
6227
+ key: v.key,
6228
+ }),
6229
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
6230
+ recordMiss: () => recordMissedApproval({
6231
+ stageId,
6232
+ toolName: 'request_secret',
6233
+ action: `provide the secret \`${v.key}\` for ${v.agent}`,
6234
+ chatId: v.chat_id,
6235
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6236
+ now,
6237
+ }),
6238
+ log: cardExpiryLog,
6239
+ })
6240
+ process.stderr.write(
6241
+ `telegram gateway: request_secret TTL expired β€” wake agent=${v.agent} ` +
6242
+ `key=${v.key} stage=${stageId} delivered=${delivered}\n`,
6243
+ )
6244
+ }
6245
+
6246
+ function expireMentalModelProposeCard(stageId: string, v: PendingMentalModelPropose, now: number): void {
6247
+ const timeoutMinutes = Math.round(MENTAL_MODEL_PROPOSE_TTL_MS / 60000)
6248
+ const { delivered } = expirePendingCard({
6249
+ remove: () => {
6250
+ pendingMentalModelProposes.delete(stageId)
6251
+ pendingCardStore.remove(stageId)
6252
+ },
6253
+ editCard: () => void editCardExpired(
6254
+ v.chat_id,
6255
+ v.card_message_id,
6256
+ `βŒ› _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._`,
6257
+ ),
6258
+ buildInbound: () => buildMentalModelProposeTimeoutInbound({
6259
+ agent: v.agent,
6260
+ chatId: v.chat_id,
6261
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6262
+ stageId,
6263
+ timeoutMinutes,
6264
+ name: v.spec.name,
6265
+ }),
6266
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
6267
+ recordMiss: () => recordMissedApproval({
6268
+ stageId,
6269
+ toolName: 'mental_model_propose',
6270
+ action: `declare the mental model \`${v.spec.name}\` for ${v.agent}`,
6271
+ chatId: v.chat_id,
6272
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6273
+ now,
6274
+ }),
6275
+ log: cardExpiryLog,
6276
+ })
6277
+ process.stderr.write(
6278
+ `telegram gateway: mental_model_propose TTL expired β€” wake agent=${v.agent} ` +
6279
+ `name=${v.spec.name} stage=${stageId} delivered=${delivered}\n`,
6280
+ )
6281
+ }
6282
+
6283
+ // Run all four agent-initiated approval-card expiry sweeps. Called from the
6284
+ // pendingStateReaper (the authoritative 60s timer). Each family sweep is
6285
+ // per-entry guarded via sweepExpiredEntries, so one throwing expiry (dead IPC
6286
+ // socket, store IO error) can't skip the remaining entries or families.
6287
+ function sweepExpiredApprovalCards(now: number): void {
6288
+ sweepPendingVaultRequestAccesses(now)
6289
+ sweepPendingVaultRequestSaves(now)
6290
+ sweepPendingMentalModelProposes(now)
6291
+ sweepSecretRequests(now)
6292
+ }
6293
+
6294
+ // Boot restore: repopulate the four in-memory approval-card maps from the
6295
+ // durable store so a post-restart tap on a still-valid card resolves normally
6296
+ // (approve β†’ grant + synthetic; deny β†’ denial synthetic) instead of hitting
6297
+ // the "Card expired" tombstone. Entries already past their TTL are left for
6298
+ // the reaper's next tick, which wakes the parked agent via the timeout path.
6299
+ // vault_request_save entries restore WITHOUT their staged value (never
6300
+ // persisted) and are flagged `restoredWithoutValue` so a Save tap degrades
6301
+ // gracefully rather than writing an empty secret.
6302
+ function restorePendingApprovalCards(): number {
6303
+ let restored = 0
6304
+ for (const e of pendingCardStore.loadAll()) {
6305
+ try {
6306
+ if (e.family === 'vault_request_access') {
6307
+ pendingVaultRequestAccesses.set(e.stageId, {
6308
+ agent: e.agent,
6309
+ chat_id: e.chatId,
6310
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6311
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6312
+ key: e.key,
6313
+ scope: e.scope,
6314
+ ...(e.reason != null ? { reason: e.reason } : {}),
6315
+ ttl_seconds: e.ttlSeconds,
6316
+ staged_at: e.stagedAt,
6317
+ })
6318
+ restored++
6319
+ } else if (e.family === 'vault_request_save') {
6320
+ pendingVaultRequestSaves.set(e.stageId, {
6321
+ agent: e.agent,
6322
+ chat_id: e.chatId,
6323
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6324
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6325
+ key: e.key,
6326
+ kind: e.kind,
6327
+ value: '', // never persisted β€” secrets hygiene
6328
+ ...(e.why != null ? { why: e.why } : {}),
6329
+ staged_at: e.stagedAt,
6330
+ restoredWithoutValue: true,
6331
+ })
6332
+ restored++
6333
+ } else if (e.family === 'request_secret') {
6334
+ pendingSecretRequests.set(e.stageId, {
6335
+ agent: e.agent,
6336
+ chat_id: e.chatId,
6337
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6338
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6339
+ key: e.key,
6340
+ ...(e.reason != null ? { reason: e.reason } : {}),
6341
+ staged_at: e.stagedAt,
6342
+ })
6343
+ restored++
6344
+ } else if (e.family === 'mental_model_propose') {
6345
+ pendingMentalModelProposes.set(e.stageId, {
6346
+ agent: e.agent,
6347
+ chat_id: e.chatId,
6348
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6349
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6350
+ spec: e.spec,
6351
+ ...(e.reason != null ? { reason: e.reason } : {}),
6352
+ staged_at: e.stagedAt,
6353
+ })
6354
+ restored++
6355
+ }
6356
+ } catch (err) {
6357
+ process.stderr.write(
6358
+ `telegram gateway: pending-card restore skipped a malformed entry: ${(err as Error).message}\n`,
6359
+ )
6360
+ }
6361
+ }
6362
+ if (restored > 0) {
6363
+ process.stderr.write(
6364
+ `telegram gateway: restored ${restored} pending approval card(s) from prior gateway session\n`,
6365
+ )
6366
+ }
6367
+ return restored
6368
+ }
6369
+
5581
6370
  // 60-second sweep drops anything past its documented TTL.
5582
6371
  const pendingStateReaper = setInterval(() => {
5583
6372
  const now = Date.now()
@@ -5711,6 +6500,23 @@ const pendingStateReaper = setInterval(() => {
5711
6500
  for (const [k, v] of deferredSecrets) {
5712
6501
  if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
5713
6502
  }
6503
+ // Agent-initiated approval cards (vault_request_access / vault_request_save /
6504
+ // request_secret / mental_model_propose): expire past-TTL entries and WAKE
6505
+ // the parked agent (timeout synthetic + missed-approvals re-offer). This is
6506
+ // the authoritative timer β€” before this the only expiry path was a lazy
6507
+ // sweep on the NEXT stage, so an agent that ended its turn to wait on one of
6508
+ // these cards could sit parked forever if no further request ever staged.
6509
+ // try/catch matches the sibling sweepStaleTurnActiveMarker guard: an escaped
6510
+ // throw inside this setInterval callback would reach uncaughtException and
6511
+ // take the WHOLE gateway down (per-entry faults are already contained inside
6512
+ // sweepExpiredEntries/expirePendingCard; this is the outer belt).
6513
+ try {
6514
+ sweepExpiredApprovalCards(now)
6515
+ } catch (err) {
6516
+ process.stderr.write(
6517
+ `telegram gateway: approval-card expiry sweep failed: ${(err as Error).message}\n`,
6518
+ )
6519
+ }
5714
6520
  // #550: sweep a stale turn-active marker. Defence-in-depth for the
5715
6521
  // case where neither the turn_end arm nor onTurnComplete fired (SDK
5716
6522
  // killed before the JSONL turn_duration record, compaction window,
@@ -6186,6 +6992,10 @@ const statusPinState = new Map<string, PinState>()
6186
6992
  // owned pins without threading the chat id through every call site. Written on
6187
6993
  // every desired-pinned reconcile, cleared alongside the state on unpin.
6188
6994
  const statusPinChatIds = new Map<string, string>()
6995
+ // Companion registry: pinKey β†’ wall-clock ms the claim was FIRST taken (a
6996
+ // re-pin of the same key keeps the original timestamp). Feeds the TTL gate of
6997
+ // the mid-session `wk:` pin reaper (#3001); cleared alongside the state.
6998
+ const statusPinPinnedAt = new Map<string, number>()
6189
6999
 
6190
7000
  // Durable snapshot of the pin claim set on the persistent per-agent volume
6191
7001
  // (STATE_DIR = /state/agent/telegram in prod). Closes the crash hole: the
@@ -6237,6 +7047,22 @@ const BANNER_PIN_KEY = 'banner:owner'
6237
7047
  // run β†’ no-op). The boot-cleanup gate below is widened to cover this.
6238
7048
  const bannerPinPersistEnabled = !STATIC
6239
7049
 
7050
+ // `pin_message` MCP-tool pin registration (#3001). Tool pins ride the same
7051
+ // shared status-pins.json store under `tool:<chatId>:<messageId>` keys, but
7052
+ // with a TTL row (`expiresAt`): a tool pin is a deliberate agent action with
7053
+ // no "work finished" event, so a restart does NOT reset it β€” boot cleanup
7054
+ // keeps unexpired tool rows and only unpins them once the TTL lapses (the
7055
+ // backstop against agent-pinned messages accumulating forever). Independent
7056
+ // of PIN_STATUS_WHILE_WORKING (it gates the auto status pin, not the tool).
7057
+ const toolPinPersistEnabled = !STATIC
7058
+ // 7 days: generous β€” an agent-pinned message the operator still cares about
7059
+ // after a week has usually been re-pinned or acted on; anything older is the
7060
+ // stale-pin long tail this issue exists to clear. Override for tuning.
7061
+ const TOOL_PIN_TTL_MS = (() => {
7062
+ const v = Number(process.env.SWITCHROOM_TOOL_PIN_TTL_MS)
7063
+ return Number.isFinite(v) && v > 0 ? v : 7 * 24 * 60 * 60_000
7064
+ })()
7065
+
6240
7066
  // Persist (or drop) the slot-banner's pin row into the shared store. Routes
6241
7067
  // through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
6242
7068
  // serialised on the store's per-path lock, so the live fg:/wk: status-pin rows
@@ -6287,9 +7113,9 @@ async function statusPinBootCleanup(): Promise<void> {
6287
7113
  // map-backed status pins (statusPinPersistEnabled) or the slot banner
6288
7114
  // (bannerPinPersistEnabled). A single cleanup drains ALL orphaned rows β€”
6289
7115
  // status pins AND banner alike β€” since they share status-pins.json.
6290
- if (!statusPinPersistEnabled && !bannerPinPersistEnabled) return
7116
+ if (!statusPinPersistEnabled && !bannerPinPersistEnabled && !toolPinPersistEnabled) return
6291
7117
  const api = statusPinApi()
6292
- const { cleared, total } = await runStatusPinBootCleanup({
7118
+ const { cleared, retained, kept, total } = await runStatusPinBootCleanup({
6293
7119
  path: STATUS_PIN_STORE_PATH,
6294
7120
  fs: statusPinStoreFs,
6295
7121
  unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
@@ -6297,7 +7123,8 @@ async function statusPinBootCleanup(): Promise<void> {
6297
7123
  if (total > 0) {
6298
7124
  process.stderr.write(
6299
7125
  `telegram gateway: status-pin: cleared ${cleared}/${total} ` +
6300
- `orphaned pin(s) from a prior session\n`,
7126
+ `orphaned pin(s) from a prior session ` +
7127
+ `(retained ${retained} for retry, kept ${kept} unexpired tool pin(s))\n`,
6301
7128
  )
6302
7129
  }
6303
7130
  }
@@ -6398,6 +7225,25 @@ const MID_SESSION_CARD_REAPER_INTERVAL_MS = (() => {
6398
7225
  return Number.isFinite(v) && v > 0 ? v : 5 * 60_000 // 5 min
6399
7226
  })()
6400
7227
 
7228
+ // ─── Mid-session stale worker-pin reaper (#3001) ─────────────────────────────
7229
+ // The `wk:<agentId>` pin is normally dropped by the worker's completion
7230
+ // handler, but a missed onFinish (watcher crash / SDK SIGKILL / dropped JSONL
7231
+ // tail) used to leave the pin glued to the chat until the NEXT gateway boot.
7232
+ // This sweep (piggybacking on the mid-session reaper interval) unpins a
7233
+ // claimed worker pin when the registry says its worker is TERMINAL, or when
7234
+ // the pin has been held past a TTL. Pure decision in worker-pin-reaper.ts;
7235
+ // each reap executes through reconcileStatusPin so the in-memory claim and
7236
+ // the durable store row clear together.
7237
+ //
7238
+ // Kill switch: SWITCHROOM_WORKER_PIN_REAPER=0 disables (clean revert, mirrors
7239
+ // SWITCHROOM_MID_SESSION_CARD_REAPER). TTL overridable via env for tuning.
7240
+ const WORKER_PIN_REAPER_ENABLED =
7241
+ process.env.SWITCHROOM_WORKER_PIN_REAPER !== '0'
7242
+ const WORKER_PIN_REAPER_TTL_MS = (() => {
7243
+ const v = Number(process.env.SWITCHROOM_WORKER_PIN_REAPER_TTL_MS)
7244
+ return Number.isFinite(v) && v > 0 ? v : WORKER_PIN_TTL_MS_DEFAULT // 6 h
7245
+ })()
7246
+
6401
7247
  // Snapshot the turn_keys / topic keys owned by a live in-flight turn right now.
6402
7248
  function liveTurnKeySets(): { registryKeys: Set<string>; topicKeys: Set<string> } {
6403
7249
  const registryKeys = new Set<string>()
@@ -6462,15 +7308,27 @@ async function runMidSessionCardReaper(): Promise<void> {
6462
7308
  verb: 'activity-card.mid-session-reap-finalize',
6463
7309
  },
6464
7310
  ),
6465
- unpinCard: (record) =>
6466
- robustApiCall(
7311
+ // #3001: route through reconcileStatusPin when the pin is a live
7312
+ // in-memory claim, so the claim AND the durable status-pins.json row
7313
+ // clear together (the raw-API unpin left both behind β€” the boot sweep
7314
+ // then re-unpinned an already-unpinned message and the service-message
7315
+ // handler kept a phantom claim). Falls back to the raw unpin when no
7316
+ // claim is tracked (e.g. claim already dropped out-of-band).
7317
+ unpinCard: async (record) => {
7318
+ const pinKey = `fg:${record.turnKey}`
7319
+ if (statusPinState.has(pinKey)) {
7320
+ await reconcileStatusPin(pinKey, record.chatId, { pinned: false })
7321
+ return true
7322
+ }
7323
+ return robustApiCall(
6467
7324
  () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
6468
7325
  {
6469
7326
  chat_id: record.chatId,
6470
7327
  ...(record.threadId != null ? { threadId: record.threadId } : {}),
6471
7328
  verb: 'activity-card.mid-session-reap-unpin',
6472
7329
  },
6473
- ),
7330
+ )
7331
+ },
6474
7332
  })
6475
7333
  if (total > 0) {
6476
7334
  process.stderr.write(
@@ -6484,6 +7342,55 @@ async function runMidSessionCardReaper(): Promise<void> {
6484
7342
  )
6485
7343
  }
6486
7344
  }
7345
+
7346
+ // 3) Reap stale `wk:` worker pins (#3001): a claimed worker pin whose worker
7347
+ // is terminal in the registry (missed onFinish) or whose claim outlived
7348
+ // the TTL. Executes through reconcileStatusPin so the in-memory claim and
7349
+ // the durable store row clear together.
7350
+ if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
7351
+ try {
7352
+ const candidates = [...statusPinState.keys()]
7353
+ .filter((k) => k.startsWith('wk:'))
7354
+ .map((k) => ({
7355
+ pinKey: k,
7356
+ chatId: statusPinChatIds.get(k) ?? '',
7357
+ // A missing timestamp (should not happen β€” set on every claim)
7358
+ // degrades to "claimed just now": terminality can still reap it,
7359
+ // the TTL gate never can. Conservative, never a spurious unpin.
7360
+ pinnedAt: statusPinPinnedAt.get(k) ?? now,
7361
+ }))
7362
+ const reaps = decideWorkerPinReaps({
7363
+ pins: candidates,
7364
+ statusOf: (agentId) => {
7365
+ if (turnsDb == null) return 'unknown'
7366
+ try {
7367
+ const row = getSubagentByJsonlId(turnsDb, agentId)
7368
+ if (row == null) return 'unknown'
7369
+ if (row.status === 'completed' || row.status === 'failed') return 'terminal'
7370
+ // A live 'running' row exempts the pin from the TTL (no churn on
7371
+ // healthy long workers); 'stalled' degrades to 'unknown' β†’ TTL.
7372
+ if (row.status === 'running') return 'running'
7373
+ return 'unknown'
7374
+ } catch {
7375
+ return 'unknown' // DB hiccup degrades to "keep until TTL"
7376
+ }
7377
+ },
7378
+ ttlMs: WORKER_PIN_REAPER_TTL_MS,
7379
+ now,
7380
+ })
7381
+ for (const reap of reaps) {
7382
+ process.stderr.write(
7383
+ `telegram gateway: worker-pin reaper unpinning ${reap.pinKey} ` +
7384
+ `(chat=${reap.chatId} reason=${reap.reason})\n`,
7385
+ )
7386
+ await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false })
7387
+ }
7388
+ } catch (err) {
7389
+ process.stderr.write(
7390
+ `telegram gateway: worker-pin reaper error: ${(err as Error).message}\n`,
7391
+ )
7392
+ }
7393
+ }
6487
7394
  }
6488
7395
 
6489
7396
  const midSessionCardReaper = setInterval(() => {
@@ -6571,9 +7478,11 @@ async function reconcileStatusPinInner(
6571
7478
  if (next == null) {
6572
7479
  statusPinState.delete(pinKey)
6573
7480
  statusPinChatIds.delete(pinKey)
7481
+ statusPinPinnedAt.delete(pinKey)
6574
7482
  } else {
6575
7483
  statusPinState.set(pinKey, next)
6576
7484
  statusPinChatIds.set(pinKey, chatId)
7485
+ if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
6577
7486
  }
6578
7487
  return
6579
7488
  }
@@ -6593,9 +7502,11 @@ async function reconcileStatusPinInner(
6593
7502
  if (next == null) {
6594
7503
  statusPinState.delete(pinKey)
6595
7504
  statusPinChatIds.delete(pinKey)
7505
+ statusPinPinnedAt.delete(pinKey)
6596
7506
  } else {
6597
7507
  statusPinState.set(pinKey, next)
6598
7508
  statusPinChatIds.set(pinKey, chatId)
7509
+ if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
6599
7510
  }
6600
7511
  }
6601
7512
 
@@ -6638,7 +7549,7 @@ async function unpinAllStatusPins(): Promise<void> {
6638
7549
  if (st == null) continue
6639
7550
  // Recover the chat id from the state map's companion key registry.
6640
7551
  const chatId = statusPinChatIds.get(key)
6641
- if (chatId == null) { statusPinState.delete(key); continue }
7552
+ if (chatId == null) { statusPinState.delete(key); statusPinPinnedAt.delete(key); continue }
6642
7553
  await reconcileStatusPin(key, chatId, { pinned: false })
6643
7554
  }
6644
7555
  }
@@ -7396,6 +8307,19 @@ function trackRedeliveredInbound(merged: InboundMessage): void {
7396
8307
  ) {
7397
8308
  return
7398
8309
  }
8310
+ // Button-tap anti-storm guard β€” mirrors the immediate-delivery path in
8311
+ // deliverButtonTapInbound. A tap synthesized without a source message
8312
+ // (`cbMessageId == null` β†’ `messageId: 0`, no meta.message_id) has no id
8313
+ // the `enqueue` ack can ever match, so enrolling it would make the
8314
+ // never-drop sweep re-deliver it until TTL. The immediate path skips
8315
+ // tracking for such taps; a tap that buffered mid-turn and flushed through
8316
+ // here must be skipped identically (asymmetry = a storm on one path only).
8317
+ if (
8318
+ merged.meta?.button_callback === 'true' &&
8319
+ (merged.meta.message_id == null || merged.meta.message_id === '')
8320
+ ) {
8321
+ return
8322
+ }
7399
8323
  const key = chatKey(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null)
7400
8324
  trackDelivery(
7401
8325
  deliveryQueue,
@@ -8230,6 +9154,7 @@ const ipcServer: IpcServer = createIpcServer({
8230
9154
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
8231
9155
  configSnapshotPath: join(resolvedAgentDirForCard, '.config-snapshot.json'),
8232
9156
  bootCardStatePath: join(resolvedAgentDirForCard, '.boot-card-msgid.json'),
9157
+ floodStatePath: FLOOD_STATE_PATH,
8233
9158
  ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
8234
9159
  }, ackMsgId).then(handle => {
8235
9160
  activeBootCard = handle
@@ -9261,6 +10186,42 @@ const ipcServer: IpcServer = createIpcServer({
9261
10186
  void fireFleetAutoFallback(msg.agentName, untilMs)
9262
10187
  },
9263
10188
 
10189
+ // Issue #2971 β€” read-only wedge-watchdog probe: is there a live pending
10190
+ // permission request (Telegram approval card) for this agent right now?
10191
+ // Sourced directly from `pendingPermissions` β€” no mutation, no card
10192
+ // posting, just a snapshot read. The watchdog uses this to decide whether
10193
+ // to Esc a shape-persistent permission-prompt TUI or defer to the card /
10194
+ // the #2724 TTL reaper. Always answered synchronously and on the SAME
10195
+ // connection so the watchdog's short (~2s) budget can resolve promptly.
10196
+ onQueryPendingPermission(client: IpcClient, msg: QueryPendingPermissionMessage) {
10197
+ const self = process.env.SWITCHROOM_AGENT_NAME
10198
+ if (self && msg.agentName !== self) {
10199
+ process.stderr.write(
10200
+ `telegram gateway: query_pending_permission rejected β€” agent mismatch (${msg.agentName} != ${self})\n`,
10201
+ )
10202
+ try {
10203
+ client.send({ type: 'pending_permission_status', correlationId: msg.correlationId, pending: false })
10204
+ } catch { /* best effort */ }
10205
+ return
10206
+ }
10207
+ // Any LIVE entry answers the question β€” this gateway serves exactly one
10208
+ // agent, so `pendingPermissions` is already scoped to `msg.agentName`.
10209
+ const [requestId] = pendingPermissions.keys()
10210
+ const pending = pendingPermissions.size > 0
10211
+ try {
10212
+ client.send({
10213
+ type: 'pending_permission_status',
10214
+ correlationId: msg.correlationId,
10215
+ pending,
10216
+ ...(pending && requestId ? { requestId } : {}),
10217
+ })
10218
+ } catch (err) {
10219
+ process.stderr.write(
10220
+ `telegram gateway: query_pending_permission reply failed: ${(err as Error).message}\n`,
10221
+ )
10222
+ }
10223
+ },
10224
+
9264
10225
  // #2670 one-tap self-improvement β€” persist a skill-improvement proposal and
9265
10226
  // post its Approve/Dismiss card. The store transition + apply-injection on
9266
10227
  // Approve are owned by handleSkillProposalCallback (so a gateway restart
@@ -9788,6 +10749,15 @@ const voicePreSynthQueue = new PreSynthQueue({
9788
10749
  }
9789
10750
  const filePath = writeVoiceCacheFile(VOICE_CACHE_DIR, job.token, result.audio)
9790
10751
  voiceOnDemandCache.setFilePath(job.token, filePath)
10752
+ // TODO(first-tap-instant): pre-mint the Telegram file_id here so even the
10753
+ // FIRST user tap sends by id (no upload). Would require uploading this ogg
10754
+ // once to the bot's OWN hidden storage/log chat and calling
10755
+ // voiceOnDemandCache.setTelegramFileId(job.token, msg.voice.file_id).
10756
+ // Deferred: no storage-chat id exists in config/env today, and the product
10757
+ // invariant forbids sending audio to the USER's chat without a tap β€” so a
10758
+ // dedicated bot-storage chat id must be added first. Until then, the first
10759
+ // tap uploads (via the disk fast-path) and captures the id for reuse; only
10760
+ // the second+ taps are instant.
9791
10761
  process.stderr.write(
9792
10762
  `telegram gateway: voice-presynth: cached ${result.audio.length} bytes at ${filePath} ` +
9793
10763
  `(${result.durationMs}ms, backlog=${voicePreSynthQueue.size})\n`,
@@ -11748,6 +12718,21 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
11748
12718
  { threadId, chat_id, verb: 'vault_request_save.card' },
11749
12719
  )
11750
12720
  pending.card_message_id = sent.message_id
12721
+ // Persist card METADATA (never the staged `value` β€” secrets hygiene) so a
12722
+ // gateway restart doesn't strand the parked agent. A restored Save tap can't
12723
+ // complete (value is gone) and degrades to a "value lost to restart" wake-up.
12724
+ pendingCardStore.add({
12725
+ family: 'vault_request_save',
12726
+ stageId,
12727
+ agent: pending.agent,
12728
+ chatId: pending.chat_id,
12729
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
12730
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
12731
+ key: pending.key,
12732
+ kind: pending.kind,
12733
+ ...(pending.why != null ? { why: pending.why } : {}),
12734
+ stagedAt: pending.staged_at,
12735
+ })
11751
12736
 
11752
12737
  return {
11753
12738
  content: [
@@ -11791,11 +12776,17 @@ const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
11791
12776
  const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
11792
12777
  const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
11793
12778
 
11794
- function sweepSecretRequests(): void {
11795
- const now = Date.now()
11796
- for (const [k, v] of pendingSecretRequests) {
11797
- if (now - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS) pendingSecretRequests.delete(k)
11798
- }
12779
+ function sweepSecretRequests(now = Date.now()): void {
12780
+ sweepExpiredEntries(
12781
+ pendingSecretRequests,
12782
+ (v, n) => n - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS,
12783
+ expireSecretRequestCard,
12784
+ now,
12785
+ cardExpiryLog,
12786
+ )
12787
+ // armedSecretCaptures is a TRANSIENT post-tap window (never persisted): it's
12788
+ // only set after the operator taps [Provide securely], and the request is
12789
+ // no longer parked-on-a-card. Just drop stale ones β€” no wake needed.
11799
12790
  for (const [k, v] of armedSecretCaptures) {
11800
12791
  if (now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) armedSecretCaptures.delete(k)
11801
12792
  }
@@ -11845,7 +12836,10 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
11845
12836
  // Dedupe: one open request per (chat, key). Drop any prior stage for
11846
12837
  // the same target so the operator never sees stacked cards.
11847
12838
  for (const [sid, p] of pendingSecretRequests) {
11848
- if (p.chat_id === chat_id && p.key === key) pendingSecretRequests.delete(sid)
12839
+ if (p.chat_id === chat_id && p.key === key) {
12840
+ pendingSecretRequests.delete(sid)
12841
+ pendingCardStore.remove(sid)
12842
+ }
11849
12843
  }
11850
12844
 
11851
12845
  const stageId = randomBytes(4).toString('hex')
@@ -11867,6 +12861,21 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
11867
12861
  { threadId, chat_id, verb: 'request_secret.card' },
11868
12862
  )
11869
12863
  pending.card_message_id = sent.message_id
12864
+ // Persist card metadata so a gateway restart doesn't strand the parked
12865
+ // agent. request_secret holds NO value at staging time (the value arrives
12866
+ // after the operator taps [Provide securely]), so nothing sensitive lands
12867
+ // on disk here.
12868
+ pendingCardStore.add({
12869
+ family: 'request_secret',
12870
+ stageId,
12871
+ agent: pending.agent,
12872
+ chatId: pending.chat_id,
12873
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
12874
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
12875
+ key: pending.key,
12876
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
12877
+ stagedAt: pending.staged_at,
12878
+ })
11870
12879
 
11871
12880
  return {
11872
12881
  content: [
@@ -11915,6 +12924,7 @@ async function captureProvidedSecret(
11915
12924
  armedSecretCaptures.delete(chat_id)
11916
12925
  const pending = pendingSecretRequests.get(armed.stageId)
11917
12926
  pendingSecretRequests.delete(armed.stageId)
12927
+ pendingCardStore.remove(armed.stageId)
11918
12928
 
11919
12929
  // Delete the raw message FIRST β€” surfaces a warning if it fails.
11920
12930
  if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'provided secret value')
@@ -12030,6 +13040,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
12030
13040
 
12031
13041
  if (action === 'decline') {
12032
13042
  pendingSecretRequests.delete(stageId)
13043
+ pendingCardStore.remove(stageId)
12033
13044
  armedSecretCaptures.delete(pending.chat_id)
12034
13045
  await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
12035
13046
  if (pending.card_message_id != null) {
@@ -12197,12 +13208,27 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
12197
13208
  { threadId, chat_id, verb: 'vault_request_access.card' },
12198
13209
  )
12199
13210
  pending.card_message_id = sent.message_id
13211
+ // Persist card metadata (no secret material β€” this flow stages only the ACL
13212
+ // request) so a gateway restart doesn't strand the parked agent.
13213
+ pendingCardStore.add({
13214
+ family: 'vault_request_access',
13215
+ stageId,
13216
+ agent: pending.agent,
13217
+ chatId: pending.chat_id,
13218
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
13219
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
13220
+ key: pending.key,
13221
+ scope: pending.scope,
13222
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
13223
+ ttlSeconds: pending.ttl_seconds,
13224
+ stagedAt: pending.staged_at,
13225
+ })
12200
13226
 
12201
13227
  return {
12202
13228
  content: [
12203
13229
  {
12204
13230
  type: 'text',
12205
- 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.`,
13231
+ 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.`,
12206
13232
  },
12207
13233
  ],
12208
13234
  }
@@ -12366,6 +13392,19 @@ async function executeMentalModelPropose(args: Record<string, unknown>): Promise
12366
13392
  { threadId, chat_id, verb: 'mental_model_propose.card' },
12367
13393
  )
12368
13394
  pending.card_message_id = sent.message_id
13395
+ // Persist card metadata (the proposed DECLARATION is not secret material) so
13396
+ // a gateway restart doesn't strand the parked agent.
13397
+ pendingCardStore.add({
13398
+ family: 'mental_model_propose',
13399
+ stageId,
13400
+ agent: pending.agent,
13401
+ chatId: pending.chat_id,
13402
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
13403
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
13404
+ spec: pending.spec,
13405
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
13406
+ stagedAt: pending.staged_at,
13407
+ })
12369
13408
  // Only count a proposal against the rate budget once its card actually
12370
13409
  // posted (validation errors / dupes don't consume the budget).
12371
13410
  mentalModelProposeTimes.push(Date.now())
@@ -12526,10 +13565,26 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
12526
13565
  // errors are retried. THREAD_NOT_FOUND on a stale topic surfaces to the
12527
13566
  // agent as a tool-error β€” pinning a vanished message is genuinely a
12528
13567
  // failure the agent should see.
13568
+ const pinMsgId = Number(args.message_id)
12529
13569
  await robustApiCall(
12530
- () => lockedBot.api.pinChatMessage(pinChatId, Number(args.message_id)),
13570
+ () => lockedBot.api.pinChatMessage(pinChatId, pinMsgId),
12531
13571
  { chat_id: pinChatId, verb: 'pin_message' },
12532
13572
  )
13573
+ // #3001: register the tool pin in the shared status-pin store under a
13574
+ // `tool:` key so it is no longer fire-and-forget. Unlike work-scoped
13575
+ // fg:/wk: rows a tool pin has no "work finished" event, so a restart does
13576
+ // NOT reset it β€” boot cleanup keeps the row until its TTL, then unpins the
13577
+ // (likely long-forgotten) message. Best-effort fire-and-forget: a store
13578
+ // failure must never fail the tool call the pin already landed for.
13579
+ if (toolPinPersistEnabled) {
13580
+ const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
13581
+ void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, {
13582
+ pinKey: toolPinKey,
13583
+ chatId: pinChatId,
13584
+ messageId: pinMsgId,
13585
+ expiresAt: Date.now() + TOOL_PIN_TTL_MS,
13586
+ })
13587
+ }
12533
13588
  return { content: [{ type: 'text', text: `pinned message ${args.message_id}` }] }
12534
13589
  }
12535
13590
 
@@ -12771,6 +13826,7 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
12771
13826
  elapsedMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
12772
13827
  toolCount: turn.labeledToolCount,
12773
13828
  state: final ? 'done' : 'running',
13829
+ model: turn.currentModel,
12774
13830
  }
12775
13831
  return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
12776
13832
  }
@@ -13088,6 +14144,7 @@ function openLivenessFeedIfDue(turn: CurrentTurn): void {
13088
14144
  const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working…']
13089
14145
  const livenessHeader: SessionActivityHeader = {
13090
14146
  label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
14147
+ model: turn.currentModel,
13091
14148
  }
13092
14149
  // Liveness card is a single "step" whose start is the turn start, so `age`
13093
14150
  // IS the step's own elapsed. formatStepSuffix keeps the `β†’` line timer-free
@@ -13218,6 +14275,7 @@ function feedHeartbeatTick(): void {
13218
14275
  const age = Date.now() - turn.startedAt
13219
14276
  const livenessHeader: SessionActivityHeader = {
13220
14277
  label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
14278
+ model: turn.currentModel,
13221
14279
  }
13222
14280
  const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working in background…']
13223
14281
  // `subagentAt` is the worker's last ADVANCE β€” the current step's start β€”
@@ -13407,6 +14465,7 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
13407
14465
  const livenessElapsed = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
13408
14466
  const livenessHeader: SessionActivityHeader = {
13409
14467
  label: 'Agent', elapsedMs: livenessElapsed, toolCount: turn.labeledToolCount, state: 'done',
14468
+ model: turn.currentModel,
13410
14469
  }
13411
14470
  finalHtml = renderActivityFeedWithNested(['Working…'], [], true, '', undefined, livenessHeader)
13412
14471
  }
@@ -13897,6 +14956,22 @@ function handleSessionEvent(ev: SessionEvent): void {
13897
14956
  return
13898
14957
  }
13899
14958
  case 'dequeue': return
14959
+ case 'model': {
14960
+ // Live model capture for the main turn. The session-tail projection
14961
+ // already filtered sentinels (`<synthetic>` compaction lines), so any
14962
+ // value reaching here is a real resolved model id. Record it on the turn
14963
+ // (update-on-change) so the activity/liveness card header and /status
14964
+ // render the model actually serving this turn's API calls β€” transcript-
14965
+ // sourced, never config. Also note it on the freshness-aware session-model
14966
+ // source so a /status query between turns still reflects the last model
14967
+ // (and a fresh assistant line reclaims the source from a /model override).
14968
+ const turn = currentTurn
14969
+ if (turn != null) {
14970
+ turn.currentModel = ev.model
14971
+ }
14972
+ sessionModelSource.noteTranscriptModel(ev.model)
14973
+ return
14974
+ }
13900
14975
  case 'thinking': {
13901
14976
  // #1067: snapshot the turn atom at handler entry. Even though this
13902
14977
  // handler is sync, the principle is uniform across all event arms
@@ -17022,13 +18097,23 @@ async function handleInbound(
17022
18097
  // know they're queued. Suppressed for DMs (no topics) and same-topic
17023
18098
  // queues (the in-flight turn's own card already covers them).
17024
18099
  const inFlightThread = currentTurn?.sessionThreadId
17025
- if (
18100
+ const crossTopicQueuedCard =
17026
18101
  QUEUED_STATUS_UX_ENABLED &&
17027
18102
  !isDmChatId(chat_id) &&
17028
18103
  messageThreadId != null &&
17029
18104
  messageThreadId !== inFlightThread
17030
- ) {
18105
+ if (crossTopicQueuedCard) {
17031
18106
  postQueuedStatus(chat_id, messageThreadId, inFlightThread)
18107
+ } else {
18108
+ // #2995 mid-flight busy ack β€” ONLY the surfaces the cross-topic card
18109
+ // suppresses (DMs, same-topic). Mutually exclusive with
18110
+ // postQueuedStatus above: both record into queuedStatusMsgIds only
18111
+ // AFTER their sendMessage awaits resolve, so calling both here would
18112
+ // race past each other's has(key) check and double-card the topic.
18113
+ // When the running turn is inside one LONG tool step, a buffered
18114
+ // quick question would otherwise wait minutes with only a πŸ‘€; post a
18115
+ // silent deterministic "Queued β€” currently inside <tool>" card.
18116
+ maybePostBusyAck('buffer-until-idle', chat_id, messageThreadId ?? undefined)
17032
18117
  }
17033
18118
  return
17034
18119
  }
@@ -17074,6 +18159,15 @@ async function handleInbound(
17074
18159
 
17075
18160
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
17076
18161
  if (delivered) {
18162
+ // #2995 β€” a steer delivered mid-turn while the turn is stuck inside one
18163
+ // long tool step gets a deterministic ack (worded as a steer, not
18164
+ // "Queued" β€” classification visibility): the model can't narrate the
18165
+ // steer until the blocking call returns. Posted only AFTER the bridge
18166
+ // dispatch succeeded β€” a "Steer noted" card for a send that missed
18167
+ // (bridge offline β†’ buffered/dropped below) would be untrue.
18168
+ if (isSteering) {
18169
+ maybePostBusyAck('steer', chat_id, messageThreadId ?? undefined)
18170
+ }
17077
18171
  // Reuse the key reserved synchronously above (#2917) when present, else
17078
18172
  // mark now β€” markClaudeBusyForInbound is idempotent (lockstep re-stamp),
17079
18173
  // so a re-mark is safe and returns the same chat key.
@@ -18463,13 +19557,12 @@ function buildAgentAudit(agentName: string): AgentAudit | undefined {
18463
19557
  // broker's fleet-wide `ListStateData` payload via
18464
19558
  // `buildAuthSummaryFromBroker`, with billingType pulled from the
18465
19559
  // agent's `.claude.json` (the broker doesn't track plan tier).
18466
- /**
18467
- * Live session-model override set by the `/model` picker (session-only). Held
18468
- * in gateway memory so it clears on restart, the same point at which claude's
18469
- * session reverts to the configured model β€” keeping `/status` honest without
18470
- * a persisted store. Null when no session switch is active.
18471
- */
18472
- let activeSessionModelOverride: string | null = null
19560
+ // The live session-model override set by the `/model` picker (session-only)
19561
+ // lives on `sessionModelSource` (setOverride/getOverride, declared beside the
19562
+ // currentTurn globals). Held in gateway memory so it clears on restart, the
19563
+ // same point at which claude's session reverts to the configured model β€”
19564
+ // keeping `/status` honest without a persisted store. `resolve()` arbitrates
19565
+ // freshness against the transcript-observed model (#2982 idle-switch window).
18473
19566
 
18474
19567
  async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
18475
19568
  type AgentListResp = {
@@ -18499,7 +19592,18 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
18499
19592
  return {
18500
19593
  agentName,
18501
19594
  model: a?.model ?? null,
18502
- sessionModel: activeSessionModelOverride,
19595
+ // The FRESHEST session-model observation wins (session-model-source.ts):
19596
+ // the transcript's `message.model` (ground truth for the last API call)
19597
+ // vs the #2982 /model override (the only truthful source in the idle-
19598
+ // after-switch window, before the next assistant line). Both rendered
19599
+ // through formatModelLabel for a consistent short form; an override value
19600
+ // that isn't model-shaped (an already-friendly "Opus 4.8" label) passes
19601
+ // through verbatim. Never sourced from config.
19602
+ sessionModel: (() => {
19603
+ const resolved = sessionModelSource.resolve()
19604
+ if (resolved == null) return null
19605
+ return formatModelLabel(resolved.model) ?? resolved.model
19606
+ })(),
18503
19607
  extendsProfile: (a?.extends ?? a?.template) ?? null,
18504
19608
  topicName: a?.topic_name ?? null,
18505
19609
  topicEmoji: a?.topic_emoji ?? null,
@@ -18730,7 +19834,7 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18730
19834
  },
18731
19835
  escapeHtml: escapeHtmlForTg,
18732
19836
  preBlock,
18733
- getActiveSessionModel: () => activeSessionModelOverride,
19837
+ getActiveSessionModel: () => sessionModelSource.getOverride(),
18734
19838
  /**
18735
19839
  * Graceful restart for sr-* β†’ Claude model switch. Same mechanism as
18736
19840
  * the /restart command: writes a restart marker (so the post-restart
@@ -18740,9 +19844,18 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18740
19844
  scheduleRestart: async (reason: string) => {
18741
19845
  const name = getMyAgentName()
18742
19846
  // Debounce: mirror the /restart command's 15 s guard to prevent
18743
- // double-dispatch on a rapid double-tap of /model <claude-alias>.
19847
+ // double-dispatch on a rapid double-tap of /model <claude-alias>. This
19848
+ // path previously `return`ed silently β€” indistinguishable from a
19849
+ // successful dispatch β€” so a model switch caught in the window was a
19850
+ // silent no-op while the caller reported success. THROW a tagged error
19851
+ // so scheduleModelRelaunch can react (keep the in-flight restart's carrier,
19852
+ // tell the operator honestly) instead of falsely claiming the switch stuck.
18744
19853
  const existing = readRestartMarker()
18745
- if (existing && Date.now() - existing.ts < 15_000) return
19854
+ if (existing && Date.now() - existing.ts < 15_000) {
19855
+ const e = new Error('a restart is already in flight β€” try again in ~15s')
19856
+ ;(e as { code?: string }).code = 'restart_in_flight'
19857
+ throw e
19858
+ }
18746
19859
  if (restartCtx) {
18747
19860
  writeRestartMarker({
18748
19861
  chat_id: restartCtx.chatId,
@@ -18752,6 +19865,15 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18752
19865
  })
18753
19866
  }
18754
19867
  stampUserRestartReason(reason)
19868
+ // Model-switch restarts are switchroom-managed relaunches: the session
19869
+ // override (written by the caller before this dispatch) must survive
19870
+ // the bounce, so stamp keep-intent BEFORE dispatch (boot default is
19871
+ // revert). hostd shells through `switchroom agent restart`, which
19872
+ // deliberately writes no intent of its own.
19873
+ {
19874
+ const smDir = resolveAgentDirFromEnv()
19875
+ if (smDir) writeRelaunchModelIntent(smDir, 'keep', reason)
19876
+ }
18755
19877
  await sweepBeforeSelfRestart()
18756
19878
  const hostdResp = await tryHostdDispatch(name, {
18757
19879
  v: 1,
@@ -18771,30 +19893,61 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18771
19893
  )
18772
19894
  return
18773
19895
  }
18774
- // hostd is configured but returned an error/denied result.
19896
+ // hostd is configured but returned an error/denied result. No restart
19897
+ // is coming, so the keep-intent stamped above must not linger β€” a
19898
+ // crash within its 10-min freshness window would wrongly KEEP.
18775
19899
  if (hostdResp.result !== 'started' && hostdResp.result !== 'completed') {
18776
19900
  clearRestartMarker()
19901
+ {
19902
+ const smDir = resolveAgentDirFromEnv()
19903
+ if (smDir) clearRelaunchModelIntent(smDir)
19904
+ }
18777
19905
  throw new Error(
18778
19906
  `hostd restart failed (result=${hostdResp.result}): ${hostdResp.error ?? '(no details)'}`,
18779
19907
  )
18780
19908
  }
18781
19909
  },
18782
19910
  /**
18783
- * Session-only switch TO an sr-* (LiteLLM/OpenRouter) model. claude's
18784
- * native `/model` picker rejects unknown sr-* ids, so we can't inject.
18785
- * Write the token to the `.session-model-override` carrier file (start.sh
18786
- * consumes it on the next boot and launches `claude --model <token>`), set
18787
- * the in-memory session-model so /status stays honest across the restart
18788
- * window, then run the SAME restart dispatch as scheduleRestart above.
19911
+ * Switch TO a model that needs a relaunch (sr-* LiteLLM/OpenRouter ids,
19912
+ * which claude's native `/model` picker rejects, and the sr-to-claude
19913
+ * direction). Write the DURABLE `.session-model` override (start.sh
19914
+ * applies it on every keep-relaunch boot and launches `claude --model
19915
+ * <token>`), set the in-memory session-model so /status stays honest
19916
+ * across the restart window, then run the SAME restart dispatch as
19917
+ * scheduleRestart above β€” which stamps the keep-intent this boot needs.
18789
19918
  */
18790
19919
  scheduleModelRelaunch: async (model: string, reason: string) => {
18791
19920
  const agentDir = resolveAgentDirFromEnv()
18792
- if (!agentDir) throw new Error('agent dir unresolvable β€” cannot write session-model carrier')
18793
- // Carrier: single line, token + newline, no quoting (start.sh strips
18794
- // whitespace and shape-gates). One-shot β€” consumed on the next boot.
18795
- writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
18796
- activeSessionModelOverride = model
18797
- await deps.scheduleRestart(reason)
19921
+ if (!agentDir) throw new Error('agent dir unresolvable β€” cannot write session-model file')
19922
+ const prevOverride = sessionModelSource.getOverride()
19923
+ const prevFileRaw = readSessionModelFileRaw(agentDir)
19924
+ writeSessionModelFile(
19925
+ agentDir,
19926
+ model,
19927
+ readConfiguredDefaultModel(agentDir) ??
19928
+ resolveMainModel(deps.getConfiguredModel() ?? undefined),
19929
+ )
19930
+ sessionModelSource.setOverride(model)
19931
+ try {
19932
+ await deps.scheduleRestart(reason)
19933
+ } catch (err) {
19934
+ // A restart already in flight OWNS the override we just wrote β€” its
19935
+ // boot (stamped keep by the in-flight path's own intent, last-writer-
19936
+ // wins) will apply our token, so the switch is queued, not lost: keep
19937
+ // the file + override and let the caller tell the operator "~15s".
19938
+ // Any OTHER dispatch failure means no restart is coming, so roll BOTH
19939
+ // back β€” a lingering file/override would lie to /status and
19940
+ // mis-launch the NEXT relaunch. The keep-intent goes with them
19941
+ // (belt-and-braces: scheduleRestart's failure branch clears it too):
19942
+ // a fresh keep on disk with no restart coming would wrongly KEEP
19943
+ // across a crash inside its 10-min window.
19944
+ if ((err as { code?: string })?.code !== 'restart_in_flight') {
19945
+ restoreSessionModelFileRaw(agentDir, prevFileRaw)
19946
+ sessionModelSource.setOverride(prevOverride)
19947
+ clearRelaunchModelIntent(agentDir)
19948
+ }
19949
+ throw err
19950
+ }
18798
19951
  },
18799
19952
  }
18800
19953
  return deps
@@ -18823,7 +19976,50 @@ bot.command('model', async ctx => {
18823
19976
  return
18824
19977
  }
18825
19978
  const reply = await handleModelCommand(parsed, deps)
18826
- await switchroomReply(ctx, reply.text, { html: reply.html })
19979
+ // Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
19980
+ // actually running β€” the SAME in-memory override the menu callback path sets
19981
+ // (buildAgentMetadata resolves it via sessionModelSource). Only
19982
+ // set on the confirmed inject path; the sr-*/relaunch paths already set the
19983
+ // override inside scheduleModelRelaunch, and an unverified switch carries no
19984
+ // selectedModel so /status is never lied to.
19985
+ const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null
19986
+ let persistWarning = ''
19987
+ if (requested?.toLowerCase() === 'default') {
19988
+ // `/model default` clears the sticky file even WITHOUT a positive
19989
+ // confirmation: claude's arg-form switch can be silent, and a surviving
19990
+ // sticky file would resurrect the old model on the next keep-relaunch.
19991
+ // Clearing is idempotent; the in-memory override change stays gated on a
19992
+ // confirmed switch so an unverified inject never lies to /status.
19993
+ const smDir = resolveAgentDirFromEnv()
19994
+ if (smDir) clearSessionModelFile(smDir)
19995
+ if (reply.selectedModel) sessionModelSource.setOverride(null)
19996
+ } else if (reply.selectedModel) {
19997
+ sessionModelSource.setOverride(reply.selectedModel)
19998
+ // Durable stickiness: persist the REQUESTED canonical token β€” never
19999
+ // the confirmation's display label ("Opus 4.8"), which `claude
20000
+ // --model` would reject on the next boot. sr-* switches never reach
20001
+ // here (they go through scheduleModelRelaunch, which persists).
20002
+ const smDir = resolveAgentDirFromEnv()
20003
+ if (smDir && requested && isValidModelArg(requested) && !isSrModel(requested)) {
20004
+ try {
20005
+ writeSessionModelFile(
20006
+ smDir,
20007
+ requested,
20008
+ readConfiguredDefaultModel(smDir) ??
20009
+ resolveMainModel(deps.getConfiguredModel() ?? undefined),
20010
+ )
20011
+ } catch (err) {
20012
+ // The reply body already promises stickiness β€” never let the promise
20013
+ // and the disk disagree silently.
20014
+ persistWarning =
20015
+ '\n⚠️ Couldn’t persist the sticky override β€” the switch is live now but won’t survive a relaunch.'
20016
+ process.stderr.write(
20017
+ `telegram gateway: session-model persist failed (typed /model): ${(err as Error)?.message ?? String(err)}\n`,
20018
+ )
20019
+ }
20020
+ }
20021
+ }
20022
+ await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html })
18827
20023
  })
18828
20024
 
18829
20025
  // `/effort` β€” show or switch the reasoning effort for the live session.
@@ -18934,6 +20130,13 @@ bot.command('restart', async ctx => {
18934
20130
  // greeting card shows "Restarted user: /restart from chat" instead
18935
20131
  // of whatever reason the downstream CLI would default to.
18936
20132
  stampUserRestartReason('user: /restart from chat')
20133
+ // /restart is a DELIBERATE restart: the session model reverts to the
20134
+ // configured default. Absence of intent would revert anyway (boot
20135
+ // default) β€” the explicit stamp is reason-honesty for the boot notice.
20136
+ {
20137
+ const smDir = resolveAgentDirFromEnv()
20138
+ if (smDir) writeRelaunchModelIntent(smDir, 'revert', 'user: /restart from chat')
20139
+ }
18937
20140
  await sweepBeforeSelfRestart()
18938
20141
  const hostdResp = await tryHostdDispatch(getMyAgentName(), {
18939
20142
  v: 1,
@@ -19092,6 +20295,12 @@ async function handleNewCommand(ctx: Context): Promise<void> {
19092
20295
  // Stamp user attribution so the next greeting shows "Restarted user:
19093
20296
  // /new" / "user: /reset" rather than the downstream CLI default.
19094
20297
  stampUserRestartReason(`user: /${kind} from chat`)
20298
+ // /new and /reset start a fresh CONVERSATION, not a fresh model choice:
20299
+ // the sticky session-model override KEEPS across them (contract row 7).
20300
+ // Boot default is revert, so the keep-intent must land before dispatch.
20301
+ if (agentDir != null) {
20302
+ writeRelaunchModelIntent(agentDir, 'keep', `user: /${kind} from chat`)
20303
+ }
19095
20304
  await sweepBeforeSelfRestart()
19096
20305
  const hostdResp = await tryHostdDispatch(getMyAgentName(), {
19097
20306
  v: 1,
@@ -21118,6 +22327,7 @@ async function performVaultAccessApproval(
21118
22327
  const visible = await listViaBroker()
21119
22328
  if (visible !== null && visible.includes(pending.key)) {
21120
22329
  pendingVaultRequestAccesses.delete(stageId)
22330
+ pendingCardStore.remove(stageId)
21121
22331
  if (pending.card_message_id != null) {
21122
22332
  await ctx.api
21123
22333
  .editMessageText(
@@ -21216,6 +22426,7 @@ async function performVaultAccessApproval(
21216
22426
  // the agent to re-issue, or the broker error message will tell
21217
22427
  // them the next step.
21218
22428
  pendingVaultRequestAccesses.delete(stageId)
22429
+ pendingCardStore.remove(stageId)
21219
22430
  if (pending.card_message_id != null) {
21220
22431
  await ctx.api
21221
22432
  .editMessageText(
@@ -21247,6 +22458,7 @@ async function performVaultAccessApproval(
21247
22458
  }
21248
22459
 
21249
22460
  pendingVaultRequestAccesses.delete(stageId)
22461
+ pendingCardStore.remove(stageId)
21250
22462
  if (pending.card_message_id != null) {
21251
22463
  const days = Math.round(pending.ttl_seconds / 86400)
21252
22464
  const footer =
@@ -21455,23 +22667,17 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
21455
22667
  // this, a card left untapped past its TTL is still resolvable if no fresh
21456
22668
  // proposal has run the sweep β€” an operator could approve a stale proposal.
21457
22669
  if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
21458
- pendingMentalModelProposes.delete(stageId)
21459
- await ctx.answerCallbackQuery({ text: 'Card expired β€” ask the agent to re-propose.' }).catch(() => {})
21460
- if (pending.card_message_id != null) {
21461
- await ctx.api
21462
- .editMessageText(
21463
- pending.chat_id,
21464
- pending.card_message_id,
21465
- richMessage('βŒ› _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
21466
- { reply_markup: { inline_keyboard: [] } },
21467
- )
21468
- .catch(() => {})
21469
- }
22670
+ // Expired between post and tap: route through the shared expiry path so the
22671
+ // parked agent is WOKEN (timeout synthetic + missed-approvals re-offer) and
22672
+ // the durable store entry is cleared β€” not just a silent map delete.
22673
+ expireMentalModelProposeCard(stageId, pending, Date.now())
22674
+ await ctx.answerCallbackQuery({ text: 'Card expired β€” the agent was notified.' }).catch(() => {})
21470
22675
  return
21471
22676
  }
21472
22677
  // Single-shot: remove the pending entry immediately so a double-tap can't
21473
22678
  // resolve twice.
21474
22679
  pendingMentalModelProposes.delete(stageId)
22680
+ pendingCardStore.remove(stageId)
21475
22681
 
21476
22682
  const proposal: MentalModelPendingProposal = {
21477
22683
  agent: pending.agent,
@@ -21613,6 +22819,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
21613
22819
 
21614
22820
  if (action === 'deny') {
21615
22821
  pendingVaultRequestAccesses.delete(stageId)
22822
+ pendingCardStore.remove(stageId)
21616
22823
  await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
21617
22824
  if (pending.card_message_id != null) {
21618
22825
  await ctx.api
@@ -21835,6 +23042,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21835
23042
 
21836
23043
  if (action === 'discard') {
21837
23044
  pendingVaultRequestSaves.delete(stageId)
23045
+ pendingCardStore.remove(stageId)
21838
23046
  await ctx.answerCallbackQuery({ text: '🚫 Discarded' }).catch(() => {})
21839
23047
  if (pending.card_message_id != null) {
21840
23048
  await ctx.api
@@ -21905,6 +23113,43 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21905
23113
  // stale "spinning" state on the button while we run the write.
21906
23114
  await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
21907
23115
 
23116
+ // Restored-after-restart guard: the staged secret VALUE is held in gateway
23117
+ // memory only and is never persisted (secrets hygiene). If this card was
23118
+ // restored from disk after a gateway restart, the value is gone β€” we CANNOT
23119
+ // complete the write. Degrade gracefully: strip the card, wake the agent
23120
+ // with a save-failed (value-lost) synthetic so it re-requests, and stop.
23121
+ if (pending.restoredWithoutValue || pending.value.length === 0) {
23122
+ pendingVaultRequestSaves.delete(stageId)
23123
+ pendingCardStore.remove(stageId)
23124
+ if (pending.card_message_id != null) {
23125
+ await ctx.api
23126
+ .editMessageText(
23127
+ pending.chat_id,
23128
+ pending.card_message_id,
23129
+ 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._`),
23130
+ { reply_markup: { inline_keyboard: [] } },
23131
+ )
23132
+ .catch(() => {})
23133
+ }
23134
+ const lostInbound = buildVaultSaveFailedInbound({
23135
+ ctx: {
23136
+ agent: pending.agent,
23137
+ key: pending.key,
23138
+ chat_id: pending.chat_id,
23139
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
23140
+ },
23141
+ stageId,
23142
+ operatorId: senderId,
23143
+ reason: 'staged value lost to a gateway restart β€” re-request the save',
23144
+ })
23145
+ const lDelivered = deliverResumeSyntheticOrBuffer(pending.agent, lostInbound)
23146
+ process.stderr.write(
23147
+ `telegram gateway: vault_request_save value lost to restart β€” wake agent=${pending.agent} ` +
23148
+ `key=${pending.key} stage=${stageId} delivered=${lDelivered}\n`,
23149
+ )
23150
+ return
23151
+ }
23152
+
21908
23153
  // #1115 follow-up: the save-approve flow now mirrors the access-
21909
23154
  // approve flow under telegram-id mode β€” broker `put` accepts
21910
23155
  // `attest_via_posture: true` (server.ts:1448-1500), so the
@@ -21940,6 +23185,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21940
23185
  .catch(() => {})
21941
23186
  }
21942
23187
  pendingVaultRequestSaves.delete(stageId)
23188
+ pendingCardStore.remove(stageId)
21943
23189
  return
21944
23190
  }
21945
23191
  // defaultVaultWrite spawns `switchroom vault set <key>` with the
@@ -21971,6 +23217,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21971
23217
  // retry by re-invoking the same MCP tool, but the value will be
21972
23218
  // re-staged with a new ID. Drop the current stage.
21973
23219
  pendingVaultRequestSaves.delete(stageId)
23220
+ pendingCardStore.remove(stageId)
21974
23221
  // Wake the waiting agent with the failure (symmetric with the
21975
23222
  // success/discard paths) so it doesn't assume vault:<key> exists.
21976
23223
  const failReason =
@@ -21996,6 +23243,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21996
23243
 
21997
23244
  // Success β€” mask the value in the card for visual confirmation.
21998
23245
  pendingVaultRequestSaves.delete(stageId)
23246
+ pendingCardStore.remove(stageId)
21999
23247
  if (pending.card_message_id != null) {
22000
23248
  await ctx.api
22001
23249
  .editMessageText(
@@ -23888,7 +25136,7 @@ bot.on('callback_query:data', async ctx => {
23888
25136
  // sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
23889
25137
  // Parity with the text `/model sr-*` path β€” claude's native picker rejects
23890
25138
  // unknown sr-* ids, so an in-place inject can't set them. Carry the token
23891
- // across a graceful restart (the `.session-model-override` carrier) and
25139
+ // across a graceful restart (the durable `.session-model` override) and
23892
25140
  // relaunch `claude --model sr-*`. Session-only; reverts to the configured
23893
25141
  // default on the next restart. The sr-* β†’ Claude direction is handled below
23894
25142
  // via the SELECT/alias outcome + isSrToClaudeTransition.
@@ -23921,13 +25169,38 @@ bot.on('callback_query:data', async ctx => {
23921
25169
  }
23922
25170
  const didInterimSrEdit = false
23923
25171
  try {
23924
- const prevSessionModel = activeSessionModelOverride
25172
+ const prevSessionModel = sessionModelSource.getOverride()
23925
25173
  const outcome = await handleModelMenuCallback(data, modelDeps)
23926
25174
  // Record a successful session switch so /status reflects what's
23927
- // actually running. In-memory only β†’ clears when the gateway (and thus
23928
- // claude's session) restarts, exactly matching the session-only scope.
25175
+ // actually running, and persist the STICKY override
25176
+ // (reference/rfcs/session-model-stickiness.md): the canonical token
25177
+ // (never the display label) goes to the durable `.session-model`; a
25178
+ // confirmed "Default (recommended)" selection clears it instead.
23929
25179
  if (outcome.selectedModel) {
23930
- activeSessionModelOverride = outcome.selectedModel
25180
+ sessionModelSource.setOverride(outcome.selectedModel)
25181
+ const smDir = resolveAgentDirFromEnv()
25182
+ if (smDir && outcome.selectedModelToken) {
25183
+ try {
25184
+ writeSessionModelFile(
25185
+ smDir,
25186
+ outcome.selectedModelToken,
25187
+ readConfiguredDefaultModel(smDir) ??
25188
+ resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
25189
+ )
25190
+ } catch (err) {
25191
+ // The banner already promises stickiness β€” surface the failure on
25192
+ // the same card instead of only stderr.
25193
+ outcome.reply.text +=
25194
+ '\n⚠️ Couldn’t persist the sticky override β€” the switch is live now but won’t survive a relaunch.'
25195
+ process.stderr.write(
25196
+ `telegram gateway: session-model persist failed (menu): ${(err as Error)?.message ?? String(err)}\n`,
25197
+ )
25198
+ }
25199
+ }
25200
+ }
25201
+ if (outcome.clearedDefault) {
25202
+ const smDir = resolveAgentDirFromEnv()
25203
+ if (smDir) clearSessionModelFile(smDir)
23931
25204
  }
23932
25205
  // toastOnly: leave the menu untouched β€” but only if we haven't already
23933
25206
  // cleared its buttons with the interim sr-* edit. If we have, fall
@@ -23949,6 +25222,36 @@ bot.on('callback_query:data', async ctx => {
23949
25222
  { reply_markup: { inline_keyboard: [] } },
23950
25223
  )
23951
25224
  .catch(() => {})
25225
+ // Carry the requested Claude model across the restart via the SAME
25226
+ // durable `.session-model` override a Claude β†’ sr-* switch uses β€”
25227
+ // otherwise boot launches the CONFIGURED default and the tapped model
25228
+ // is silently dropped. `selectedModelToken` is a real `claude --model`
25229
+ // token (alias or full claude-* id); a "Default"-row tap yields no
25230
+ // token β†’ clear the override and boot the configured default
25231
+ // (correct). start.sh's LiteLLM-down guard only skips sr-* overrides,
25232
+ // so a Claude token is never dropped.
25233
+ {
25234
+ const agentDir = resolveAgentDirFromEnv()
25235
+ const token = outcome.selectedModelToken
25236
+ if (agentDir && token) {
25237
+ try {
25238
+ writeSessionModelFile(
25239
+ agentDir,
25240
+ token,
25241
+ readConfiguredDefaultModel(agentDir) ??
25242
+ resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
25243
+ )
25244
+ sessionModelSource.setOverride(token)
25245
+ } catch (e) {
25246
+ process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
25247
+ }
25248
+ } else if (agentDir) {
25249
+ // Default-row tap while on sr-*: the restart must land on the
25250
+ // configured default β€” a stale sticky override would resurrect
25251
+ // the old model on the next keep-relaunch.
25252
+ clearSessionModelFile(agentDir)
25253
+ }
25254
+ }
23952
25255
  // Write the restart marker so the post-restart boot card edits into this chat.
23953
25256
  writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
23954
25257
  stampUserRestartReason('user: sr-to-claude model switch (menu)')
@@ -24307,22 +25610,36 @@ bot.on('callback_query:data', async ctx => {
24307
25610
  }
24308
25611
  return undefined
24309
25612
  })()
24310
- // #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written
24311
- // by the pre-synth queue at reply time). If it's on disk, attach it
25613
+ const tok = token as string
25614
+ const sendOpts = {
25615
+ ...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
25616
+ ...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
25617
+ } as never
25618
+ const sendVerbOpts = {
25619
+ chat_id: cbChatId,
25620
+ verb: 'voice-ondemand.sendVoice',
25621
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
25622
+ }
25623
+
25624
+ // #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written by
25625
+ // the pre-synth queue at reply time). If it's on disk, attach it
24312
25626
  // immediately β€” no GPU wait. Missing/unreadable file (expired + swept,
24313
- // crash, pre-feature entry, kill-switched gateway) falls back
24314
- // transparently to the lazy synth path below.
24315
- let audio: Uint8Array | null = null
24316
- if (entry.filePath != null) {
24317
- try {
24318
- audio = readFileSync(entry.filePath)
24319
- } catch {
24320
- audio = null // swept/missing β€” lazy fallback
25627
+ // crash, pre-feature entry, kill-switched gateway) falls back transparently
25628
+ // to the lazy synth path. Only invoked when there's no reusable file_id (or
25629
+ // a stored id was rejected as stale) β€” see sendVoiceReusingFileId.
25630
+ const loadAudio = async (): Promise<Uint8Array | null> => {
25631
+ let audio: Uint8Array | null = null
25632
+ if (entry.filePath != null) {
25633
+ try {
25634
+ audio = readFileSync(entry.filePath)
25635
+ } catch {
25636
+ audio = null // swept/missing β€” lazy fallback
25637
+ }
25638
+ }
25639
+ if (audio != null) {
25640
+ await ctx.answerCallbackQuery({ text: 'πŸ”Š' }).catch(() => {})
25641
+ return audio
24321
25642
  }
24322
- }
24323
- if (audio != null) {
24324
- await ctx.answerCallbackQuery({ text: 'πŸ”Š' }).catch(() => {})
24325
- } else {
24326
25643
  await ctx.answerCallbackQuery({ text: 'πŸ”Š Synthesizing…' }).catch(() => {})
24327
25644
  // Local sidecar (kokoro) synthesis β€” same helper the immediate voice-out
24328
25645
  // path uses. On-demand is a local-engine feature; the cache is only
@@ -24332,7 +25649,7 @@ bot.on('callback_query:data', async ctx => {
24332
25649
  await ctx
24333
25650
  .answerCallbackQuery({ text: 'Voice sidecar unavailable β€” try again later.' })
24334
25651
  .catch(() => {})
24335
- return
25652
+ return null
24336
25653
  }
24337
25654
  const result = await synthesizeViaSidecar({
24338
25655
  token: sidecarToken,
@@ -24351,33 +25668,52 @@ bot.on('callback_query:data', async ctx => {
24351
25668
  await ctx
24352
25669
  .answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
24353
25670
  .catch(() => {})
24354
- return
25671
+ return null
25672
+ }
25673
+ return result.audio
25674
+ }
25675
+
25676
+ // Fast path: if we already captured a reusable file_id, ack instantly and
25677
+ // send BY the id β€” no disk read, no re-upload. First tap (no id yet) and a
25678
+ // stale-id fallback both go through loadAudio + InputFile below and refresh
25679
+ // the stored id from the returned message.
25680
+ if (entry.telegramFileId != null) {
25681
+ await ctx.answerCallbackQuery({ text: 'πŸ”Š' }).catch(() => {})
25682
+ }
25683
+ const sendResult = await sendVoiceReusingFileId({
25684
+ fileId: entry.telegramFileId ?? null,
25685
+ sendByFileId: (fid) =>
25686
+ robustApiCall(
25687
+ // allow-raw-bot-api: reuse Telegram's file_id β€” no re-upload.
25688
+ () => bot.api.sendVoice(cbChatId, fid, sendOpts),
25689
+ sendVerbOpts,
25690
+ ),
25691
+ loadAudio,
25692
+ sendByUpload: (audioOut) =>
25693
+ robustApiCall(
25694
+ // allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
25695
+ () => bot.api.sendVoice(cbChatId, new InputFile(Buffer.from(audioOut)), sendOpts),
25696
+ sendVerbOpts,
25697
+ ),
25698
+ onFileId: (fid) => voiceOnDemandCache.setTelegramFileId(tok, fid),
25699
+ log: (line) => process.stderr.write(line),
25700
+ })
25701
+
25702
+ if (!sendResult.ok) {
25703
+ // no-audio already surfaced a toast inside loadAudio; send-failed is
25704
+ // logged non-fatally β€” the 'πŸ”Š Listen' keyboard is left intact so the
25705
+ // user can retry (only a successful send strips it below).
25706
+ if (sendResult.reason === 'send-failed') {
25707
+ const err = sendResult.error
25708
+ const msg = err instanceof Error ? err.message : String(err)
25709
+ process.stderr.write(
25710
+ `telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
25711
+ )
24355
25712
  }
24356
- audio = result.audio
25713
+ return
24357
25714
  }
24358
- // Rebind as const so the closure below narrows to non-null (TS doesn't
24359
- // narrow a captured `let` inside an arrow function).
24360
- const audioOut: Uint8Array = audio
25715
+
24361
25716
  try {
24362
- // Native voice note (NOT a document), quote-replying the button's
24363
- // message.
24364
- await robustApiCall(
24365
- () =>
24366
- bot.api.sendVoice(
24367
- cbChatId,
24368
- // allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
24369
- new InputFile(Buffer.from(audioOut)),
24370
- {
24371
- ...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
24372
- ...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
24373
- } as never,
24374
- ),
24375
- {
24376
- chat_id: cbChatId,
24377
- verb: 'voice-ondemand.sendVoice',
24378
- ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
24379
- },
24380
- )
24381
25717
  // Single-use on SUCCESS: strip the 'πŸ”Š Listen' keyboard so the button
24382
25718
  // can't be re-tapped now that the audio has been delivered. Mirrors the
24383
25719
  // agent-button single_use strip (keyboardIsSingleUse) house style.
@@ -24401,7 +25737,7 @@ bot.on('callback_query:data', async ctx => {
24401
25737
  } catch (err) {
24402
25738
  const msg = err instanceof Error ? err.message : String(err)
24403
25739
  process.stderr.write(
24404
- `telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
25740
+ `telegram gateway: voice-out on-demand: strip-listen-keyboard failed (non-fatal): ${msg}\n`,
24405
25741
  )
24406
25742
  }
24407
25743
  return
@@ -24490,17 +25826,17 @@ bot.on('callback_query:data', async ctx => {
24490
25826
  process.stderr.write(
24491
25827
  `telegram gateway: button_callback chatId=${cbChatId} user=${ctx.from.id} data=${JSON.stringify(agentCb.raw)} btnText=${JSON.stringify(buttonText ?? null)}\n`,
24492
25828
  )
24493
- // Registered-keyed delivery + buffer-on-miss (same fix as the
24494
- // normal-inbound path above): broadcast()/clientCount() lost the
24495
- // tap whenever the bridge was mid-reconnect (clientCount() counts
24496
- // unregistered sockets, so the notice was suppressed AND nothing
24497
- // was actually queued). sendToAgent β†’ pendingInboundBuffer (drained
24498
- // by onClientRegistered) makes the "queued" promise real.
25829
+ // #271 turn-safety: route the tap through the SAME turn-safe delivery
25830
+ // machinery as a normal inbound (deliverButtonTapInbound) instead of a raw
25831
+ // sendToAgent. A tap landing mid-turn now buffers until idle (never strands
25832
+ // in the composer, #1556), a delivered tap is composer-cleared first and
25833
+ // tracked so the redelivery sweep rescues a strand, and a bridge-offline tap
25834
+ // still spools + shows the restart notice below (unchanged UX). The old raw
25835
+ // path (sendToAgent β†’ pendingInboundBuffer, drained by onClientRegistered)
25836
+ // fixed only the bridge-mid-reconnect drop; it never gated on turn state.
24499
25837
  const selfAgentBtn = process.env.SWITCHROOM_AGENT_NAME ?? ''
24500
- const btnDelivered = ipcServer.sendToAgent(selfAgentBtn, inboundMsg)
24501
- if (btnDelivered) markClaudeBusyForInbound(inboundMsg)
24502
- if (!btnDelivered) {
24503
- pendingInboundBuffer.push(selfAgentBtn, inboundMsg)
25838
+ const btnOutcome = await deliverButtonTapInbound(selfAgentBtn, inboundMsg)
25839
+ if (btnOutcome === 'buffered-bridge-offline') {
24504
25840
  // No registered bridge β€” the agent's mid-restart. Tell the user
24505
25841
  // so they don't think the button silently swallowed their tap;
24506
25842
  // the tap is genuinely buffered now and replays on reconnect.
@@ -24729,6 +26065,36 @@ bot.on('callback_query:data', async ctx => {
24729
26065
  process.stderr.write(
24730
26066
  `telegram gateway: always-allow hostd FAILED: ${failReason} (request_id=${request_id})\n`,
24731
26067
  )
26068
+ // #2973 pt.2 β€” enqueue for the durable retry queue UNLESS the
26069
+ // failure is non-retryable (config edits locked β€” retrying
26070
+ // won't help until the operator flips the flag; that case
26071
+ // keeps today's honest "did NOT save" card only). Everything
26072
+ // else (stale config view, transient hostd error, rate limit)
26073
+ // gets picked up by the boot/periodic drain instead of quietly
26074
+ // requiring the operator to notice and re-tap.
26075
+ if (!editLockHint) {
26076
+ try {
26077
+ await alwaysAllowPersistQueue.enqueue({
26078
+ agentName,
26079
+ rule: chosen.rule,
26080
+ grantPhrase,
26081
+ chatId: ctx.chat?.id != null ? String(ctx.chat.id) : undefined,
26082
+ threadId: (ctx.callbackQuery?.message as { message_thread_id?: number } | undefined)?.message_thread_id,
26083
+ error: failReason,
26084
+ })
26085
+ } catch (enqueueErr) {
26086
+ // The retry queue's own write failed (disk full, perms, …) β€”
26087
+ // don't pretend this landed. Fold it into the operator-facing
26088
+ // failReason so the "did NOT save" card is honest about the
26089
+ // retry mechanism ALSO having failed, not just the original
26090
+ // dispatch (#2973 adversarial review pt.2).
26091
+ const enqueueMsg = (enqueueErr as Error).message
26092
+ process.stderr.write(
26093
+ `telegram gateway: always-allow enqueue for retry FAILED: ${enqueueMsg} (request_id=${request_id})\n`,
26094
+ )
26095
+ failReason = `${failReason} (retry queue also failed to persist: ${enqueueMsg})`
26096
+ }
26097
+ }
24732
26098
  }
24733
26099
  }
24734
26100
 
@@ -26619,6 +27985,12 @@ void (async () => {
26619
27985
  }
26620
27986
  }
26621
27987
 
27988
+ // #2973 pt.2 β€” drain any always-allow persists left queued by a
27989
+ // prior gateway process (e.g. one that restarted mid-persist),
27990
+ // then keep draining periodically for the rest of this process's
27991
+ // lifetime.
27992
+ scheduleAlwaysAllowPersistDrain()
27993
+
26622
27994
  void registerSwitchroomBotCommands().catch(() => {})
26623
27995
 
26624
27996
  // #613 fix: pre-warm the chatAvailableReactions cache for every
@@ -26732,6 +28104,18 @@ void (async () => {
26732
28104
  }
26733
28105
  }
26734
28106
 
28107
+ // Restore the four agent-initiated approval-card families from the
28108
+ // durable store so a post-restart tap on a still-valid card resolves
28109
+ // normally instead of hitting the "Card expired" tombstone (and an
28110
+ // already-expired entry gets woken by the reaper's next tick).
28111
+ try {
28112
+ restorePendingApprovalCards()
28113
+ } catch (err) {
28114
+ process.stderr.write(
28115
+ `telegram gateway: pending approval-card restore failed: ${(err as Error).message}\n`,
28116
+ )
28117
+ }
28118
+
26735
28119
  // Boot-time pin sweep
26736
28120
  try {
26737
28121
  const bootAccess = loadAccess()
@@ -26903,6 +28287,7 @@ void (async () => {
26903
28287
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
26904
28288
  configSnapshotPath: join(resolvedAgentDirForBootCard, '.config-snapshot.json'),
26905
28289
  bootCardStatePath: join(resolvedAgentDirForBootCard, '.boot-card-msgid.json'),
28290
+ floodStatePath: FLOOD_STATE_PATH,
26906
28291
  ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
26907
28292
  }, ackMsgId)
26908
28293
  activeBootCard = handle
@@ -26961,8 +28346,9 @@ void (async () => {
26961
28346
  // a phantom session override.
26962
28347
  return resolveMainModel(raw ?? undefined)
26963
28348
  })()
26964
- activeSessionModelOverride =
26965
- launched.length > 0 && launched !== configured ? launched : null
28349
+ sessionModelSource.setOverride(
28350
+ launched.length > 0 && launched !== configured ? launched : null,
28351
+ )
26966
28352
  } catch { /* leave override as-is on a bad read */ }
26967
28353
  }
26968
28354
 
@@ -27276,7 +28662,7 @@ void (async () => {
27276
28662
  // Gated to background completions: foreground sub-agents
27277
28663
  // need nothing here, and 'orphan' is a stale historical-at-
27278
28664
  // boot row, not a fresh completion the user is waiting on.
27279
- onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
28665
+ onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
27280
28666
  // Reaction promotion: if the parent turn already ended
27281
28667
  // with this (or another) worker still running, its πŸ‘ was
27282
28668
  // deferred (held on ✍️/⚑). Now that a worker finished,
@@ -27307,13 +28693,32 @@ void (async () => {
27307
28693
  // (worker-feed-dispatch.ts, pinned by its test). Best-effort:
27308
28694
  // a DB hiccup keeps the watcher's generic label rather than
27309
28695
  // throwing out of the terminal handler.
27310
- let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
28696
+ let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description, entryBackground)
27311
28697
  if (turnsDb != null) {
27312
28698
  try {
27313
- dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
28699
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description, entryBackground)
27314
28700
  } catch { /* best-effort */ }
27315
28701
  }
27316
- const isBackground = dispatch.isBackground
28702
+ let isBackground = dispatch.isBackground
28703
+ // Fix #1(+#2): the registry row never linked AND the watcher
28704
+ // entry's own cached background flag was never observed
28705
+ // either (both `resolveWorkerFeedDispatch` fallbacks came up
28706
+ // empty) β€” this is the "DB row is unlinked" bug's worst
28707
+ // case. A finished worker with actual narrative result text
28708
+ // is far more likely a dropped background handback than a
28709
+ // legitimate foreground no-op (a foreground sub-agent's
28710
+ // result returns inline as the Task tool result β€” the
28711
+ // gateway wouldn't otherwise need to route anything here).
28712
+ // Degrade to background so the result is delivered instead
28713
+ // of silently lost. Idempotency: this only flips the
28714
+ // dispatch classification for THIS single onFinish call β€”
28715
+ // all the existing dedup/idempotency guards below
28716
+ // (decideSubagentHandback's spool key, completionNotified,
28717
+ // etc.) still apply unchanged, so this cannot cause a
28718
+ // double-handback.
28719
+ if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
28720
+ isBackground = true
28721
+ }
27317
28722
  // NESTED (depth-2+) worker terminal: its live status surfaced
27318
28723
  // via the worker feed (see onProgress), so finalize that card
27319
28724
  // cleanly β€” never leave it frozen mid-"β†’ step". But NO user
@@ -27330,6 +28735,10 @@ void (async () => {
27330
28735
  latestSummary: resultText,
27331
28736
  elapsedMs: durationMs,
27332
28737
  state: outcome === 'failed' ? 'failed' : 'done',
28738
+ // Persisted (registry) model β€” the last one the watcher
28739
+ // recorded from the worker's transcript β€” so the terminal
28740
+ // card keeps the model tag even with no live entry.
28741
+ model: dispatch.feedModel ?? undefined,
27333
28742
  })
27334
28743
  reconcileWorkerPin(agentId, null, false)
27335
28744
  }
@@ -27407,6 +28816,7 @@ void (async () => {
27407
28816
  latestSummary: resultText,
27408
28817
  elapsedMs: durationMs,
27409
28818
  state: outcome === 'failed' ? 'failed' : 'done',
28819
+ model: dispatch.feedModel ?? undefined,
27410
28820
  })
27411
28821
  // Status-pin: worker done β€” drop its pin.
27412
28822
  reconcileWorkerPin(agentId, null, false)
@@ -27426,6 +28836,7 @@ void (async () => {
27426
28836
  latestSummary: resultText,
27427
28837
  elapsedMs: durationMs,
27428
28838
  state: outcome === 'failed' ? 'failed' : 'done',
28839
+ model: dispatch.feedModel ?? undefined,
27429
28840
  })
27430
28841
  // Status-pin: worker done β€” drop its pin.
27431
28842
  reconcileWorkerPin(agentId, null, false)
@@ -27512,7 +28923,7 @@ void (async () => {
27512
28923
  // suppresses stale-after-restart delivery (a 4-h-old
27513
28924
  // "still working (5m)" would be a lie). Sweep on handback
27514
28925
  // lives in the `onFinish` block just above.
27515
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
28926
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
27516
28927
  let fleetChatId = ''
27517
28928
  try {
27518
28929
  const fleets = progressDriver?.peekAllFleets() ?? []
@@ -27546,6 +28957,13 @@ void (async () => {
27546
28957
  // never grew past "starting…" (the frozen-card symptom). The
27547
28958
  // foreground nest path below already used this precedence.
27548
28959
  const stepLine = (progressLine != null && progressLine.length > 0) ? progressLine : latestSummary
28960
+ // Live model for the worker card: prefer the transcript-sourced
28961
+ // model on the entry (threaded via onProgress) and fall back to
28962
+ // the dispatch-time model persisted on the registry row
28963
+ // (tool_input.model) until the worker's first assistant line
28964
+ // lands. Undefined when neither is known β€” the card omits it,
28965
+ // never guessing from config.
28966
+ const feedModel = model ?? dispatch.feedModel ?? undefined
27549
28967
  if (!isBackground) {
27550
28968
  // Model A β€” a foreground sub-agent runs inside the parent's
27551
28969
  // turn, so its live narrative nests under the parent's
@@ -27585,6 +29003,7 @@ void (async () => {
27585
29003
  latestSummary: stepLine,
27586
29004
  elapsedMs,
27587
29005
  state: 'running',
29006
+ model: feedModel,
27588
29007
  },
27589
29008
  wk.threadId,
27590
29009
  )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
@@ -27726,6 +29145,7 @@ void (async () => {
27726
29145
  latestSummary: stepLine,
27727
29146
  elapsedMs,
27728
29147
  state: 'running',
29148
+ model: feedModel,
27729
29149
  },
27730
29150
  wk.threadId,
27731
29151
  )?.then(() => reconcileWorkerPin(agentId, wkChat, true))