switchroom 0.18.9 → 0.18.10

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 (62) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +198 -13
  3. package/dist/cli/notion-write-pretool.mjs +1 -0
  4. package/dist/cli/switchroom.js +28 -4
  5. package/dist/host-control/main.js +3 -2
  6. package/dist/vault/approvals/kernel-server.js +2 -1
  7. package/dist/vault/broker/server.js +2 -1
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +119 -37
  10. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  11. package/skills/dev-protocol/SKILL.md +131 -0
  12. package/telegram-plugin/README.md +2 -1
  13. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  14. package/telegram-plugin/admin-commands/index.ts +6 -1
  15. package/telegram-plugin/bridge/bridge.ts +23 -1
  16. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  17. package/telegram-plugin/chat-lock.ts +13 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  19. package/telegram-plugin/dist/gateway/gateway.js +1831 -263
  20. package/telegram-plugin/dist/server.js +29 -2
  21. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  22. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  23. package/telegram-plugin/gateway/effort-command.ts +47 -3
  24. package/telegram-plugin/gateway/gateway.ts +1435 -211
  25. package/telegram-plugin/gateway/model-command.ts +94 -8
  26. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  27. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  28. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  29. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  30. package/telegram-plugin/gateway/stop-command.ts +56 -0
  31. package/telegram-plugin/photo-precheck.ts +201 -0
  32. package/telegram-plugin/quota-watch.ts +141 -2
  33. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  34. package/telegram-plugin/registry/subagents.test.ts +67 -0
  35. package/telegram-plugin/retry-api-call.ts +31 -0
  36. package/telegram-plugin/subagent-watcher.ts +392 -1
  37. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  38. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  39. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  40. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  41. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  42. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  43. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  44. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  46. package/telegram-plugin/tests/model-command.test.ts +46 -3
  47. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  48. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  49. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  50. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  51. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  52. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  53. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  54. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  55. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  56. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  57. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  58. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  59. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  60. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  61. package/telegram-plugin/welcome-text.ts +4 -3
  62. package/telegram-plugin/worker-activity-feed.ts +27 -0
@@ -41,6 +41,7 @@ import {
41
41
  resolveInterruptMaxWaitMs,
42
42
  resolveSafeBoundaryEnabled,
43
43
  } from './interrupt-defer.js'
44
+ import { parseStopKeyword, buildStopReply } from './stop-command.js'
44
45
  import { shouldPostBusyAck, formatBusyAckText, BUSY_ACK_STEP_AGE_THRESHOLD_MS } from './busy-ack.js'
45
46
  import {
46
47
  resolveStickerSendArgs,
@@ -123,6 +124,7 @@ import {
123
124
  approvalTtlMs,
124
125
  ttlForTool,
125
126
  buildTimedOutCardEdits,
127
+ buildCancelledCardEdits,
126
128
  STALE_TAP_NOTICE,
127
129
  type PermissionCardRef,
128
130
  } from './permission-timeout.js'
@@ -183,7 +185,9 @@ import {
183
185
  createRetryApiCall,
184
186
  createSwallowingRetryApiCall,
185
187
  retryWithThreadFallback,
188
+ isPhotoDimensionRejectError,
186
189
  } from '../retry-api-call.js'
190
+ import { classifyPhotoFile, rerouteResultSuffix } from '../photo-precheck.js'
187
191
  import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
188
192
  import { floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
189
193
  import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
@@ -387,11 +391,14 @@ import {
387
391
  MODEL_CALLBACK_PREFIX,
388
392
  MODEL_CALLBACK_HEADER,
389
393
  MODEL_CALLBACK_SR,
394
+ MODEL_CALLBACK_ALIAS,
390
395
  MODEL_CALLBACK_PAGE_EXTERNAL,
391
396
  MODEL_CALLBACK_PAGE_MAIN,
392
397
  srFriendlyLabel,
393
398
  expandSrAlias,
394
399
  isSrModel,
400
+ isBusyRefusalText,
401
+ isOfflineTrustedModelToken,
395
402
  type ModelMenuDeps,
396
403
  type ModelCommandDeps,
397
404
  type ModelMenuReply,
@@ -405,9 +412,16 @@ import {
405
412
  writeRelaunchModelIntent,
406
413
  clearRelaunchModelIntent,
407
414
  intentForRestartReason,
415
+ readSessionModelFile,
416
+ RELAUNCH_MODEL_INTENT_FILE,
417
+ GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX,
418
+ clearStaleGatewayShutdownIntent,
419
+ writeSessionEffortFile,
420
+ clearSessionEffortFile,
421
+ readSessionEffortFile,
408
422
  } from './session-model-file.js'
409
423
  import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
410
- import { resolveMainModel } from '../../src/agents/scaffold.js'
424
+ import { resolveMainModel, SWITCHROOM_DEFAULT_THINKING_EFFORT } from '../../src/agents/scaffold.js'
411
425
  import {
412
426
  parseEffortCommand,
413
427
  handleEffortCommand,
@@ -417,6 +431,18 @@ import {
417
431
  type EffortCommandDeps,
418
432
  type EffortMenuReply,
419
433
  } from './effort-command.js'
434
+ import {
435
+ createPendingSessionCommandSlots,
436
+ drainCapDecision as pendingCmdDrainCapDecision,
437
+ shutdownResolutionActions as pendingCmdShutdownResolutionActions,
438
+ resolveForRestart as pendingCmdResolveForRestart,
439
+ drainTakenCommands as pendingCmdDrainTaken,
440
+ type ShutdownResolutionAction,
441
+ ackText as pendingCmdAckText,
442
+ supersededText as pendingCmdSupersededText,
443
+ type PendingSessionCommand,
444
+ } from './pending-session-command.js'
445
+ import type { EffortLevel } from './effort-command.js'
420
446
  import { registerSwitchroomBotCommands } from './register-bot-commands.js'
421
447
  import { registerOpsInfoCommands } from './bot-commands-ops-info.js'
422
448
  import { applyEffort } from '../../src/agents/effort-picker.js'
@@ -657,7 +683,14 @@ import {
657
683
  buildQuotaClaimKey,
658
684
  QUOTA_WATCH_CLAIM_WINDOW_MS,
659
685
  isLiveCorroboration,
686
+ evaluateFleetRollAnnounce,
687
+ FLEET_ROLL_ANNOUNCE_KEY,
660
688
  } from '../quota-watch.js'
689
+ import {
690
+ createModelUnavailableCardRegistry,
691
+ decideAnnouncementDelivery,
692
+ foldAnnouncementIntoCard,
693
+ } from '../fallback-card-collapse.js'
661
694
  import { buildSnapshotsFromState, buildSnapshotsFromCachedState, zipProbeResults } from '../auth-snapshot-format.js'
662
695
  import { maskUsername } from '../demo-mask.js'
663
696
  import {
@@ -711,6 +744,13 @@ import {
711
744
  buildResumeDeferredReportInbound,
712
745
  decideBootResumeKind,
713
746
  } from './resume-inbound-builder.js'
747
+ import {
748
+ createBridgeDeadWatchdog,
749
+ consumeBridgeDeadEscalationMarker,
750
+ buildBridgeDeadIdleNoticeInbound,
751
+ DEFAULT_BRIDGE_DEAD_GRACE_MS,
752
+ } from './bridge-dead-watchdog.js'
753
+ import { findAgentProcessInContainer } from './boot-probes.js'
714
754
  import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
715
755
  import type { InterruptedSubagent } from './resume-inbound-builder.js'
716
756
  import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
@@ -966,6 +1006,24 @@ function triggerSelfRestart(
966
1006
  }
967
1007
  }
968
1008
 
1009
+ // #3018 finding 4: a gateway-only bounce (supervisor relaunch, bare gateway
1010
+ // unit restart) leaves the shutdown handler's deploy-survival keep-intent
1011
+ // stamp on disk UNCONSUMED — start.sh only runs on a container-level boot.
1012
+ // If this gateway boot still sees a gateway-shutdown-stamped intent, the
1013
+ // preceding bounce was gateway-only: clear it so a genuine crash inside the
1014
+ // 10-min freshness window can't be converted into a "keep" (crash-reverts
1015
+ // policy intact). A real container stop/deploy consumes the file in start.sh
1016
+ // before any gateway boots, so a legitimate deploy stamp is never touched;
1017
+ // triggerSelfRestart / user-slash stamps use un-prefixed reasons.
1018
+ {
1019
+ const bootSmDir = resolveAgentDirFromEnv()
1020
+ if (bootSmDir != null && clearStaleGatewayShutdownIntent(bootSmDir)) {
1021
+ process.stderr.write(
1022
+ 'telegram gateway: cleared stale gateway-shutdown relaunch-model intent (previous bounce was gateway-only — container never restarted)\n',
1023
+ )
1024
+ }
1025
+ }
1026
+
969
1027
  // Cached lazily — the claude CLI binary doesn't change inside a running
970
1028
  // gateway process; on `switchroom update` the gateway restarts, refreshing this.
971
1029
  let cachedClaudeCliVersion: string | null | undefined = undefined
@@ -1485,6 +1543,10 @@ let turnsDb: ReturnType<typeof openTurnsDb> | null = null
1485
1543
  // Stashed here; pushed to the spool once it's constructed below. The spool's
1486
1544
  // turn_key-keyed dedup makes a re-stash across multiple restarts a no-op.
1487
1545
  let bootResumeInbound: { agent: string; msg: InboundMessage } | null = null
1546
+ // #3038 cross-boot damper: consecutive bridge-dead escalations by PRIOR
1547
+ // boots (the consumed marker's `count`; 0 when no fresh marker). Set in
1548
+ // the boot block below, consumed by the watchdog constructor further down.
1549
+ let bridgeDeadPriorStreak = 0
1488
1550
  try {
1489
1551
  // STATE_DIR is `<agentDir>/telegram` in production. openTurnsDb expects
1490
1552
  // the parent (agent dir) and joins `telegram/registry.db` itself.
@@ -1540,6 +1602,32 @@ try {
1540
1602
  process.stderr.write(`telegram gateway: turn-registry initialized at ${join(agentDir, 'telegram', 'registry.db')}\n`)
1541
1603
  }
1542
1604
 
1605
+ // #3038 — bridge-dead escalation marker. If the PREVIOUS gateway bounced
1606
+ // this container because the MCP bridge died (see bridge-dead-watchdog.ts),
1607
+ // it left a marker so THIS boot's resume inbound can state the real cause
1608
+ // instead of implying an operator restart or a watchdog timeout. Consumed
1609
+ // (always cleared) whether or not a turn was in flight.
1610
+ const bridgeDeadMarker = consumeBridgeDeadEscalationMarker(
1611
+ join(STATE_DIR, 'bridge-dead-escalation.json'),
1612
+ )
1613
+ if (bridgeDeadMarker != null) {
1614
+ bridgeDeadPriorStreak = bridgeDeadMarker.count ?? 1
1615
+ process.stderr.write(
1616
+ `telegram gateway: boot: prior restart was a bridge-dead escalation (reason=${bridgeDeadMarker.reason}` +
1617
+ `, consecutive=${bridgeDeadPriorStreak}` +
1618
+ `${bridgeDeadMarker.crashTail ? `, crashTail=${bridgeDeadMarker.crashTail}` : ''})\n`,
1619
+ )
1620
+ }
1621
+ const bridgeDeadRestartCause = bridgeDeadMarker != null
1622
+ ? {
1623
+ reason: bridgeDeadMarker.reason,
1624
+ note:
1625
+ 'The framework itself triggered this restart: your Telegram MCP bridge process had died ' +
1626
+ '(chat tools were unavailable — you could not send replies), so the container was bounced ' +
1627
+ 'to restore the chat surface. This was NOT an operator-initiated restart and NOT a hang-watchdog kill.',
1628
+ }
1629
+ : undefined
1630
+
1543
1631
  // Build the boot resume/report inbound for the LATEST turn if it was
1544
1632
  // interrupted. selectResumeBuilder owns the resume-vs-report policy.
1545
1633
  const pending = findLatestTurnIfInterrupted(turnsDb)
@@ -1613,7 +1701,11 @@ try {
1613
1701
  if (bootResumeKind === 'resume') {
1614
1702
  bootResumeInbound = {
1615
1703
  agent: selfAgent,
1616
- msg: buildResumeInterruptedInbound({ turn: pending, subagents: interruptedSubagents }),
1704
+ msg: buildResumeInterruptedInbound({
1705
+ turn: pending,
1706
+ subagents: interruptedSubagents,
1707
+ restartCause: bridgeDeadRestartCause,
1708
+ }),
1617
1709
  }
1618
1710
  } else if (bootResumeKind === 'report') {
1619
1711
  // idleMs: this boot's measured marker age if it just classified this
@@ -1629,7 +1721,12 @@ try {
1629
1721
  if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
1630
1722
  bootResumeInbound = {
1631
1723
  agent: selfAgent,
1632
- msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs, subagents: interruptedSubagents }),
1724
+ msg: buildResumeWatchdogReportInbound({
1725
+ turn: pending,
1726
+ idleMs,
1727
+ subagents: interruptedSubagents,
1728
+ restartCause: bridgeDeadRestartCause,
1729
+ }),
1633
1730
  }
1634
1731
  } else if (bootResumeKind === 'defer-loop' || bootResumeKind === 'defer-suppressed') {
1635
1732
  // Passive deferred-report: work was in flight but we decline to
@@ -1641,6 +1738,7 @@ try {
1641
1738
  turn: pending,
1642
1739
  reason: bootResumeKind === 'defer-loop' ? 'loop-guard' : 'clean-restart-suppressed',
1643
1740
  subagents: interruptedSubagents,
1741
+ restartCause: bridgeDeadRestartCause,
1644
1742
  }),
1645
1743
  }
1646
1744
  }
@@ -1665,6 +1763,38 @@ try {
1665
1763
  }
1666
1764
  }
1667
1765
 
1766
+ // #3038 review finding 2 — idle-case honesty. When the previous boot was
1767
+ // a bridge-dead escalation but NO turn was interrupted (idle agent, dead
1768
+ // bridge), the consumed marker's cause would otherwise reach stderr only:
1769
+ // the agent and the user never learn why the container bounced. Surface
1770
+ // it once, via the same boot-inbound channel the resume path uses, routed
1771
+ // to the agent's default/owner chat.
1772
+ if (bridgeDeadMarker != null && bootResumeInbound == null && selfAgent) {
1773
+ const idleNoticeChat = (() => {
1774
+ try {
1775
+ return loadAccess().allowFrom[0] ?? null
1776
+ } catch {
1777
+ return null
1778
+ }
1779
+ })()
1780
+ if (idleNoticeChat != null) {
1781
+ bootResumeInbound = {
1782
+ agent: selfAgent,
1783
+ msg: buildBridgeDeadIdleNoticeInbound({
1784
+ chatId: String(idleNoticeChat),
1785
+ marker: bridgeDeadMarker,
1786
+ }),
1787
+ }
1788
+ process.stderr.write(
1789
+ `telegram gateway: boot: bridge-dead idle notice queued chat=${idleNoticeChat} (no turn was in flight)\n`,
1790
+ )
1791
+ } else {
1792
+ process.stderr.write(
1793
+ `telegram gateway: boot: bridge-dead idle notice skipped — no allowFrom chat to surface it in\n`,
1794
+ )
1795
+ }
1796
+ }
1797
+
1668
1798
  // Diagnostic env file (one-shot, sourced by start.sh) — kept for the
1669
1799
  // wake-audit context. The injected inbound above is the real wake signal;
1670
1800
  // these vars are passive context only.
@@ -2619,6 +2749,19 @@ async function deliverButtonTapInbound(
2619
2749
 
2620
2750
  const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
2621
2751
 
2752
+ // Deterministic ack-queue-apply-confirm for /model + /effort issued mid-turn
2753
+ // (#3017). One slot per kind (model|effort): same-kind last-write-wins,
2754
+ // cross-kind coexist (#3018 finding 2). Enqueued at the busy gates (typed
2755
+ // and menu), drained at the SAME model-idle gate as pendingRestarts (turn
2756
+ // complete + the reaper cap), which then EDITS the ack card into the
2757
+ // confirmation. See pending-session-command.ts for the contract.
2758
+ const pendingSessionCommand = createPendingSessionCommandSlots()
2759
+ // Bounded drain-cap for a queued /model|/effort command whose session never
2760
+ // cleanly idles — mirrors PENDING_RESTART_DRAIN_CAP_MS. After this, force the
2761
+ // apply-or-report so the ack card never dangles unresolved — but ONLY while
2762
+ // no turn is in flight (#3018 finding 1; see drainCapDecision).
2763
+ const PENDING_CMD_DRAIN_CAP_MS = 60_000
2764
+
2622
2765
  // ─── Proactive context compaction (session.max_context_tokens) ──────────
2623
2766
  //
2624
2767
  // Opt-in: when the resolved agent config sets session.max_context_tokens,
@@ -3766,6 +3909,194 @@ async function fireDeferredInterrupt(reason: 'boundary' | 'timeout'): Promise<vo
3766
3909
  }
3767
3910
  }
3768
3911
 
3912
+ // #3020 — halt-now (the operator kill switch: empty `!`, `/stop`, bare "stop").
3913
+ //
3914
+ // Unlike a `!` interrupt WITH a body (which stashes a replacement inbound in
3915
+ // `pendingDeferredInterrupt` and re-delivers it), a halt has no replacement:
3916
+ // the operator just wants the in-flight turn dead. Shared sequence:
3917
+ // 1. Honor the safe-boundary deferral (same `decideInterruptTiming` +
3918
+ // max-wait config the `!` path uses) so we don't C-c mid-tool-call.
3919
+ // 2. SIGINT via tmux send-keys (`sendAgentInterrupt`) — the same primitive
3920
+ // the `!` path fires.
3921
+ // 3. Cancel the killed turn's obligation (the user explicitly cancelled it;
3922
+ // re-presenting it later would be wrong).
3923
+ // 4. Deterministically release the turn's busy state. With no replacement
3924
+ // inbound there is no later event guaranteed to end the turn — an
3925
+ // interrupted claude turn may never emit `turn_end`, and leaning on the
3926
+ // busy-key reaper leaves the chat gated for minutes. Same primitives
3927
+ // the reply path uses (`endCurrentTurnAtomic` + `releaseTurnBufferGate`),
3928
+ // both idempotent, reaper stays as backstop. The obligation cancel in
3929
+ // step 3 runs FIRST so `endCurrentTurnAtomic`'s no-reply branch is a
3930
+ // ledger no-op instead of stamping a grace clock on a cancelled turn.
3931
+ //
3932
+ // The boundary wait is event-driven: `waitForSafeBoundary` parks a waiter
3933
+ // that the session-event ingest kicks (`notifyHaltBoundaryWaiters`) whenever
3934
+ // the tracker might have drained, with the configured max-wait as the bound.
3935
+
3936
+ const haltBoundaryWaiters = new Set<() => void>()
3937
+
3938
+ function notifyHaltBoundaryWaiters(): void {
3939
+ if (haltBoundaryWaiters.size === 0) return
3940
+ for (const check of [...haltBoundaryWaiters]) check()
3941
+ }
3942
+
3943
+ function waitForSafeBoundary(maxWaitMs: number): Promise<'boundary' | 'timeout'> {
3944
+ if (!toolFlightTracker.isMidToolCall()) return Promise.resolve('boundary')
3945
+ return new Promise(resolve => {
3946
+ let done = false
3947
+ const finish = (reason: 'boundary' | 'timeout') => {
3948
+ if (done) return
3949
+ done = true
3950
+ clearTimeout(timer)
3951
+ haltBoundaryWaiters.delete(check)
3952
+ resolve(reason)
3953
+ }
3954
+ const timer = setTimeout(() => finish('timeout'), maxWaitMs)
3955
+ const check = () => {
3956
+ if (!toolFlightTracker.isMidToolCall()) finish('boundary')
3957
+ }
3958
+ haltBoundaryWaiters.add(check)
3959
+ })
3960
+ }
3961
+
3962
+ // #3020 item 4 — a halted turn may be suspended INSIDE an MCP permission
3963
+ // call with a live Approve/Deny card in chat. The card's pending entry keeps
3964
+ // the buffer gate closed (`hasPendingApproval`), and a later Approve tap
3965
+ // would dispatch a verdict into an idle session. Deny each pending request
3966
+ // (unblocking the suspended call before/as the C-c lands) and edit its
3967
+ // card(s) to a cancelled state with the keyboard stripped.
3968
+ function cancelPendingPermissionsForHalt(origin: string): void {
3969
+ if (pendingPermissions.size === 0) return
3970
+ for (const [requestId, details] of pendingPermissions) {
3971
+ // halted-turn-verdict: the turn this card parked is being KILLED by the
3972
+ // operator — there is nothing to resume, so no resumeReactionAfterVerdict
3973
+ // / postPermissionResumeMessage pairing. The card edit ("⏹ Cancelled")
3974
+ // is the visible terminal state and the /stop reply is the operator ack.
3975
+ dispatchPermissionVerdict({
3976
+ type: 'permission',
3977
+ requestId,
3978
+ behavior: 'deny',
3979
+ message: 'Cancelled — the operator stopped this turn. Do not retry.',
3980
+ })
3981
+ void stripCancelledPermissionCards(details.card_text, details.cards)
3982
+ pendingPermissions.delete(requestId)
3983
+ permCardStore.remove(requestId)
3984
+ process.stderr.write(
3985
+ `telegram gateway: halt-now cancelled pending permission origin=${origin} ` +
3986
+ `request=${requestId} tool=${details.tool_name}\n`,
3987
+ )
3988
+ }
3989
+ }
3990
+
3991
+ async function stripCancelledPermissionCards(
3992
+ cardText: string,
3993
+ cards: PermissionCardRef[],
3994
+ ): Promise<void> {
3995
+ for (const edit of buildCancelledCardEdits(cardText, cards)) {
3996
+ await swallowingApiCall(
3997
+ // allow-raw-bot-api: routed through swallowingApiCall (retry policy); message-id-targeted edit (no thread to lose). Passing {} as opts (no reply_markup) strips the stale Allow/Deny keyboard atomically with the text edit.
3998
+ () => bot.api.editMessageText(edit.chatId, edit.messageId, richMessage(edit.text), {}),
3999
+ { chat_id: edit.chatId, verb: 'permission_halt.strip' },
4000
+ )
4001
+ }
4002
+ }
4003
+
4004
+ async function executeHaltNow(origin: string): Promise<void> {
4005
+ const agentName = process.env.SWITCHROOM_AGENT_NAME
4006
+ if (!agentName) return
4007
+ // #3020 item 3 — snapshot the halt target at request entry. The boundary
4008
+ // wait below can park for seconds; if the targeted turn ends naturally in
4009
+ // that window and a NEW turn starts, firing the C-c would kill an innocent
4010
+ // turn the requester never saw. Identity is the currentTurn object itself
4011
+ // (registryKey included, for the log line).
4012
+ const haltTarget = currentTurn
4013
+ const haltTargetKey = haltTarget?.registryKey ?? null
4014
+ const access = loadAccess()
4015
+ const timing = decideInterruptTiming({
4016
+ safeBoundaryEnabled: resolveSafeBoundaryEnabled(access.interruptSafeBoundary),
4017
+ midToolCall: toolFlightTracker.isMidToolCall(),
4018
+ })
4019
+ const startedAt = Date.now()
4020
+ if (timing === 'defer') {
4021
+ const maxWaitMs = resolveInterruptMaxWaitMs(access.interruptMaxWaitMs)
4022
+ const reason = await waitForSafeBoundary(maxWaitMs)
4023
+ process.stderr.write(
4024
+ `telegram gateway: halt-now boundary-wait origin=${origin} reason=${reason} ` +
4025
+ `waited_ms=${Date.now() - startedAt} in_flight=${toolFlightTracker.inFlightCount()}\n`,
4026
+ )
4027
+ }
4028
+ // #3020 item 3 — wrong-turn kill guard: if the turn changed while we
4029
+ // waited (the target ended naturally; a new turn may already be running),
4030
+ // no-op the C-c AND the teardown — the requester's turn already ended and
4031
+ // the current busy/permission state belongs to someone else. This also
4032
+ // skips the benign-but-pointless idle-session C-c when target-ended → idle.
4033
+ if (currentTurn !== haltTarget) {
4034
+ process.stderr.write(
4035
+ `telegram gateway: halt-now skipped — turn changed during boundary wait ` +
4036
+ `origin=${origin} target_key=${haltTargetKey ?? 'none'} ` +
4037
+ `current_key=${currentTurn?.registryKey ?? 'none'}\n`,
4038
+ )
4039
+ return
4040
+ }
4041
+ try {
4042
+ // Same tmux-direct rationale as the `!` path: the gateway runs inside the
4043
+ // agent container, so PID-probing interruptAgent can't work — send-keys can.
4044
+ const { sendAgentInterrupt } = await import('../../src/agents/tmux.js')
4045
+ const r = sendAgentInterrupt({ agentName })
4046
+ if ('ok' in r) {
4047
+ process.stderr.write(
4048
+ `telegram gateway: halt-now SIGINT delivered via tmux send-keys origin=${origin} agent=${agentName}\n`,
4049
+ )
4050
+ } else {
4051
+ process.stderr.write(
4052
+ `telegram gateway: halt-now SIGINT via tmux failed origin=${origin} agent=${agentName}: ${r.error}\n`,
4053
+ )
4054
+ }
4055
+ } catch (err) {
4056
+ process.stderr.write(`telegram gateway: halt-now SIGINT failed origin=${origin}: ${(err as Error).message}\n`)
4057
+ }
4058
+ // The SIGINT just killed the in-flight turn — cancel its obligation so the
4059
+ // cancelled question isn't re-presented/escalated later. Must run before the
4060
+ // teardown below (it reads `currentTurn`).
4061
+ cancelInterruptedObligation()
4062
+ // Item 4 — deny + strip any Approve/Deny cards belonging to the halted
4063
+ // turn so the buffer gate (`hasPendingApproval`) opens and a later tap
4064
+ // can't dispatch into an idle session.
4065
+ cancelPendingPermissionsForHalt(origin)
4066
+ // Deterministic busy release (step 4 above).
4067
+ const turn = currentTurn
4068
+ if (turn != null) {
4069
+ // Stamp the registry row as a CLEAN end (`endedVia: 'stop'`). A killed
4070
+ // turn never emits the session `turn_end` event, so without this the row
4071
+ // stays open (`ended_at IS NULL`) and `findLatestTurnIfInterrupted` would
4072
+ // fire a resume_interrupted synthetic for it at the next boot — resuming
4073
+ // work the operator explicitly cancelled. 'stop' is the resume gate's
4074
+ // "ended cleanly" verdict, which is exactly what a deliberate cancel is.
4075
+ if (turnsDb != null && turn.registryKey != null) {
4076
+ const _turnKey = turn.registryKey
4077
+ try {
4078
+ recordTurnEnd(turnsDb, {
4079
+ turnKey: _turnKey,
4080
+ endedVia: 'stop' as const,
4081
+ lastAssistantMsgId: turn.lastAssistantMsgId,
4082
+ lastAssistantDone: turn.lastAssistantDone,
4083
+ toolCallCount: turn.toolCallCount,
4084
+ })
4085
+ } catch (err) {
4086
+ process.stderr.write(`telegram gateway: recordTurnEnd(halt) failed turnKey=${_turnKey}: ${(err as Error).message}\n`)
4087
+ }
4088
+ }
4089
+ const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
4090
+ endCurrentTurnAtomic(turn)
4091
+ releaseTurnBufferGate(key, turn)
4092
+ }
4093
+ // #3020 item 5 — the busy release above may have opened the idle gate, and
4094
+ // no turn_end event follows a halt to trigger the usual idle-drain hook.
4095
+ // Kick the queued session-command drain now so a queued /model or /effort
4096
+ // applies promptly instead of waiting for the 60s reaper.
4097
+ if (!turnInFlightForGate()) void drainPendingSessionCommand()
4098
+ }
4099
+
3769
4100
  // #549 fix — preamble suppression for the answer-stream path.
3770
4101
  //
3771
4102
  // Background: assistant text emitted before a tool_use is "preamble"
@@ -4103,6 +4434,13 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
4103
4434
  // serialize gate): a pending self-restart or proactive compaction must
4104
4435
  // fire when claude is idle regardless of whether the last turn replied.
4105
4436
  if (!turnInFlightForGate()) {
4437
+ // Apply any /model|/effort command queued mid-turn (#3017) BEFORE the
4438
+ // restart drain reads the pending-restart map — drainPendingSessionCommand
4439
+ // itself checks pendingRestarts and, when a restart is also pending,
4440
+ // reports "restarting" on the ack card instead of a false confirmation.
4441
+ // The sr-*→Claude menu apply may ITSELF enqueue a restart, which the
4442
+ // restart drain below then picks up. Async + best-effort (own try/catch).
4443
+ void drainPendingSessionCommand()
4106
4444
  if (pendingRestarts.size > 0) {
4107
4445
  for (const [agentName, _timestamp] of pendingRestarts.entries()) {
4108
4446
  triggerSelfRestart(agentName, 'turn-complete-pending-restart');
@@ -6389,6 +6727,34 @@ const pendingStateReaper = setInterval(() => {
6389
6727
  triggerSelfRestart(agentName, 'restart-drain-cap-forced', 100)
6390
6728
  }
6391
6729
  }
6730
+ // Drain cap for queued /model|/effort commands (#3017): if the session
6731
+ // never cleanly idled, force the apply-or-report so the ack card never
6732
+ // dangles unresolved. drainPendingSessionCommand no-ops on an idle-gate race
6733
+ // (its own re-entrancy guard) and reports honestly when a restart is pending.
6734
+ // #3018 finding 1: NEVER force while a turn is genuinely in flight — a
6735
+ // forced mid-turn drain hits the typed model handler's busy gate (edits the
6736
+ // ack card into the old "try again" refusal, choice destroyed) and /effort
6737
+ // has no busy gate at all (would type into the live claude input). The cap
6738
+ // only rescues the missed-idle-gate case; mid-turn we keep waiting and the
6739
+ // idle gate drains at turn end.
6740
+ const queuedCmds = pendingSessionCommand.list()
6741
+ const cmdDecision = pendingCmdDrainCapDecision(
6742
+ queuedCmds,
6743
+ now,
6744
+ PENDING_CMD_DRAIN_CAP_MS,
6745
+ turnInFlightForGate(),
6746
+ )
6747
+ if (cmdDecision === 'defer-turn-in-flight') {
6748
+ process.stderr.write(
6749
+ `telegram gateway: [pending-cmd-drain] deferred reason=turn-in-flight queued=${queuedCmds.map(c => c.kind).join(',')}\n`,
6750
+ )
6751
+ } else if (cmdDecision === 'force') {
6752
+ const oldest = Math.min(...queuedCmds.map(c => c.requestedAt))
6753
+ process.stderr.write(
6754
+ `telegram gateway: [pending-cmd-drain] forcing queued=${queuedCmds.map(c => `${c.kind}:${c.targetLabel}`).join(',')} waited=${Math.round((now - oldest) / 1000)}s threshold=${Math.round(PENDING_CMD_DRAIN_CAP_MS / 1000)}s\n`,
6755
+ )
6756
+ void drainPendingSessionCommand()
6757
+ }
6392
6758
  }, 60_000)
6393
6759
  pendingStateReaper.unref()
6394
6760
 
@@ -6560,6 +6926,10 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
6560
6926
  const modelUnavailable = resolveModelUnavailableFromOperatorEvent(event)
6561
6927
  let renderedText: string
6562
6928
  let renderedKeyboard: ReturnType<typeof renderOperatorEvent>['keyboard'] | undefined
6929
+ // #3031 PR 3 — when the card promises an in-flight auto-failover, record
6930
+ // each per-chat send in the collapse registry so a SUCCESSFUL swap edits
6931
+ // the card (single evolving message) instead of sending a second one.
6932
+ let cardPromisedFallback = false
6563
6933
  if (modelUnavailable) {
6564
6934
  // Two questions, asked synchronously to avoid the "card promises
6565
6935
  // an announcement that never arrives" trap:
@@ -6584,6 +6954,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
6584
6954
  autoFallbackInFlight: willActuallyFire,
6585
6955
  })
6586
6956
  renderedKeyboard = undefined
6957
+ cardPromisedFallback = willActuallyFire
6587
6958
  // Trigger fleet-wide auto-fallback. Pre-fix this branch only
6588
6959
  // rendered the card; the fallback machinery was unreachable from
6589
6960
  // here. We fire-and-forget so card delivery is never blocked on
@@ -6654,11 +7025,26 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
6654
7025
  // very next line is what unlocks the raw bot.api call.
6655
7026
  // Opts now includes message_thread_id when supergroup mode is on.
6656
7027
  // allow-raw-bot-api: operator-event broadcast loop; topic-aware opts
6657
- void bot.api.sendRichMessage(chat_id, richMessage(renderedText), opts as never).catch(e => {
6658
- process.stderr.write(
6659
- `telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
6660
- )
6661
- })
7028
+ void bot.api.sendRichMessage(chat_id, richMessage(renderedText), opts as never)
7029
+ .then((sent: { message_id: number }) => {
7030
+ // #3031 PR 3 remember the fallback-promising card so the swap
7031
+ // announcement can EDIT it (collapse) instead of sending anew.
7032
+ // Recorded only after the send resolves (we need the message_id);
7033
+ // the collapse path degrades to a plain send when no record exists.
7034
+ if (cardPromisedFallback) {
7035
+ modelUnavailableCardRegistry.record(String(chat_id), {
7036
+ messageId: sent.message_id,
7037
+ text: renderedText,
7038
+ atMs: Date.now(),
7039
+ promisedFallback: true,
7040
+ })
7041
+ }
7042
+ })
7043
+ .catch(e => {
7044
+ process.stderr.write(
7045
+ `telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
7046
+ )
7047
+ })
6662
7048
  }
6663
7049
  }
6664
7050
 
@@ -8789,6 +9175,51 @@ async function stripStalePermissionCard(card: PersistedPermCard): Promise<void>
8789
9175
  }
8790
9176
  }
8791
9177
 
9178
+ // ─── #3038 — bridge-dead watchdog ────────────────────────────────────────
9179
+ // When the gateway (re)starts, the MCP bridge inside the running claude
9180
+ // session normally re-registers on the IPC socket within seconds. If it
9181
+ // died with the previous gateway (Claude Code never respawns a dead MCP
9182
+ // server), the session stays alive but toolless/mute forever. This
9183
+ // watchdog escalates: no real bridge registered within the grace window
9184
+ // while claude is alive → bounce the container once (reason
9185
+ // 'bridge-dead-resume') so the MCP server respawns. See
9186
+ // bridge-dead-watchdog.ts for the guard rails.
9187
+ // Config: SWITCHROOM_BRIDGE_DEAD_GRACE_MS (default 90s),
9188
+ // SWITCHROOM_BRIDGE_DEAD_ESCALATION=0 disables.
9189
+ const BRIDGE_DEAD_ESCALATION_ENABLED = process.env.SWITCHROOM_BRIDGE_DEAD_ESCALATION !== '0'
9190
+ const BRIDGE_DEAD_GRACE_MS = (() => {
9191
+ const v = Number(process.env.SWITCHROOM_BRIDGE_DEAD_GRACE_MS)
9192
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_BRIDGE_DEAD_GRACE_MS
9193
+ })()
9194
+ const bridgeDeadWatchdog = createBridgeDeadWatchdog({
9195
+ graceMs: BRIDGE_DEAD_GRACE_MS,
9196
+ // Require a CONFIDENT claude match (comm === 'claude'), not
9197
+ // findAgentProcessInContainer's heaviest-node fallback: an orphaned or
9198
+ // unrelated node process must never flip a "claude not up" retry into a
9199
+ // container bounce (#3038 review finding 4). False negatives are safe —
9200
+ // the watchdog then retries forever instead of escalating.
9201
+ isSessionAlive: () => findAgentProcessInContainer()?.comm === 'claude',
9202
+ // `shuttingDown` is declared (let, module scope) further down this file;
9203
+ // the closure only runs when the grace timer fires, long after module
9204
+ // init completes, so the TDZ is never hit.
9205
+ isShuttingDown: () => shuttingDown,
9206
+ escalate: (reason) =>
9207
+ triggerSelfRestart(process.env.SWITCHROOM_AGENT_NAME ?? '', reason, 1500),
9208
+ crashLogPath: join(STATE_DIR, 'bridge-crash.log'),
9209
+ markerPath: join(STATE_DIR, 'bridge-dead-escalation.json'),
9210
+ log: (line) => process.stderr.write(`${line}\n`),
9211
+ // Cross-boot damper (#3038 review finding 1): the consumed marker's
9212
+ // consecutive-escalation count. At the cap, arm() stands down loudly
9213
+ // instead of restart-looping a deterministically-failing bridge.
9214
+ priorStreak: bridgeDeadPriorStreak,
9215
+ })
9216
+ if (BRIDGE_DEAD_ESCALATION_ENABLED) {
9217
+ bridgeDeadWatchdog.arm()
9218
+ process.stderr.write(
9219
+ `telegram gateway: [bridge-dead-watchdog] armed (grace=${BRIDGE_DEAD_GRACE_MS}ms)\n`,
9220
+ )
9221
+ }
9222
+
8792
9223
  const ipcServer: IpcServer = createIpcServer({
8793
9224
  socketPath: SOCKET_PATH,
8794
9225
 
@@ -8818,6 +9249,13 @@ const ipcServer: IpcServer = createIpcServer({
8818
9249
  const bridgeUpEffects = client.agentName != null
8819
9250
  ? shadowEmit({ kind: 'bridgeUp', at: Date.now() })
8820
9251
  : []
9252
+ // #3038 — a REAL (named, non-cron) bridge registered: stand the
9253
+ // bridge-dead watchdog down. Anonymous clients (recall.py, mcp
9254
+ // handshakes) and cron-session bridges must NOT satisfy it — the
9255
+ // watchdog gates on the identity INTERNALLY (isRealBridgeIdentity), so
9256
+ // this call is safe wherever it sits relative to the cron early-return
9257
+ // above (#3038 review finding 5).
9258
+ bridgeDeadWatchdog.noteBridgeRegistered(client.agentName)
8821
9259
  client.send({ type: 'status', status: 'agent_connected' })
8822
9260
 
8823
9261
  // Phase 2b PR 3a — bridgeUp cutover. The state machine's `bridgeUp`
@@ -9007,6 +9445,12 @@ const ipcServer: IpcServer = createIpcServer({
9007
9445
  if (client.agentName != null) {
9008
9446
  process.stderr.write(`telegram gateway: bridge disconnected — agent=${client.agentName}\n`)
9009
9447
  shadowEmit({ kind: 'bridgeDown', at: Date.now() })
9448
+ // #3038 — the real bridge went away mid-life. Re-arm the grace
9449
+ // window: a normal claude restart re-registers within seconds and
9450
+ // stands it down; a bridge that died for good escalates once (the
9451
+ // once-per-boot fuse inside the watchdog caps it). Cron/anonymous
9452
+ // identities are ignored inside the watchdog itself (finding 5).
9453
+ if (BRIDGE_DEAD_ESCALATION_ENABLED) bridgeDeadWatchdog.noteBridgeDisconnected(client.agentName)
9010
9454
  }
9011
9455
 
9012
9456
  // Scope the flush to clients that actually registered as an agent.
@@ -9094,6 +9538,9 @@ const ipcServer: IpcServer = createIpcServer({
9094
9538
  if (pendingDeferredInterrupt != null && !toolFlightTracker.isMidToolCall()) {
9095
9539
  void fireDeferredInterrupt('boundary')
9096
9540
  }
9541
+ // #3020: a parked halt-now (/stop, bare "stop", empty `!`) waits on the
9542
+ // same boundary — kick its waiters whenever the tracker may have drained.
9543
+ notifyHaltBoundaryWaiters()
9097
9544
  // #1122 silence-poke: surface activity signals from the session
9098
9545
  // stream so the 300s framework-fallback message wording is honest
9099
9546
  // (thinking vs working, plus the longest-running in-flight tool).
@@ -11711,35 +12158,111 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
11711
12158
  }
11712
12159
  }
11713
12160
 
12161
+ // #3033 layer-2 (pre-send validation): probe each photo-extension file
12162
+ // BEFORE it hits the wire and pre-route any that Telegram's photo path
12163
+ // would reject (extreme aspect ratio, width+height over cap, >10MB) as
12164
+ // documents. One bad photo fails a WHOLE sendMediaGroup album with an
12165
+ // opaque 400 — catching it here keeps the good files deliverable and
12166
+ // sends the offender as a document on the first attempt. Probe
12167
+ // failures keep the photo route; the reactive #3022 fallback backstops.
12168
+ const photoPrecheck = new Map<string, ReturnType<typeof classifyPhotoFile>>()
12169
+ // #3038 polish: files that ultimately went out as documents despite a
12170
+ // photo extension (precheck reroute or reactive fallback). Feeds two
12171
+ // honesty surfaces: the reply tool result suffix (so the agent doesn't
12172
+ // claim an inline image rendered) and attachment_kinds history (record
12173
+ // what was actually sent).
12174
+ const documentReroutes: Array<{ path: string; reason: string }> = []
12175
+ const sentAsDocument = new Set<string>()
12176
+ for (const f of files) {
12177
+ if (!PHOTO_EXTS.has(extname(f).toLowerCase())) continue
12178
+ const cls = classifyPhotoFile(f)
12179
+ photoPrecheck.set(f, cls)
12180
+ if (cls.route === 'document') {
12181
+ documentReroutes.push({ path: f, reason: cls.reason })
12182
+ sentAsDocument.add(f)
12183
+ process.stderr.write(
12184
+ `telegram gateway: photo-precheck rerouting ${f} as document (${cls.reason})\n`,
12185
+ )
12186
+ }
12187
+ }
12188
+ const sendableAsPhoto = (f: string) =>
12189
+ PHOTO_EXTS.has(extname(f).toLowerCase()) && photoPrecheck.get(f)?.route !== 'document'
12190
+
11714
12191
  // #273: when files is 2-10 photos, batch them into a single
11715
12192
  // sendMediaGroup album rather than N separate sendPhoto calls. The
11716
12193
  // user's device fires one notification for the album instead of N
11717
12194
  // (notification-budget protection per the issue's JTBD note). Falls
11718
12195
  // back to the per-file path for any non-all-photo set.
12196
+ //
12197
+ // #3038 known tradeoff: ONE precheck-rerouted photo in the set drops
12198
+ // the WHOLE album to per-file sends — the user gets N notifications
12199
+ // instead of 1. Deliberate: sendMediaGroup can't mix photo and
12200
+ // document media, and a partial album plus a stray document is more
12201
+ // confusing than N files. Revisit only if mixed albums become common.
11719
12202
  const allPhotos = files.length >= 2 && files.length <= 10
11720
- && files.every((f) => PHOTO_EXTS.has(extname(f).toLowerCase()))
12203
+ && files.every(sendableAsPhoto)
11721
12204
  // #1075: thread-id-bearing file sends. Mirror the chunk-loop's
11722
12205
  // THREAD_NOT_FOUND fallback (deleted topic → drop the thread and
11723
12206
  // resend on the main chat) so an attachment-bearing reply doesn't
11724
12207
  // crash when the user deletes the topic mid-flight.
11725
12208
  const replyParams =
11726
12209
  reply_to != null && replyMode !== 'off' ? { reply_parameters: { message_id: reply_to } } : {}
11727
- if (allPhotos) {
11728
- const media = files.map((f) => ({
11729
- type: 'photo' as const,
11730
- media: new InputFile(f),
11731
- }))
11732
- const sent = await retryWithThreadFallback(
12210
+ // Send one file as a document, routed through the same thread-fallback
12211
+ // policy as the photo path. `InputFile` streams are single-use, so a
12212
+ // document retry after a failed sendPhoto must build a FRESH InputFile
12213
+ // from the path. Returns the sent message (with its echoed thread id).
12214
+ const sendAsDocument = (f: string) =>
12215
+ retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
11733
12216
  robustApiCall,
11734
12217
  (tid) => {
11735
12218
  const baseOpts = {
11736
12219
  ...replyParams,
11737
12220
  ...(tid != null ? { message_thread_id: tid } : {}),
11738
12221
  }
11739
- return lockedBot.api.sendMediaGroup(chat_id, media, baseOpts)
12222
+ // allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware document fallback
12223
+ return lockedBot.api.sendDocument(chat_id, new InputFile(f), baseOpts)
11740
12224
  },
11741
- { threadId, chat_id, verb: 'sendMediaGroup' },
12225
+ { threadId, chat_id, verb: 'sendDocument' },
11742
12226
  )
12227
+
12228
+ if (allPhotos) {
12229
+ const media = files.map((f) => ({
12230
+ type: 'photo' as const,
12231
+ media: new InputFile(f),
12232
+ }))
12233
+ let sent: Array<{ message_id: number; message_thread_id?: number }>
12234
+ try {
12235
+ sent = await retryWithThreadFallback(
12236
+ robustApiCall,
12237
+ (tid) => {
12238
+ const baseOpts = {
12239
+ ...replyParams,
12240
+ ...(tid != null ? { message_thread_id: tid } : {}),
12241
+ }
12242
+ return lockedBot.api.sendMediaGroup(chat_id, media, baseOpts)
12243
+ },
12244
+ { threadId, chat_id, verb: 'sendMediaGroup' },
12245
+ )
12246
+ } catch (err) {
12247
+ // Graceful fallback: one bad photo in the album (e.g. a tall phone
12248
+ // screenshot → PHOTO_INVALID_DIMENSIONS) fails the WHOLE media group.
12249
+ // Rather than surface an error, re-send each file individually as a
12250
+ // document so the user still receives all of them. See #klanker
12251
+ // 2026-07-10 incident + isPhotoDimensionRejectError.
12252
+ if (!isPhotoDimensionRejectError(err)) throw err
12253
+ process.stderr.write(
12254
+ `telegram gateway: sendMediaGroup rejected the photo album ` +
12255
+ `(${err instanceof Error ? err.message : String(err)}); ` +
12256
+ `falling back to per-file sendDocument\n`,
12257
+ )
12258
+ sent = []
12259
+ const albumReason = 'Telegram rejected the photo album; whole album re-sent as documents'
12260
+ for (const f of files) {
12261
+ sent.push(await sendAsDocument(f))
12262
+ sentAsDocument.add(f)
12263
+ documentReroutes.push({ path: f, reason: albumReason })
12264
+ }
12265
+ }
11743
12266
  if (threadId != null) {
11744
12267
  // If the fallback dropped the thread id, propagate that decision
11745
12268
  // to subsequent calls in this reply (no further retries needed).
@@ -11752,22 +12275,41 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
11752
12275
  for (const m of sent) sentIds.push(m.message_id)
11753
12276
  } else {
11754
12277
  for (const f of files) {
11755
- const ext = extname(f).toLowerCase()
11756
12278
  const input = new InputFile(f)
11757
- const isPhoto = PHOTO_EXTS.has(ext)
11758
- const sent = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
11759
- robustApiCall,
11760
- (tid) => {
11761
- const baseOpts = {
11762
- ...replyParams,
11763
- ...(tid != null ? { message_thread_id: tid } : {}),
11764
- }
11765
- return isPhoto
11766
- ? lockedBot.api.sendPhoto(chat_id, input, baseOpts)
11767
- : lockedBot.api.sendDocument(chat_id, input, baseOpts)
11768
- },
11769
- { threadId, chat_id, verb: isPhoto ? 'sendPhoto' : 'sendDocument' },
11770
- )
12279
+ // Photo-ext files that failed the pre-send probe route straight to
12280
+ // sendDocument (see photoPrecheck above) instead of bouncing a 400.
12281
+ const isPhoto = sendableAsPhoto(f)
12282
+ let sent: { message_id: number; message_thread_id?: number }
12283
+ try {
12284
+ sent = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
12285
+ robustApiCall,
12286
+ (tid) => {
12287
+ const baseOpts = {
12288
+ ...replyParams,
12289
+ ...(tid != null ? { message_thread_id: tid } : {}),
12290
+ }
12291
+ return isPhoto
12292
+ ? lockedBot.api.sendPhoto(chat_id, input, baseOpts)
12293
+ : lockedBot.api.sendDocument(chat_id, input, baseOpts)
12294
+ },
12295
+ { threadId, chat_id, verb: isPhoto ? 'sendPhoto' : 'sendDocument' },
12296
+ )
12297
+ } catch (err) {
12298
+ // Graceful fallback: an image Telegram won't accept as a photo
12299
+ // (dimensions out of range / too large — a tall phone screenshot
12300
+ // is the canonical trigger, PHOTO_INVALID_DIMENSIONS) is re-sent
12301
+ // as a document so the user still receives the file instead of
12302
+ // getting nothing. Non-photo-dimension errors propagate as before.
12303
+ if (!(isPhoto && isPhotoDimensionRejectError(err))) throw err
12304
+ process.stderr.write(
12305
+ `telegram gateway: sendPhoto rejected ${f} ` +
12306
+ `(${err instanceof Error ? err.message : String(err)}); ` +
12307
+ `falling back to sendDocument\n`,
12308
+ )
12309
+ sent = await sendAsDocument(f)
12310
+ sentAsDocument.add(f)
12311
+ documentReroutes.push({ path: f, reason: 'Telegram rejected it as a photo; re-sent as document' })
12312
+ }
11771
12313
  // Mirror the threadId-clear above so the *next* file in the
11772
12314
  // loop skips the doomed thread without paying for another
11773
12315
  // round trip + retry.
@@ -11778,9 +12320,13 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
11778
12320
  }
11779
12321
  }
11780
12322
 
11781
- const result = sentIds.length === 1
12323
+ // #3038: surface photo→document reroutes in the tool result — the
12324
+ // agent otherwise only "sees" success and may tell the user an inline
12325
+ // image rendered when it went out as a file attachment.
12326
+ const result = (sentIds.length === 1
11782
12327
  ? `sent (id: ${sentIds[0]})`
11783
- : `sent ${sentIds.length} parts (ids: ${sentIds.join(', ')})`
12328
+ : `sent ${sentIds.length} parts (ids: ${sentIds.join(', ')})`)
12329
+ + rerouteResultSuffix(documentReroutes)
11784
12330
 
11785
12331
  if (HISTORY_ENABLED && sentIds.length > 0) {
11786
12332
  try {
@@ -11790,9 +12336,14 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
11790
12336
  const attachKinds: (string | null)[] = []
11791
12337
  for (let i = 0; i < textCount; i++) { texts.push(chunks[i] ?? ''); attachKinds.push(null) }
11792
12338
  for (let i = 0; i < fileCount; i++) {
11793
- const ext = extname(files[i] ?? '').toLowerCase()
11794
- texts.push(`(${PHOTO_EXTS.has(ext) ? 'photo' : 'document'}: ${files[i]})`)
11795
- attachKinds.push(PHOTO_EXTS.has(ext) ? 'photo' : 'document')
12339
+ const f = files[i] ?? ''
12340
+ const ext = extname(f).toLowerCase()
12341
+ // #3038: record what was ACTUALLY sent — a photo-extension file
12342
+ // rerouted to sendDocument (precheck or reactive fallback) is a
12343
+ // 'document' in history, not a 'photo' from its raw extension.
12344
+ const kind = PHOTO_EXTS.has(ext) && !sentAsDocument.has(f) ? 'photo' : 'document'
12345
+ texts.push(`(${kind}: ${f})`)
12346
+ attachKinds.push(kind)
11796
12347
  }
11797
12348
  recordOutbound({ chat_id, thread_id: threadId ?? null, message_ids: sentIds, texts, attachment_kinds: attachKinds })
11798
12349
  } catch (err) {
@@ -16334,6 +16885,16 @@ async function handleInboundCoalesced(
16334
16885
  return handleInbound(ctx, text, downloadImage, attachment)
16335
16886
  }
16336
16887
 
16888
+ // #3020: bare "stop" is a kill switch, same urgency as `!` — bypass the
16889
+ // coalesce window so it fires mid-turn instead of merging into a normal
16890
+ // turn (and so an earlier buffered message can't prepend itself and defeat
16891
+ // the exact-word parse in handleInbound). Pure text only: a photo/document
16892
+ // captioned "stop" is content for the agent (e.g. a stop sign to look at),
16893
+ // not a halt request — attachments flow through the normal pipeline.
16894
+ if (parseStopKeyword(text) && downloadImage == null && attachment == null) {
16895
+ return handleInbound(ctx, text, downloadImage, attachment)
16896
+ }
16897
+
16337
16898
  const hasAttachment = downloadImage != null || attachment != null
16338
16899
  const maxAttachments = coalesceMaxAttachments()
16339
16900
 
@@ -16641,11 +17202,58 @@ async function handleInbound(
16641
17202
  // Authorization: same allowFrom gate as any inbound message —
16642
17203
  // unauthorized senders never reach this code (gate() above).
16643
17204
  // Interrupt requires the same trust as sending a normal message.
17205
+ // #3020: bare operator "stop" — the kill switch without the `!` marker.
17206
+ // Exact-word only (parseStopKeyword): "stop the build" flows to the agent
17207
+ // as a normal turn. Intercepted here (never forwarded) so it fires mid-turn
17208
+ // instead of queueing behind the very turn it's trying to cancel.
17209
+ // Authorization: same allowFrom gate as any inbound (gate() above).
17210
+ // Pure text only (7b): a photo/attachment captioned "stop" is content for
17211
+ // the agent, not a halt request — it must never trip the halt-and-drop
17212
+ // path (the caption + attachment flow through as a normal turn).
17213
+ if (
17214
+ parseStopKeyword(text) &&
17215
+ downloadImage == null &&
17216
+ attachment == null &&
17217
+ (extraAttachments == null || extraAttachments.length === 0)
17218
+ ) {
17219
+ const queuedLabels = pendingSessionCommand.list().map(c => `/${c.kind} ${c.targetLabel}`)
17220
+ const inFlight = turnInFlightForGate()
17221
+ process.stderr.write(
17222
+ `telegram gateway: stop-keyword received chat_id=${chat_id} in_flight_turn=${inFlight} ` +
17223
+ `queued_cmds=${queuedLabels.length}\n`,
17224
+ )
17225
+ if (inFlight) {
17226
+ if (msgId != null) {
17227
+ void bot.api.setMessageReaction(chat_id, msgId, [
17228
+ { type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
17229
+ ]).catch(() => {})
17230
+ }
17231
+ await executeHaltNow('stop-keyword')
17232
+ }
17233
+ const stopReply = buildStopReply(inFlight, queuedLabels)
17234
+ // #1075: thread-id-bearing — swallow so a deleted topic can't crash us.
17235
+ await swallowingApiCall(
17236
+ () =>
17237
+ bot.api.sendMessage(
17238
+ chat_id,
17239
+ stopReply.text,
17240
+ messageThreadId != null ? { message_thread_id: messageThreadId } : {},
17241
+ ),
17242
+ {
17243
+ chat_id,
17244
+ verb: 'stop-keyword-reply',
17245
+ ...(messageThreadId != null ? { threadId: messageThreadId } : {}),
17246
+ },
17247
+ )
17248
+ return
17249
+ }
17250
+
16644
17251
  const interrupt = parseInterruptMarker(text)
16645
17252
  // Problem B: defer this `!`'s SIGINT to a safe boundary instead of firing it
16646
17253
  // synchronously below. Set only when the `interrupt.safe_boundary` flag is on
16647
17254
  // AND a top-level tool call is in flight AND the body is non-empty (an empty
16648
- // `!` is an explicit halt-now and stays immediate). When set, we skip the
17255
+ // `!` is a halt-now routed through `executeHaltNow`, which honors the same
17256
+ // safe-boundary deferral internally, #3020). When set, we skip the
16649
17257
  // synchronous SIGINT here and stash the built inbound at the delivery site.
16650
17258
  let deferInterrupt = false
16651
17259
  if (interrupt.isInterrupt) {
@@ -16667,6 +17275,29 @@ async function handleInbound(
16667
17275
  { type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
16668
17276
  ]).catch(() => {})
16669
17277
  }
17278
+ if (interrupt.emptyBody) {
17279
+ // #3020: empty `!` is a pure halt (no replacement body) — same shared
17280
+ // sequence as /stop: safe-boundary deferral, tmux C-c, obligation
17281
+ // cancel, deterministic busy release.
17282
+ await executeHaltNow('bang-empty')
17283
+ // #1075: thread-id-bearing — route through swallowingApiCall so
17284
+ // a deleted topic doesn't crash the gateway; the reaction
17285
+ // already acked the user so a missing follow-up is tolerable.
17286
+ await swallowingApiCall(
17287
+ () =>
17288
+ bot.api.sendMessage(
17289
+ chat_id,
17290
+ '⚡ Interrupted. Send your replacement instruction now.',
17291
+ messageThreadId != null ? { message_thread_id: messageThreadId } : {},
17292
+ ),
17293
+ {
17294
+ chat_id,
17295
+ verb: 'interrupt-empty-body',
17296
+ ...(messageThreadId != null ? { threadId: messageThreadId } : {}),
17297
+ },
17298
+ )
17299
+ return
17300
+ }
16670
17301
  if (agentName && !deferInterrupt) {
16671
17302
  try {
16672
17303
  // The gateway runs INSIDE the agent container in docker mode,
@@ -16696,25 +17327,6 @@ async function handleInbound(
16696
17327
  // interrupted (user-redirected) question isn't re-presented/escalated later.
16697
17328
  cancelInterruptedObligation()
16698
17329
  }
16699
- if (interrupt.emptyBody) {
16700
- // #1075: thread-id-bearing — route through swallowingApiCall so
16701
- // a deleted topic doesn't crash the gateway; the reaction
16702
- // already acked the user so a missing follow-up is tolerable.
16703
- await swallowingApiCall(
16704
- () =>
16705
- bot.api.sendMessage(
16706
- chat_id,
16707
- '⚡ Interrupted. Send your replacement instruction now.',
16708
- messageThreadId != null ? { message_thread_id: messageThreadId } : {},
16709
- ),
16710
- {
16711
- chat_id,
16712
- verb: 'interrupt-empty-body',
16713
- ...(messageThreadId != null ? { threadId: messageThreadId } : {}),
16714
- },
16715
- )
16716
- return
16717
- }
16718
17330
  // Replace the inbound text with the body and continue normal
16719
17331
  // processing. The agent receives a fresh turn with no `!` prefix.
16720
17332
  text = interrupt.body
@@ -19866,62 +20478,406 @@ function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined
19866
20478
  return kb
19867
20479
  }
19868
20480
 
19869
- bot.command('model', async ctx => {
19870
- if (!isAuthorizedSender(ctx)) return
19871
- const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
19872
- const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
19873
- const chatId = String(ctx.chat!.id)
19874
- const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
19875
- const deps = buildModelDeps({ chatId, threadId })
19876
- if (parsed.kind === 'show' && process.env.SWITCHROOM_MODEL_MENU !== '0') {
19877
- const menu = await buildModelMenu(deps)
19878
- await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
19879
- return
19880
- }
19881
- const reply = await handleModelCommand(parsed, deps)
19882
- // Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
19883
- // actually running — the SAME in-memory override the menu callback path sets
19884
- // (buildAgentMetadata resolves it via sessionModelSource). Only
19885
- // set on the confirmed inject path; the sr-*/relaunch paths already set the
19886
- // override inside scheduleModelRelaunch, and an unverified switch carries no
19887
- // selectedModel so /status is never lied to.
19888
- const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null
19889
- let persistWarning = ''
20481
+ /**
20482
+ * Record a POSITIVELY-CONFIRMED typed `/model` switch: set the in-memory
20483
+ * override so `/status` reflects the live model and persist the sticky
20484
+ * `.session-model` carrier. Shared by the live `bot.command('model')` handler
20485
+ * and the deferred (queued mid-turn) apply so both record identically. Returns
20486
+ * a persist-warning suffix to append to the reply body (empty when clean).
20487
+ *
20488
+ * The `/status` honesty invariant lives here: only `reply.selectedModel`
20489
+ * (present only on a confirmed switch) records; an unverified inject records
20490
+ * nothing. `/model default` clears the carrier idempotently.
20491
+ */
20492
+ function recordTypedModelSwitch(
20493
+ reply: { text: string; selectedModel?: string },
20494
+ requestedModelArg: string | null,
20495
+ deps: ModelCommandDeps,
20496
+ ): string {
20497
+ const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null
19890
20498
  if (requested?.toLowerCase() === 'default') {
19891
- // `/model default` clears the sticky file even WITHOUT a positive
19892
- // confirmation: claude's arg-form switch can be silent, and a surviving
19893
- // sticky file would resurrect the old model on the next keep-relaunch.
19894
- // Clearing is idempotent; the in-memory override change stays gated on a
19895
- // confirmed switch so an unverified inject never lies to /status.
19896
20499
  const smDir = resolveAgentDirFromEnv()
19897
20500
  if (smDir) clearSessionModelFile(smDir)
19898
20501
  if (reply.selectedModel) sessionModelSource.setOverride(null)
19899
- } else if (reply.selectedModel) {
19900
- sessionModelSource.setOverride(reply.selectedModel)
19901
- // Durable stickiness: persist the REQUESTED canonical token — never
19902
- // the confirmation's display label ("Opus 4.8"), which `claude
19903
- // --model` would reject on the next boot. sr-* switches never reach
19904
- // here (they go through scheduleModelRelaunch, which persists).
19905
- const smDir = resolveAgentDirFromEnv()
19906
- if (smDir && requested && isValidModelArg(requested) && !isSrModel(requested)) {
19907
- try {
19908
- writeSessionModelFile(
20502
+ return ''
20503
+ }
20504
+ if (!reply.selectedModel) return ''
20505
+ sessionModelSource.setOverride(reply.selectedModel)
20506
+ const smDir = resolveAgentDirFromEnv()
20507
+ if (smDir && requested && isValidModelArg(requested) && !isSrModel(requested)) {
20508
+ try {
20509
+ writeSessionModelFile(
20510
+ smDir,
20511
+ requested,
20512
+ readConfiguredDefaultModel(smDir) ??
20513
+ resolveMainModel(deps.getConfiguredModel() ?? undefined),
20514
+ )
20515
+ } catch (err) {
20516
+ process.stderr.write(
20517
+ `telegram gateway: session-model persist failed (typed /model): ${(err as Error)?.message ?? String(err)}\n`,
20518
+ )
20519
+ return '\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
20520
+ }
20521
+ }
20522
+ return ''
20523
+ }
20524
+
20525
+ /**
20526
+ * Record a model-MENU callback outcome (persist/clear sticky override) and
20527
+ * drive an sr-*→Claude graceful restart when the tap crosses that boundary.
20528
+ * Extracted from the live `mdl:*` dispatcher so the deferred (queued mid-turn)
20529
+ * apply records + restarts identically. Does NOT edit any Telegram message —
20530
+ * callers own the card edit. Returns a restart notice when a session restart
20531
+ * was scheduled (the card should then drop its keyboard).
20532
+ */
20533
+ function recordModelMenuSideEffects(
20534
+ outcome: Awaited<ReturnType<typeof handleModelMenuCallback>>,
20535
+ modelDeps: ModelCommandDeps,
20536
+ cbChatId: string,
20537
+ cbThreadId: number | undefined,
20538
+ prevSessionModel: string | null,
20539
+ ): { restartNotice?: string } {
20540
+ // Record a successful session switch so /status reflects what's actually
20541
+ // running, and persist the STICKY override
20542
+ // (reference/rfcs/session-model-stickiness.md): the canonical token (never
20543
+ // the display label) goes to the durable `.session-model`; a confirmed
20544
+ // "Default (recommended)" selection clears it instead.
20545
+ if (outcome.selectedModel) {
20546
+ sessionModelSource.setOverride(outcome.selectedModel)
20547
+ const smDir = resolveAgentDirFromEnv()
20548
+ if (smDir && outcome.selectedModelToken) {
20549
+ try {
20550
+ writeSessionModelFile(
19909
20551
  smDir,
19910
- requested,
20552
+ outcome.selectedModelToken,
19911
20553
  readConfiguredDefaultModel(smDir) ??
19912
- resolveMainModel(deps.getConfiguredModel() ?? undefined),
20554
+ resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
19913
20555
  )
19914
20556
  } catch (err) {
19915
- // The reply body already promises stickiness — never let the promise
19916
- // and the disk disagree silently.
19917
- persistWarning =
20557
+ outcome.reply.text +=
19918
20558
  '\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
19919
20559
  process.stderr.write(
19920
- `telegram gateway: session-model persist failed (typed /model): ${(err as Error)?.message ?? String(err)}\n`,
20560
+ `telegram gateway: session-model persist failed (menu): ${(err as Error)?.message ?? String(err)}\n`,
19921
20561
  )
19922
20562
  }
19923
20563
  }
19924
20564
  }
20565
+ if (outcome.clearedDefault) {
20566
+ const smDir = resolveAgentDirFromEnv()
20567
+ if (smDir) clearSessionModelFile(smDir)
20568
+ }
20569
+
20570
+ // sr-* → Claude transition: the picker-select only changes the session model
20571
+ // label, but the sr-* LiteLLM routing context persists until the session is
20572
+ // torn down — a graceful restart (same mechanism as /restart) is required.
20573
+ if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
20574
+ const agentName = getMyAgentName()
20575
+ // Carry the requested Claude model across the restart via the SAME durable
20576
+ // `.session-model` override a Claude → sr-* switch uses — otherwise boot
20577
+ // launches the CONFIGURED default and the tapped model is silently dropped.
20578
+ const agentDir = resolveAgentDirFromEnv()
20579
+ const token = outcome.selectedModelToken
20580
+ if (agentDir && token) {
20581
+ try {
20582
+ writeSessionModelFile(
20583
+ agentDir,
20584
+ token,
20585
+ readConfiguredDefaultModel(agentDir) ??
20586
+ resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
20587
+ )
20588
+ sessionModelSource.setOverride(token)
20589
+ } catch (e) {
20590
+ process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
20591
+ }
20592
+ } else if (agentDir) {
20593
+ // Default-row tap while on sr-*: the restart must land on the configured
20594
+ // default — a stale sticky override would resurrect the old model.
20595
+ clearSessionModelFile(agentDir)
20596
+ }
20597
+ // Write the restart marker so the post-restart boot card edits into this chat.
20598
+ writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
20599
+ stampUserRestartReason('user: sr-to-claude model switch (menu)')
20600
+ if (turnInFlightForGate()) {
20601
+ // Defer restart until the in-flight turn completes (same gate as /restart).
20602
+ pendingRestarts.set(agentName, Date.now())
20603
+ } else {
20604
+ void sweepBeforeSelfRestart().finally(() =>
20605
+ triggerSelfRestart(agentName, 'sr-to-claude-model-switch', 1500),
20606
+ )
20607
+ }
20608
+ return {
20609
+ restartNotice: `🔄 Switching from **${escapeHtmlForTg(prevSessionModel!)}** back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
20610
+ }
20611
+ }
20612
+ return {}
20613
+ }
20614
+
20615
+ // ─── Mid-turn ack-queue-apply-confirm for /model + /effort (#3017) ──────────
20616
+ //
20617
+ // See pending-session-command.ts for the contract. The busy gates ENQUEUE
20618
+ // here + ACK; drainPendingSessionCommand() APPLIES the moment the agent idles
20619
+ // (from the same model-idle gate that drains pendingRestarts, plus the reaper
20620
+ // cap) and EDITS the ack card into the confirmation.
20621
+
20622
+ /**
20623
+ * Edit a queued command's ack card (chatId+messageId) into a new body,
20624
+ * dropping any inline keyboard. Best-effort — a deleted card just logs.
20625
+ * Mirrors redeliverBufferedInbound's edit-on-deliver via the shared retry
20626
+ * policy so flood-wait / not-found are handled by the standard wrapper.
20627
+ */
20628
+ async function editPendingCommandCard(chatId: string, messageId: number, text: string): Promise<void> {
20629
+ try {
20630
+ await robustApiCall(
20631
+ () =>
20632
+ // allow-raw-bot-api: deferred command-card edit routed through robustApiCall (not in the THREAD_NOT_FOUND blast pattern)
20633
+ lockedBot.api.editMessageText(chatId, messageId, richMessage(hardenCardBreaks(text)), {
20634
+ reply_markup: { inline_keyboard: [] },
20635
+ }),
20636
+ { chat_id: chatId, verb: 'pending-cmd-card' },
20637
+ )
20638
+ } catch (err) {
20639
+ process.stderr.write(
20640
+ `telegram gateway: pending-command card edit failed chat=${chatId} msg=${messageId}: ${(err as Error)?.message ?? String(err)}\n`,
20641
+ )
20642
+ }
20643
+ }
20644
+
20645
+ /**
20646
+ * Enqueue a mid-turn session command (same-kind last-write-wins; a /model and
20647
+ * a /effort coexist). If it displaces an earlier same-kind command, edit that
20648
+ * stale ack card into a "superseded" note so the operator's replaced choice is
20649
+ * never silently lost.
20650
+ */
20651
+ function enqueueSessionCommand(cmd: PendingSessionCommand): void {
20652
+ const displaced = pendingSessionCommand.set(cmd)
20653
+ if (displaced) {
20654
+ void editPendingCommandCard(
20655
+ displaced.ackChatId,
20656
+ displaced.ackMessageId,
20657
+ pendingCmdSupersededText(displaced, cmd, escapeHtmlForTg),
20658
+ )
20659
+ }
20660
+ // #3018 finding 5 — enqueue/turn-end race: if the idle gate fired between
20661
+ // the busy check and this enqueue, no further turn-end will drain the slot
20662
+ // and the command would sit until the reaper cap. If the session is idle
20663
+ // NOW, kick the drain immediately (re-entrancy-guarded, no-op if a racing
20664
+ // drain already ran).
20665
+ if (!turnInFlightForGate()) void drainPendingSessionCommand()
20666
+ }
20667
+
20668
+ /** Apply a queued typed/menu-alias model command at idle; return the reply body. */
20669
+ async function applyQueuedModelCommand(cmd: PendingSessionCommand): Promise<string> {
20670
+ const deps = buildModelDeps({ chatId: cmd.chatId, threadId: cmd.threadId })
20671
+ if (cmd.origin === 'menu') {
20672
+ // Menu SELECT (mdl:s:<tag>) — replay the callback handler (discovery is
20673
+ // idle-safe now) and record via the shared side-effects helper.
20674
+ const prevSessionModel = sessionModelSource.getOverride()
20675
+ const outcome = await handleModelMenuCallback(cmd.arg, deps)
20676
+ const { restartNotice } = recordModelMenuSideEffects(
20677
+ outcome,
20678
+ deps,
20679
+ cmd.chatId,
20680
+ cmd.threadId,
20681
+ prevSessionModel,
20682
+ )
20683
+ return restartNotice ?? outcome.reply.text
20684
+ }
20685
+ // Typed (and alias/sr menu taps converted to typed at enqueue): run the real
20686
+ // handler + shared recording.
20687
+ const reply = await handleModelCommand({ kind: 'set', model: cmd.arg }, deps)
20688
+ const warning = recordTypedModelSwitch(reply, cmd.arg, deps)
20689
+ return reply.text + warning
20690
+ }
20691
+
20692
+ /** Apply a queued typed/menu effort command at idle; return the reply body. */
20693
+ async function applyQueuedEffortCommand(cmd: PendingSessionCommand): Promise<string> {
20694
+ const deps = buildEffortDeps()
20695
+ if (cmd.origin === 'menu') {
20696
+ const outcome = await handleEffortMenuCallback(cmd.arg, deps)
20697
+ return outcome.reply.text
20698
+ }
20699
+ const parsed =
20700
+ cmd.arg === 'default'
20701
+ ? ({ kind: 'default' } as const)
20702
+ : ({ kind: 'set', level: cmd.arg as EffortLevel } as const)
20703
+ const reply = await handleEffortCommand(parsed, deps)
20704
+ return reply.text
20705
+ }
20706
+
20707
+ /**
20708
+ * Re-enqueue a command the drain took but could not safely apply (a turn
20709
+ * raced in). If a NEWER same-kind command was enqueued while the drain was
20710
+ * mid-flight, the newer one wins (last-write-wins invariant) and this older
20711
+ * card is edited to superseded instead.
20712
+ */
20713
+ function reEnqueueUnlessSuperseded(cmd: PendingSessionCommand): void {
20714
+ const newer = pendingSessionCommand.get(cmd.kind)
20715
+ if (newer != null) {
20716
+ void editPendingCommandCard(
20717
+ cmd.ackChatId,
20718
+ cmd.ackMessageId,
20719
+ pendingCmdSupersededText(cmd, newer, escapeHtmlForTg),
20720
+ )
20721
+ return
20722
+ }
20723
+ pendingSessionCommand.set(cmd)
20724
+ }
20725
+
20726
+ /**
20727
+ * #3039 — execute a shutdown/restart resolution action's durable write:
20728
+ * carry a queued (typed) choice across the bounce via the boot carriers
20729
+ * (`.session-model` / `.session-effort`) instead of asking for a re-issue.
20730
+ * Returns the ack-card text to edit in (persisted vs re-issue fallback).
20731
+ */
20732
+ function persistQueuedCommandForRestart(action: ShutdownResolutionAction): string {
20733
+ if (action.persist == null) return action.reissueText
20734
+ const agentDir = resolveAgentDirFromEnv()
20735
+ if (agentDir == null) return action.reissueText
20736
+ try {
20737
+ switch (action.persist) {
20738
+ case 'model': {
20739
+ // #3042 blocker 2a: this token was QUEUED, never confirmed by claude.
20740
+ // Under the keep-by-default boot a garbage-but-shape-valid token
20741
+ // persisted here would crashloop `claude --model <garbage>` with the
20742
+ // gateway dead. Only offline-trustable tokens (static Claude aliases,
20743
+ // curated sr-* alias targets) may be persisted unconfirmed; anything
20744
+ // else gets the honest "couldn't verify — re-issue" card instead.
20745
+ if (!isOfflineTrustedModelToken(action.arg)) {
20746
+ return `↩️ Couldn’t verify \`${escapeHtmlForTg(action.cmd.targetLabel || action.arg)}\` as a known model without the live session — it was NOT saved. Re-issue \`/model ${escapeHtmlForTg(action.arg)}\` once the agent is back.`
20747
+ }
20748
+ const configured =
20749
+ readConfiguredDefaultModel(agentDir) ?? resolveMainModel(undefined)
20750
+ writeSessionModelFile(agentDir, expandSrAlias(action.arg), configured)
20751
+ // Boot default is keep (#3039), but stamp explicit keep-intent for
20752
+ // reason-honesty in the boot notice.
20753
+ writeRelaunchModelIntent(agentDir, 'keep', 'queued /model carried across restart')
20754
+ break
20755
+ }
20756
+ case 'clear-model':
20757
+ clearSessionModelFile(agentDir)
20758
+ break
20759
+ case 'effort':
20760
+ writeSessionEffortFile(agentDir, action.arg, getConfiguredEffortForPersist())
20761
+ break
20762
+ case 'clear-effort':
20763
+ clearSessionEffortFile(agentDir)
20764
+ break
20765
+ }
20766
+ return action.persistedText
20767
+ } catch (err) {
20768
+ process.stderr.write(
20769
+ `telegram gateway: queued-command persist-for-restart failed kind=${action.cmd.kind} arg=${action.arg}: ${(err as Error)?.message ?? String(err)}\n`,
20770
+ )
20771
+ return action.reissueText
20772
+ }
20773
+ }
20774
+
20775
+ /**
20776
+ * The cascade-resolved thinking_effort, for `.session-effort`'s
20777
+ * configuredDefaultAtWrite. #3042 item 5: on a transient `agent list`
20778
+ * failure fall back to the scaffold default rather than null — persisting ''
20779
+ * would trip a bogus "configured default effort changed" clear+alert at the
20780
+ * next boot (start.sh bakes the same default when yaml is unset).
20781
+ */
20782
+ function getConfiguredEffortForPersist(): string {
20783
+ try {
20784
+ type AgentListResp = { agents: Array<{ name: string; thinking_effort?: string | null }> }
20785
+ const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
20786
+ return (
20787
+ data?.agents?.find(a => a.name === getMyAgentName())?.thinking_effort ??
20788
+ SWITCHROOM_DEFAULT_THINKING_EFFORT
20789
+ )
20790
+ } catch {
20791
+ return SWITCHROOM_DEFAULT_THINKING_EFFORT
20792
+ }
20793
+ }
20794
+
20795
+ // Synchronous re-entrancy guard — the idle gate can fire several times per
20796
+ // turn-end; the async apply must not double-dispatch before the first settles
20797
+ // (mirrors compactDispatching).
20798
+ let pendingCmdDraining = false
20799
+
20800
+ /**
20801
+ * Drain the queued session command(s) when the agent goes idle: apply each,
20802
+ * then edit its ack card into the confirmation (or a failure banner). Called
20803
+ * from the model-idle gate (turn complete), the post-enqueue idle kick, and
20804
+ * the reaper drain-cap. No-op when the slots are empty. If a restart is (or
20805
+ * becomes — a model apply can itself enqueue one) pending, the session is
20806
+ * going away — a Claude-model / effort session change would not survive, so
20807
+ * report that honestly rather than falsely confirming.
20808
+ */
20809
+ async function drainPendingSessionCommand(): Promise<void> {
20810
+ if (pendingCmdDraining) return
20811
+ if (pendingSessionCommand.size === 0) return
20812
+ pendingCmdDraining = true
20813
+ try {
20814
+ // Iteration + loss-safety invariants live in drainTakenCommands
20815
+ // (pending-session-command.ts, unit-tested): a restart-pending command is
20816
+ // persisted to the boot carriers; an early stop (turn raced in / handler
20817
+ // busy-refusal) re-enqueues EVERY not-yet-applied taken command (#3042
20818
+ // blocker 1) instead of dropping the rest of the batch.
20819
+ await pendingCmdDrainTaken(pendingSessionCommand.takeAll(), {
20820
+ restartPending: () => pendingRestarts.size > 0,
20821
+ turnInFlight: () => turnInFlightForGate(),
20822
+ apply: cmd =>
20823
+ cmd.kind === 'model' ? applyQueuedModelCommand(cmd) : applyQueuedEffortCommand(cmd),
20824
+ isBusyRefusal: isBusyRefusalText,
20825
+ resolveForRestartText: cmd =>
20826
+ persistQueuedCommandForRestart(pendingCmdResolveForRestart(cmd, escapeHtmlForTg)),
20827
+ editCard: (cmd, text) => editPendingCommandCard(cmd.ackChatId, cmd.ackMessageId, text),
20828
+ reEnqueue: reEnqueueUnlessSuperseded,
20829
+ failureText: (cmd, err) =>
20830
+ `❌ Couldn’t apply the queued ${cmd.kind} switch to \`${escapeHtmlForTg(cmd.targetLabel)}\`: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
20831
+ })
20832
+ } finally {
20833
+ pendingCmdDraining = false
20834
+ }
20835
+ }
20836
+
20837
+ bot.command('model', async ctx => {
20838
+ if (!isAuthorizedSender(ctx)) return
20839
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
20840
+ const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
20841
+ const chatId = String(ctx.chat!.id)
20842
+ const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
20843
+ const deps = buildModelDeps({ chatId, threadId })
20844
+ if (parsed.kind === 'show' && process.env.SWITCHROOM_MODEL_MENU !== '0') {
20845
+ const menu = await buildModelMenu(deps)
20846
+ await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
20847
+ return
20848
+ }
20849
+ // Mid-turn: instead of dead-ending ("Try again in a moment"), ACK + QUEUE +
20850
+ // apply-on-idle + confirm (#3017). The typed set path either injects into
20851
+ // claude's input box or triggers a carrier restart — both unsafe mid-turn.
20852
+ if (parsed.kind === 'set' && deps.isBusy()) {
20853
+ const target = expandSrAlias(parsed.model)
20854
+ const sent = await ctx.replyWithRichMessage(
20855
+ richMessage(hardenCardBreaks(pendingCmdAckText('model', target, escapeHtmlForTg))),
20856
+ threadId != null ? { message_thread_id: threadId } : {},
20857
+ )
20858
+ enqueueSessionCommand({
20859
+ kind: 'model',
20860
+ origin: 'typed',
20861
+ arg: target,
20862
+ targetLabel: target,
20863
+ chatId,
20864
+ threadId,
20865
+ ackChatId: chatId,
20866
+ ackMessageId: (sent as { message_id: number }).message_id,
20867
+ requestedAt: Date.now(),
20868
+ })
20869
+ return
20870
+ }
20871
+ const reply = await handleModelCommand(parsed, deps)
20872
+ // Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
20873
+ // actually running (shared with the deferred/menu paths). The sr-*/relaunch
20874
+ // paths already set the override inside scheduleModelRelaunch; an unverified
20875
+ // switch carries no selectedModel so /status is never lied to.
20876
+ const persistWarning = recordTypedModelSwitch(
20877
+ reply,
20878
+ parsed.kind === 'set' ? parsed.model : null,
20879
+ deps,
20880
+ )
19925
20881
  await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html })
19926
20882
  })
19927
20883
 
@@ -19936,12 +20892,36 @@ bot.command('model', async ctx => {
19936
20892
  // effort-command.ts so it's unit-testable without booting the bot.
19937
20893
  function buildEffortDeps(): EffortCommandDeps {
19938
20894
  return {
19939
- applyEffort: (agent, level) => applyEffort(agent, level),
20895
+ // #3039: single persistence choke point — EVERY positively-confirmed
20896
+ // effort apply (typed, menu tap, queued drain) durably records the level
20897
+ // to `.session-effort`, which start.sh resolves into `--effort` on every
20898
+ // boot. `/effort default` clears it via clearSessionEffort (the handler
20899
+ // clears AFTER its restore-apply, so the wrapper's write is undone).
20900
+ applyEffort: async (agent, level) => {
20901
+ const result = await applyEffort(agent, level)
20902
+ if (result.ok) {
20903
+ const agentDir = resolveAgentDirFromEnv()
20904
+ if (agentDir) {
20905
+ try {
20906
+ writeSessionEffortFile(agentDir, level, getConfiguredEffortForPersist())
20907
+ } catch (err) {
20908
+ process.stderr.write(
20909
+ `telegram gateway: session-effort persist failed level=${level}: ${(err as Error)?.message ?? String(err)}\n`,
20910
+ )
20911
+ }
20912
+ }
20913
+ }
20914
+ return result
20915
+ },
19940
20916
  getAgentName: getMyAgentName,
19941
- getConfiguredEffort: () => {
19942
- type AgentListResp = { agents: Array<{ name: string; thinking_effort?: string | null }> }
19943
- const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
19944
- return data?.agents?.find(a => a.name === getMyAgentName())?.thinking_effort ?? null
20917
+ getConfiguredEffort: () => getConfiguredEffortForPersist(),
20918
+ clearSessionEffort: () => {
20919
+ const agentDir = resolveAgentDirFromEnv()
20920
+ if (agentDir) clearSessionEffortFile(agentDir)
20921
+ },
20922
+ getSessionEffort: () => {
20923
+ const agentDir = resolveAgentDirFromEnv()
20924
+ return agentDir ? (readSessionEffortFile(agentDir)?.level ?? null) : null
19945
20925
  },
19946
20926
  escapeHtml: escapeHtmlForTg,
19947
20927
  }
@@ -19967,6 +20947,31 @@ bot.command('effort', async ctx => {
19967
20947
  await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) })
19968
20948
  return
19969
20949
  }
20950
+ // Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017) — parity with
20951
+ // /model. `applyEffort` mid-turn silently maybe-failed ("couldn't confirm it
20952
+ // applied") before this gate existed. `currentTurn !== null` is the same
20953
+ // busy signal the model path reads via deps.isBusy().
20954
+ if ((parsed.kind === 'set' || parsed.kind === 'default') && currentTurn !== null) {
20955
+ const requestedLevel = parsed.kind === 'set' ? parsed.level : 'default'
20956
+ const chatId = String(ctx.chat!.id)
20957
+ const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
20958
+ const sent = await ctx.replyWithRichMessage(
20959
+ richMessage(hardenCardBreaks(pendingCmdAckText('effort', requestedLevel, escapeHtmlForTg))),
20960
+ threadId != null ? { message_thread_id: threadId } : {},
20961
+ )
20962
+ enqueueSessionCommand({
20963
+ kind: 'effort',
20964
+ origin: 'typed',
20965
+ arg: requestedLevel,
20966
+ targetLabel: requestedLevel,
20967
+ chatId,
20968
+ threadId,
20969
+ ackChatId: chatId,
20970
+ ackMessageId: (sent as { message_id: number }).message_id,
20971
+ requestedAt: Date.now(),
20972
+ })
20973
+ return
20974
+ }
19970
20975
  const reply = await handleEffortCommand(parsed, deps)
19971
20976
  await switchroomReply(ctx, reply.text, { html: reply.html })
19972
20977
  })
@@ -19983,7 +20988,10 @@ bot.command('agentstart', async ctx => {
19983
20988
  )
19984
20989
  })
19985
20990
 
19986
- bot.command('stop', async ctx => {
20991
+ // #3020: container stop lives on /agentstop (pairs with /agentstart above).
20992
+ // It WAS /stop; that word now cancels the in-flight turn instead — the
20993
+ // intuitive meaning for a chat-first operator.
20994
+ bot.command('agentstop', async ctx => {
19987
20995
  if (!isAuthorizedSender(ctx)) return
19988
20996
  const name = ctx.match?.trim() || getMyAgentName()
19989
20997
  try { assertSafeAgentName(name) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
@@ -19995,6 +21003,32 @@ bot.command('stop', async ctx => {
19995
21003
  )
19996
21004
  })
19997
21005
 
21006
+ // #3020: /stop — cancel the agent's in-flight turn (the typed sibling of the
21007
+ // bare "stop" keyword and the empty-`!` interrupt; shared executeHaltNow).
21008
+ bot.command('stop', async ctx => {
21009
+ if (!isAuthorizedSender(ctx)) return
21010
+ // 7a: /stop takes NO argument. "/stop worker" is almost certainly old
21011
+ // container-stop muscle memory (the verb that moved to /agentstop) — warn
21012
+ // and do NOT halt, so a mis-remembered command can't kill an unrelated turn.
21013
+ const arg = ctx.match?.trim()
21014
+ if (arg) {
21015
+ await switchroomReply(
21016
+ ctx,
21017
+ `/stop takes no argument — it cancels MY in-flight turn. ` +
21018
+ `To stop a container, use /agentstop ${arg}. Nothing was stopped.`,
21019
+ )
21020
+ return
21021
+ }
21022
+ const queuedLabels = pendingSessionCommand.list().map(c => `/${c.kind} ${c.targetLabel}`)
21023
+ const inFlight = turnInFlightForGate()
21024
+ if (!inFlight) {
21025
+ await switchroomReply(ctx, buildStopReply(false, queuedLabels).text)
21026
+ return
21027
+ }
21028
+ await executeHaltNow('stop-command')
21029
+ await switchroomReply(ctx, buildStopReply(true, queuedLabels).text)
21030
+ })
21031
+
19998
21032
  bot.command('restart', async ctx => {
19999
21033
  if (!isAuthorizedSender(ctx)) return
20000
21034
  const name = ctx.match?.trim() || getMyAgentName()
@@ -20033,12 +21067,13 @@ bot.command('restart', async ctx => {
20033
21067
  // greeting card shows "Restarted user: /restart from chat" instead
20034
21068
  // of whatever reason the downstream CLI would default to.
20035
21069
  stampUserRestartReason('user: /restart from chat')
20036
- // /restart is a DELIBERATE restart: the session model reverts to the
20037
- // configured default. Absence of intent would revert anyway (boot
20038
- // default) the explicit stamp is reason-honesty for the boot notice.
21070
+ // #3039: /restart is "bounce the session", NOT "clear my model" the
21071
+ // durable override survives every restart and is cleared only by
21072
+ // `/model default`. Stamp keep for reason-honesty in the boot notice
21073
+ // (absence of intent keeps anyway under the keep-by-default boot).
20039
21074
  {
20040
21075
  const smDir = resolveAgentDirFromEnv()
20041
- if (smDir) writeRelaunchModelIntent(smDir, 'revert', 'user: /restart from chat')
21076
+ if (smDir) writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')
20042
21077
  }
20043
21078
  await sweepBeforeSelfRestart()
20044
21079
  const hostdResp = await tryHostdDispatch(getMyAgentName(), {
@@ -20852,6 +21887,16 @@ const fleetFallbackGate = createFleetFallbackGate({
20852
21887
  brokerReachable: isAuthBrokerSocketReachable,
20853
21888
  })
20854
21889
 
21890
+ /**
21891
+ * #3031 PR 3 — per-chat registry of the most recent model-unavailable card
21892
+ * that promised an in-flight auto-failover. On a SUCCESSFUL swap the
21893
+ * announcement is folded into an EDIT of that card (single evolving card);
21894
+ * on every failure / no-op outcome the notice stays a separate message
21895
+ * (promise-honesty: the 2026-06-06→07 incident contract). See
21896
+ * fallback-card-collapse.ts for the decision seam.
21897
+ */
21898
+ const modelUnavailableCardRegistry = createModelUnavailableCardRegistry()
21899
+
20855
21900
  /**
20856
21901
  * Resume-after-swap gate (auth-failover-stall fix). Owns the single-flight +
20857
21902
  * staleness decision for re-running the turn a mid-turn 429 killed. See
@@ -21061,6 +22106,34 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
21061
22106
  // not the user's answer — silence the open ping.
21062
22107
  const opts = { disable_notification: true }
21063
22108
  for (const chat_id of access.allowFrom) {
22109
+ // #3031 PR 3 — collapse: on a SUCCESSFUL swap, fold the announcement
22110
+ // into an EDIT of the model-unavailable card this gateway just sent to
22111
+ // this chat (single evolving card). One-shot `take` clears the record
22112
+ // either way. Every non-switched outcome that REACHES this loop — and
22113
+ // any edit failure — falls through to the pre-fix separate send.
22114
+ // Known PRE-EXISTING hole, unchanged by the collapse (#3035 review,
22115
+ // finding 2): the all-blocked cooldown early-return above exits before
22116
+ // this loop, so a cooldown-suppressed all-blocked REPEAT delivers
22117
+ // neither edit nor message even when a promising card exists. The
22118
+ // first all-blocked card of a window does arrive and answers the
22119
+ // promise; only the repeats inside the 30-min window are silent.
22120
+ const card = modelUnavailableCardRegistry.take(String(chat_id), Date.now())
22121
+ if (decideAnnouncementDelivery(outcome.kind, card) === 'edit' && card) {
22122
+ try {
22123
+ // allow-raw-bot-api: guarded edit; failure falls through to the wrapped send below
22124
+ await bot.api.editMessageText(
22125
+ chat_id,
22126
+ card.messageId,
22127
+ richMessage(foldAnnouncementIntoCard(card.text, outcome.announcement)),
22128
+ {},
22129
+ )
22130
+ continue
22131
+ } catch (err) {
22132
+ process.stderr.write(
22133
+ `telegram gateway: [fleet-fallback] card edit failed chat=${chat_id} — sending announcement separately: ${(err as Error)?.message ?? err}\n`,
22134
+ )
22135
+ }
22136
+ }
21064
22137
  void swallowingApiCall(
21065
22138
  // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
21066
22139
  () => bot.api.sendRichMessage(chat_id, richMessage(outcome.announcement), opts),
@@ -21312,6 +22385,61 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
21312
22385
  }
21313
22386
  }
21314
22387
 
22388
+ // #3031 PR 3 — broker-initiated proactive roll announcement. The broker's
22389
+ // fleetQuotaProbeTick roll previously emitted stdout + audit only (zero
22390
+ // operator Telegram visibility); it now records `last_fleet_roll` in
22391
+ // list-state. Edge-triggered on the roll's `at` timestamp; deduped across
22392
+ // the multi-gateway fan-in via the same broker claim-notification verb the
22393
+ // per-account pushes use — one card per roll per chat, fleet-wide.
22394
+ {
22395
+ const rollPrev = watchState[FLEET_ROLL_ANNOUNCE_KEY] ?? emptyAccountState()
22396
+ const rollDecision = evaluateFleetRollAnnounce({
22397
+ roll: listStateData.last_fleet_roll ?? null,
22398
+ prev: rollPrev,
22399
+ now,
22400
+ })
22401
+ if (rollDecision.kind === 'notify') {
22402
+ for (const chat_id of access.allowFrom) {
22403
+ if (tuning.fleetDedup) {
22404
+ const granted = await claimQuotaNotification(
22405
+ brokerClient,
22406
+ buildQuotaClaimKey(
22407
+ FLEET_ROLL_ANNOUNCE_KEY,
22408
+ String(listStateData.last_fleet_roll!.at),
22409
+ chat_id,
22410
+ ),
22411
+ )
22412
+ if (!granted) {
22413
+ process.stderr.write(
22414
+ `telegram gateway: quota-watch: fleet-roll claim denied chat=${chat_id} — another agent notified\n`,
22415
+ )
22416
+ continue
22417
+ }
22418
+ }
22419
+ await swallowingApiCall(
22420
+ () =>
22421
+ bot.api.sendRichMessage(chat_id, richMessage(rollDecision.message), {
22422
+ // Reassuring status notice, not the user's answer — silent.
22423
+ disable_notification: true,
22424
+ }),
22425
+ { chat_id, verb: 'quota-watch.fleet-roll' },
22426
+ )
22427
+ }
22428
+ // Persist immediately — mirrors the all-exhausted block's rationale:
22429
+ // a per-account early return below must not drop the latch advance.
22430
+ watchState = patchQuotaWatchState(watchState, FLEET_ROLL_ANNOUNCE_KEY, rollDecision.newState)
22431
+ try {
22432
+ saveQuotaWatchState(stateDir, watchState)
22433
+ } catch (err) {
22434
+ process.stderr.write(`telegram gateway: quota-watch: fleet-roll state save failed: ${err}\n`)
22435
+ }
22436
+ process.stderr.write(
22437
+ `telegram gateway: quota-watch: announced broker fleet roll ` +
22438
+ `${listStateData.last_fleet_roll!.from} → ${listStateData.last_fleet_roll!.to}\n`,
22439
+ )
22440
+ }
22441
+ }
22442
+
21315
22443
  // First pass: evaluate all accounts against cached state. Collect
21316
22444
  // labels that need a live probe (i.e. accounts with a detected transition
21317
22445
  // that we're about to notify about). We probe those to get fresh
@@ -21331,9 +22459,13 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
21331
22459
  let reconciledCount = 0
21332
22460
  let mutatedState = watchState
21333
22461
 
22462
+ // #3031 PR 3 — thread the last broker roll so a 🟡 throttling push for the
22463
+ // just-rolled-off account is latched silently (the roll card covered it).
22464
+ const lastRoll = listStateData.last_fleet_roll ?? null
22465
+
21334
22466
  for (const snap of snapshots) {
21335
22467
  const prev = watchState[snap.label] ?? emptyAccountState()
21336
- const decision = evaluateQuotaWatchAccount({ agentName, snap, prev, now, bootTick, tuning })
22468
+ const decision = evaluateQuotaWatchAccount({ agentName, snap, prev, now, bootTick, tuning, lastRoll })
21337
22469
  if (decision.kind === 'reconcile') {
21338
22470
  mutatedState = patchQuotaWatchState(mutatedState, decision.accountLabel, decision.newAccountState)
21339
22471
  reconciledCount++
@@ -21428,7 +22560,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
21428
22560
  // staleness gate never misfires on data we JUST probed.
21429
22561
  const enrichedSnap = { ...snapshots[snapIndex]!, quota: freshEntry!.result.data, capturedAtMs: undefined }
21430
22562
  const prev = watchState[accountLabel] ?? emptyAccountState()
21431
- const re = evaluateQuotaWatchAccount({ agentName, snap: enrichedSnap, prev, now, bootTick, tuning })
22563
+ const re = evaluateQuotaWatchAccount({ agentName, snap: enrichedSnap, prev, now, bootTick, tuning, lastRoll })
21432
22564
  // If the fresh probe still shows the same transition, use the
21433
22565
  // enriched message. If it no longer shows a transition (e.g. the
21434
22566
  // account recovered in the 100ms between listState and probe),
@@ -22704,9 +23836,37 @@ bot.on('callback_query:data', async ctx => {
22704
23836
  await ctx.answerCallbackQuery({ text: 'Not authorized.' })
22705
23837
  return
22706
23838
  }
22707
- // No picker-driving (the inline `/effort <level>` form sets it
22708
- // directly via the inject primitive), so no mid-turn guard is needed
22709
- // herejust ack and apply.
23839
+ // Mid-turn: ACK + QUEUE + apply-on-idle + confirm (#3017). `applyEffort`
23840
+ // types `/effort <level>` into claude's input box + drives the confirm
23841
+ // modalmid-turn that queues the text as user input instead of switching
23842
+ // (the silent maybe-fail this fix closes). Parity with the model-menu gate.
23843
+ const effLevel = data.startsWith('eff:s:') ? data.slice('eff:s:'.length) : null
23844
+ const effCardMsgId = ctx.callbackQuery?.message?.message_id
23845
+ if (effLevel != null && currentTurn !== null && effCardMsgId != null) {
23846
+ await ctx
23847
+ .answerCallbackQuery({ text: '📥 Queued — applies when the turn ends' })
23848
+ .catch(() => {})
23849
+ const effChatId = String(ctx.chat?.id ?? '')
23850
+ const effThreadId = resolveThreadId(effChatId, ctx.callbackQuery?.message?.message_thread_id)
23851
+ await ctx
23852
+ .editMessageText(richMessage(hardenCardBreaks(pendingCmdAckText('effort', effLevel, escapeHtmlForTg))), {
23853
+ reply_markup: { inline_keyboard: [] },
23854
+ })
23855
+ .catch(() => {})
23856
+ enqueueSessionCommand({
23857
+ kind: 'effort',
23858
+ origin: 'menu',
23859
+ arg: data,
23860
+ targetLabel: effLevel,
23861
+ chatId: effChatId,
23862
+ threadId: effThreadId,
23863
+ ackChatId: effChatId,
23864
+ ackMessageId: effCardMsgId,
23865
+ requestedAt: Date.now(),
23866
+ })
23867
+ return
23868
+ }
23869
+ // Idle: apply directly. No picker-driving concern — just ack and apply.
22710
23870
  await ctx.answerCallbackQuery({ text: 'Setting effort…' }).catch(() => {})
22711
23871
  try {
22712
23872
  const outcome = await handleEffortMenuCallback(data, buildEffortDeps())
@@ -22745,12 +23905,51 @@ bot.on('callback_query:data', async ctx => {
22745
23905
  const cbChatId = String(ctx.chat?.id ?? '')
22746
23906
  const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
22747
23907
  const modelDeps = buildModelDeps({ chatId: cbChatId, threadId: cbThreadId })
22748
- // Mid-turn refusal is INSTANT (a sync isBusy() check, no picker drive),
22749
- // so handle it before the "Working…" ack: toast WHY and leave the menu
22750
- // message untouched (buttons intact) so the operator taps again when
22751
- // idle. Editing the menu into a button-less "try again" line was the
22752
- // "nothing happened" report the menu looked dead.
23908
+ // Mid-turn (#3017): a model-SWITCH tap no longer dead-ends. ACK + QUEUE +
23909
+ // apply-on-idle + confirm the tap edits the menu card into the ack, which
23910
+ // the idle drain then edits into the confirmation. Non-switch taps (Refresh
23911
+ // / page-nav / header) still just toast they drive the picker (discovery)
23912
+ // which is unsafe mid-turn and carry no operator choice to preserve.
22753
23913
  if (modelDeps.isBusy()) {
23914
+ // Classify: sr-*/alias taps carry a real model token → queue as a typed
23915
+ // apply (reuses the typed drain + recording). A select tag (mdl:s:<tag>)
23916
+ // needs idle discovery to resolve → queue as a menu apply (replayed).
23917
+ const cardMsgId = ctx.callbackQuery?.message?.message_id
23918
+ let queueArg: string | null = null
23919
+ let queueOrigin: 'typed' | 'menu' = 'menu'
23920
+ let queueLabel = 'the selected model'
23921
+ if (data.startsWith(MODEL_CALLBACK_SR)) {
23922
+ const srName = data.slice(MODEL_CALLBACK_SR.length)
23923
+ if (isValidModelArg(srName)) { queueArg = srName; queueOrigin = 'typed'; queueLabel = srFriendlyLabel(srName) }
23924
+ } else if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
23925
+ const alias = data.slice(MODEL_CALLBACK_ALIAS.length)
23926
+ if (isValidModelArg(alias)) { queueArg = alias; queueOrigin = 'typed'; queueLabel = alias }
23927
+ } else if (data.startsWith('mdl:s:')) {
23928
+ queueArg = data; queueOrigin = 'menu'
23929
+ }
23930
+ if (queueArg != null && cardMsgId != null) {
23931
+ await ctx
23932
+ .answerCallbackQuery({ text: '📥 Queued — applies when the turn ends' })
23933
+ .catch(() => {})
23934
+ await ctx
23935
+ .editMessageText(richMessage(hardenCardBreaks(pendingCmdAckText('model', queueLabel, escapeHtmlForTg))), {
23936
+ reply_markup: { inline_keyboard: [] },
23937
+ })
23938
+ .catch(() => {})
23939
+ enqueueSessionCommand({
23940
+ kind: 'model',
23941
+ origin: queueOrigin,
23942
+ arg: queueArg,
23943
+ targetLabel: queueLabel,
23944
+ chatId: cbChatId,
23945
+ threadId: cbThreadId,
23946
+ ackChatId: cbChatId,
23947
+ ackMessageId: cardMsgId,
23948
+ requestedAt: Date.now(),
23949
+ })
23950
+ return
23951
+ }
23952
+ // Non-switch tap (or unresolvable) — keep the menu intact, just toast.
22754
23953
  await ctx
22755
23954
  .answerCallbackQuery({ text: '⏳ Agent is mid-turn — tap again when it’s idle', show_alert: false })
22756
23955
  .catch(() => {})
@@ -22814,105 +24013,32 @@ bot.on('callback_query:data', async ctx => {
22814
24013
  }
22815
24014
  return
22816
24015
  }
22817
- const didInterimSrEdit = false
22818
24016
  try {
22819
24017
  const prevSessionModel = sessionModelSource.getOverride()
22820
24018
  const outcome = await handleModelMenuCallback(data, modelDeps)
22821
- // Record a successful session switch so /status reflects what's
22822
- // actually running, and persist the STICKY override
22823
- // (reference/rfcs/session-model-stickiness.md): the canonical token
22824
- // (never the display label) goes to the durable `.session-model`; a
22825
- // confirmed "Default (recommended)" selection clears it instead.
22826
- if (outcome.selectedModel) {
22827
- sessionModelSource.setOverride(outcome.selectedModel)
22828
- const smDir = resolveAgentDirFromEnv()
22829
- if (smDir && outcome.selectedModelToken) {
22830
- try {
22831
- writeSessionModelFile(
22832
- smDir,
22833
- outcome.selectedModelToken,
22834
- readConfiguredDefaultModel(smDir) ??
22835
- resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
22836
- )
22837
- } catch (err) {
22838
- // The banner already promises stickiness — surface the failure on
22839
- // the same card instead of only stderr.
22840
- outcome.reply.text +=
22841
- '\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
22842
- process.stderr.write(
22843
- `telegram gateway: session-model persist failed (menu): ${(err as Error)?.message ?? String(err)}\n`,
22844
- )
22845
- }
22846
- }
22847
- }
22848
- if (outcome.clearedDefault) {
22849
- const smDir = resolveAgentDirFromEnv()
22850
- if (smDir) clearSessionModelFile(smDir)
22851
- }
22852
- // toastOnly: leave the menu untouched — but only if we haven't already
22853
- // cleared its buttons with the interim sr-* edit. If we have, fall
22854
- // through to the final edit so the message is recovered (busyReply or
22855
- // the full menu) rather than left permanently button-less.
22856
- if (outcome.toastOnly && !didInterimSrEdit) return
22857
-
22858
- // sr-* → Claude transition via the model menu: trigger a graceful restart.
22859
- // Switching FROM an sr-* (LiteLLM/OpenRouter) model BACK to a Claude model
22860
- // via the picker requires a session restart — the picker-select only changes
22861
- // the session model label, but the sr-* LiteLLM routing context persists
22862
- // until the session is torn down. Same mechanism as the /restart command.
22863
- if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
22864
- const agentName = getMyAgentName()
22865
- // Replace the menu with a restart notice (no buttons — session is ending).
24019
+ // toastOnly: leave the menu untouched a mid-turn refusal keeps its
24020
+ // buttons so the operator can tap again. (In the enqueue world this
24021
+ // branch is unreachable — the dispatcher enqueues switches mid-turn
24022
+ // before ever calling the handler but retained for callers that skip
24023
+ // the dispatcher gate.)
24024
+ if (outcome.toastOnly) return
24025
+ // Record the switch (persist/clear sticky override) + drive an sr-*→Claude
24026
+ // graceful restart when needed. Shared with the deferred (queued) apply so
24027
+ // both surfaces record identically. Returns a restart notice when a
24028
+ // session restart was scheduled.
24029
+ const { restartNotice } = recordModelMenuSideEffects(
24030
+ outcome,
24031
+ modelDeps,
24032
+ cbChatId,
24033
+ cbThreadId,
24034
+ prevSessionModel,
24035
+ )
24036
+ if (restartNotice) {
22866
24037
  await ctx
22867
- .editMessageText(
22868
- richMessage(`🔄 Switching from **${escapeHtmlForTg(prevSessionModel!)}** back to Claude — restarting session cleanly. Claude will be ready in ~30s.`),
22869
- { reply_markup: { inline_keyboard: [] } },
22870
- )
24038
+ .editMessageText(richMessage(restartNotice), { reply_markup: { inline_keyboard: [] } })
22871
24039
  .catch(() => {})
22872
- // Carry the requested Claude model across the restart via the SAME
22873
- // durable `.session-model` override a Claude → sr-* switch uses —
22874
- // otherwise boot launches the CONFIGURED default and the tapped model
22875
- // is silently dropped. `selectedModelToken` is a real `claude --model`
22876
- // token (alias or full claude-* id); a "Default"-row tap yields no
22877
- // token → clear the override and boot the configured default
22878
- // (correct). start.sh's LiteLLM-down guard only skips sr-* overrides,
22879
- // so a Claude token is never dropped.
22880
- {
22881
- const agentDir = resolveAgentDirFromEnv()
22882
- const token = outcome.selectedModelToken
22883
- if (agentDir && token) {
22884
- try {
22885
- writeSessionModelFile(
22886
- agentDir,
22887
- token,
22888
- readConfiguredDefaultModel(agentDir) ??
22889
- resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
22890
- )
22891
- sessionModelSource.setOverride(token)
22892
- } catch (e) {
22893
- process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
22894
- }
22895
- } else if (agentDir) {
22896
- // Default-row tap while on sr-*: the restart must land on the
22897
- // configured default — a stale sticky override would resurrect
22898
- // the old model on the next keep-relaunch.
22899
- clearSessionModelFile(agentDir)
22900
- }
22901
- }
22902
- // Write the restart marker so the post-restart boot card edits into this chat.
22903
- writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
22904
- stampUserRestartReason('user: sr-to-claude model switch (menu)')
22905
- if (turnInFlightForGate()) {
22906
- // Defer restart until the in-flight turn completes (same gate as /restart).
22907
- pendingRestarts.set(agentName, Date.now())
22908
- } else {
22909
- void sweepBeforeSelfRestart().finally(() =>
22910
- triggerSelfRestart(agentName, 'sr-to-claude-model-switch', 1500),
22911
- )
22912
- }
22913
24040
  return
22914
24041
  }
22915
-
22916
24042
  await ctx
22917
24043
  .editMessageText(richMessage(outcome.reply.text), {
22918
24044
  reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
@@ -25157,6 +26283,10 @@ async function shutdown(signal: string): Promise<void> {
25157
26283
  shuttingDown = true
25158
26284
  const agentName = process.env.SWITCHROOM_AGENT_NAME ?? '-'
25159
26285
  process.stderr.write('telegram gateway: shutting down\n')
26286
+ // #3038 — never let a pending bridge-dead grace timer race a shutdown
26287
+ // already in progress (its check() also skips on shuttingDown; this is
26288
+ // the belt to that brace).
26289
+ bridgeDeadWatchdog.stop()
25160
26290
 
25161
26291
  // Write the clean-shutdown sentinel BEFORE any drain work begins so
25162
26292
  // even if the drain hangs and the +5s force-exit kills us, the marker
@@ -25191,10 +26321,66 @@ async function shutdown(signal: string): Promise<void> {
25191
26321
  } catch (err) {
25192
26322
  process.stderr.write(`telegram gateway: shutdown.clean_marker_write_failed err=${(err as Error).message}\n`)
25193
26323
  }
26324
+ // #3017 — persist a Telegram-set model across a GRACEFUL deploy/restart.
26325
+ // Boot default is REVERT, and an EXTERNAL deploy (SIGTERM to PID 1 from
26326
+ // `switchroom apply` / `docker compose up`) never routes through
26327
+ // triggerSelfRestart, so it stamps no `.relaunch-model-intent` and start.sh
26328
+ // drops the user's `/model` choice (the overlord `fable`→`opus` revert on
26329
+ // the v0.18.9 roll). A graceful OS-signal shutdown IS a clean, planned
26330
+ // bounce — stamp keep-intent for an active `.session-model` override so the
26331
+ // chosen model survives and start.sh re-confirms it via `.session-model-alert`
26332
+ // on boot. Respect an intent an initiator already stamped (a /restart stamps
26333
+ // 'revert' before SIGTERM): only stamp when none exists. A crash routes
26334
+ // through the non-OS-signal branch and still reverts (safe side preserved).
26335
+ try {
26336
+ const smDir = resolveAgentDirFromEnv()
26337
+ if (smDir != null) {
26338
+ const hasOverride = readSessionModelFile(smDir) != null
26339
+ const intentAlreadyStamped = existsSync(join(smDir, RELAUNCH_MODEL_INTENT_FILE))
26340
+ if (hasOverride && !intentAlreadyStamped) {
26341
+ // The GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX makes this stamp
26342
+ // recognisable at the next GATEWAY boot: a gateway-only bounce never
26343
+ // runs start.sh, so a leftover stamp with this prefix is cleared at
26344
+ // boot (clearStaleGatewayShutdownIntent) instead of lingering to
26345
+ // convert a later genuine crash into a "keep" (#3018 finding 4).
26346
+ writeRelaunchModelIntent(smDir, 'keep', `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful ${signal} shutdown (deploy/rolling restart) — preserving user-chosen session model`)
26347
+ process.stderr.write(`telegram gateway: shutdown.session_model_keep_stamped signal=${signal}\n`)
26348
+ }
26349
+ }
26350
+ } catch (err) {
26351
+ process.stderr.write(`telegram gateway: shutdown.session_model_keep_stamp_failed err=${(err as Error).message}\n`)
26352
+ }
25194
26353
  } else {
25195
26354
  process.stderr.write(`telegram gateway: shutdown.clean_marker_skipped signal=${signal} (crash path — banner will fire on next boot)\n`)
25196
26355
  }
25197
26356
 
26357
+ // #3018 finding 3 + #3039: resolve any queued /model|/effort ack cards. The
26358
+ // gateway (and with it the in-memory queue) is going away — persist each
26359
+ // typed choice to the durable boot carriers (`.session-model` /
26360
+ // `.session-effort`) so it still deterministically applies as the agent
26361
+ // boots, and edit the ack card to say so. Only an unresolvable menu-tag
26362
+ // selection falls back to a re-issue note. Best-effort and time-bounded so
26363
+ // a wedged Telegram API can't block shutdown.
26364
+ const orphanedCmdActions = pendingCmdShutdownResolutionActions(pendingSessionCommand, escapeHtmlForTg)
26365
+ const orphanedCmdEdits = orphanedCmdActions.map(a => ({
26366
+ chatId: a.cmd.ackChatId,
26367
+ messageId: a.cmd.ackMessageId,
26368
+ text: persistQueuedCommandForRestart(a),
26369
+ }))
26370
+ if (orphanedCmdEdits.length > 0) {
26371
+ process.stderr.write(
26372
+ `telegram gateway: shutdown.pending_cmd_resolved count=${orphanedCmdEdits.length}\n`,
26373
+ )
26374
+ await Promise.race([
26375
+ Promise.allSettled(
26376
+ orphanedCmdEdits.map(e => editPendingCommandCard(e.chatId, e.messageId, e.text)),
26377
+ ),
26378
+ new Promise<void>(resolve => {
26379
+ setTimeout(resolve, SHUTDOWN_PROGRESS_FLUSH_BUDGET_MS).unref()
26380
+ }),
26381
+ ])
26382
+ }
26383
+
25198
26384
  // Stage 3c: stamp any in-flight turn as endedVia='sigterm' (or 'restart'
25199
26385
  // for the schedule_restart-initiated case where pendingRestarts is set).
25200
26386
  // Best-effort — SIGKILL / OOM skip this path entirely and the next-boot
@@ -26309,6 +27495,44 @@ void (async () => {
26309
27495
  // Gated to background completions: foreground sub-agents
26310
27496
  // need nothing here, and 'orphan' is a stale historical-at-
26311
27497
  // boot row, not a fresh completion the user is waiting on.
27498
+ // Issue #3023 (card resurrection). A worker whose card was
27499
+ // FALSELY finalised (silent-stall synthesis fired, then the
27500
+ // JSONL resumed growing) is being revived by the watcher. This
27501
+ // callback ONLY clears the worker feed's durable `finalized` gate
27502
+ // (workerActivityFeed.resurrect) — it does NOT itself paint or pin
27503
+ // anything. The actual repaint + re-pin happen DOWNSTREAM on the
27504
+ // next replayed `running` cue: the watcher's re-registration
27505
+ // replays `onProgress`, which calls `workerActivityFeed.update()`
27506
+ // (now un-gated) to first-paint a FRESH `🛠 Worker` message, and
27507
+ // its `.then(reconcileWorkerPin(agentId, wkChat, true))` pins that
27508
+ // new message via the `wk:<agentId>` status-pin. Clearing the gate
27509
+ // here FIRST is the ordering requirement — without it those first
27510
+ // replayed ticks would be swallowed by the finalized gate and no
27511
+ // new card would ever paint. Net effect restores the operator
27512
+ // invariant "active work must always be visible" for the case
27513
+ // PR #3019's in-flight gate can't fully prevent.
27514
+ onResurrect: (agentId, _description) => {
27515
+ try {
27516
+ workerActivityFeed?.resurrect(agentId)
27517
+ } catch (err) {
27518
+ process.stderr.write(
27519
+ `telegram gateway: worker resurrect error agent=${agentId}: ${(err as Error).message}\n`,
27520
+ )
27521
+ }
27522
+ process.stderr.write(
27523
+ `telegram gateway: worker ${agentId} card RESURRECTED — false terminal finish reversed, worker resumed (issue #3023)\n`,
27524
+ )
27525
+ },
27526
+ // Issue #3023 (bounded chain). A worker resurrected once and then
27527
+ // falsely finalised again is NOT resurrected forever — the
27528
+ // watcher names it lost. Surface the fact in the log; the
27529
+ // handback (onFinish) still delivered the synthesised result, so
27530
+ // there is nothing further to paint.
27531
+ onWorkerLost: (agentId, _description) => {
27532
+ process.stderr.write(
27533
+ `telegram gateway: worker ${agentId} NAMED AS LOST — falsely finalised twice, resurrection chain bound reached (issue #3023)\n`,
27534
+ )
27535
+ },
26312
27536
  onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
26313
27537
  // Reaction promotion: if the parent turn already ended
26314
27538
  // with this (or another) worker still running, its 👍 was