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
@@ -175,7 +175,7 @@ export interface ModelCommandReply {
175
175
  }
176
176
 
177
177
  const PERSIST_NOTE =
178
- '_Sticky across switchroom-managed relaunches (\`/new\`, watchdog recovery); reverts on \`/restart\`, agent restart, crash, or external container restart. \`/model default\` clears it. To persist, set \`model:\` in switchroom.yaml._'
178
+ '_Sticky persists across restarts, deploys, and crashes until \`/model default\` clears it (or the configured \`model:\` in switchroom.yaml changes, which resets it and notifies you). To change the default, set \`model:\` in switchroom.yaml._'
179
179
 
180
180
  function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
181
181
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' · ')
@@ -230,7 +230,7 @@ export async function handleModelCommand(
230
230
  // and ask the operator to retry (parity with the menu callback's isBusy check).
231
231
  if (deps.isBusy()) {
232
232
  return {
233
- text: '⏳ The agent is mid-turn — a model switch needs an idle session. Try again in a moment.',
233
+ text: '⏳ The agent is mid-turn — a model switch needs an idle session. The switch was not applied.',
234
234
  html: true,
235
235
  }
236
236
  }
@@ -506,6 +506,26 @@ export const SR_MODEL_ALIASES: Record<string, string> = {
506
506
  }
507
507
 
508
508
  /** Expand a short alias (case-insensitive) to its full sr-* id, or return the original. */
509
+ /**
510
+ * #3042 review blocker 2a: can `token` be trusted for a DURABLE, boot-applied
511
+ * `.session-model` persist WITHOUT a live confirmation from claude?
512
+ *
513
+ * A queued typed `/model <arg>` that is persisted at shutdown was never
514
+ * validated by claude's picker — and under the keep-by-default boot (#3039)
515
+ * a garbage-but-shape-valid token (e.g. `claude-nonexistnet-9`) would make
516
+ * every boot run `claude --model <garbage>` with the gateway dead. Only
517
+ * tokens with a switchroom-known meaning are offline-trustable: the static
518
+ * Claude aliases (claude resolves them itself) and the curated sr-* alias
519
+ * TARGETS (present in the LiteLLM config by construction). Full `claude-*` /
520
+ * arbitrary `sr-*` ids typed by hand are refused — they need the live
521
+ * session to verify, so the operator is asked to re-issue after boot.
522
+ */
523
+ export function isOfflineTrustedModelToken(token: string): boolean {
524
+ if ((MODEL_ALIASES as readonly string[]).includes(token)) return true
525
+ if (token in SR_MODEL_ALIASES) return true
526
+ return Object.values(SR_MODEL_ALIASES).includes(token)
527
+ }
528
+
509
529
  export function expandSrAlias(arg: string): string {
510
530
  return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
511
531
  }
@@ -548,10 +568,67 @@ export function modelSelectCallbackData(label: string): string {
548
568
  return `${MODEL_CALLBACK_SELECT}${labelTag(label)}`
549
569
  }
550
570
 
551
- function busyReply(deps: Pick<ModelMenuDeps, 'escapeHtml'>): ModelMenuReply {
571
+ const BUSY_REFUSAL_TEXT =
572
+ '⏳ The agent is mid-turn — the model picker needs an idle prompt. Your tap was not applied.'
573
+
574
+ /**
575
+ * Busy-refusal detector (#3039). The gateway's queued-command drain applies a
576
+ * command believing the session is idle; if a new turn started in the race
577
+ * window the underlying handler still refuses with one of these texts. The
578
+ * drain matches on this and RE-ENQUEUES the command instead of stamping the
579
+ * refusal onto the ack card as a false final state — so no user-visible path
580
+ * ever ends at "try again".
581
+ */
582
+ export function isBusyRefusalText(text: string): boolean {
583
+ return text.includes('The agent is mid-turn')
584
+ }
585
+
586
+ /**
587
+ * Mid-turn `/model` menu (#3039): instead of refusing ("try again in a
588
+ * moment"), render a STATIC keyboard that needs no picker discovery — the
589
+ * alias rows + the external page. Every switch tap rides the gateway's
590
+ * mid-turn queue (ack → apply at idle → confirm), so opening the menu during
591
+ * a long turn still lets the operator lock in a choice.
592
+ */
593
+ function busyStaticMenu(
594
+ deps: Pick<ModelMenuDeps, 'escapeHtml'> & Pick<ModelCommandDeps, 'getAgentName'>,
595
+ page: ModelMenuPage,
596
+ ): ModelMenuReply {
597
+ const externalNames = externalModelNames([])
598
+ if (page === 'external') {
599
+ return {
600
+ text: [
601
+ `**Model — ${deps.escapeHtml(deps.getAgentName())}** · 🌐 External`,
602
+ '_Agent is mid-turn — taps are queued and apply the moment the turn ends._',
603
+ 'These models are **billed separately** via OpenRouter. Tap one to switch the **live session**:',
604
+ PERSIST_NOTE,
605
+ ].join('\n'),
606
+ html: true,
607
+ keyboard: externalPageKeyboard(externalNames),
608
+ }
609
+ }
610
+ const rows: ModelMenuKeyboardButton[][] = []
611
+ for (const alias of MODEL_ALIASES) {
612
+ if (alias === 'default') continue
613
+ rows.push([{
614
+ text: alias.charAt(0).toUpperCase() + alias.slice(1),
615
+ callback_data: `${MODEL_CALLBACK_ALIAS}${alias}`,
616
+ }])
617
+ }
618
+ rows.push([{ text: 'Default (configured)', callback_data: `${MODEL_CALLBACK_ALIAS}default` }])
619
+ if (externalNames.length > 0) {
620
+ rows.push([{ text: '🌐 External models ▸', callback_data: MODEL_CALLBACK_PAGE_EXTERNAL }])
621
+ }
622
+ rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
552
623
  return {
553
- text: '⏳ The agent is mid-turn — the model picker needs an idle prompt. Try again in a moment.',
624
+ text: [
625
+ `**Model — ${deps.escapeHtml(deps.getAgentName())}**`,
626
+ '_Agent is mid-turn, so the live picker can\u2019t be read right now — this is the quick list. A tap is queued and applies the moment the turn ends._',
627
+ 'Tap a model to switch the **live session**:',
628
+ PERSIST_NOTE,
629
+ ].join('\n'),
554
630
  html: true,
631
+ keyboard: rows,
555
632
  }
556
633
  }
557
634
 
@@ -654,7 +731,7 @@ export async function buildModelMenu(
654
731
  deps: ModelMenuDeps & ModelCommandDeps,
655
732
  page: ModelMenuPage = 'main',
656
733
  ): Promise<ModelMenuReply> {
657
- if (deps.isBusy()) return busyReply(deps)
734
+ if (deps.isBusy()) return busyStaticMenu(deps, page)
658
735
 
659
736
  const [discovered, quota, srNames] = await Promise.all([
660
737
  deps.discover(deps.getAgentName()),
@@ -734,6 +811,12 @@ export interface ModelCallbackOutcome {
734
811
  * "try again" line (which read as "nothing happened").
735
812
  */
736
813
  toastOnly?: boolean
814
+ /**
815
+ * True when this outcome is the mid-turn refusal (#3039). The queued-command
816
+ * drain re-enqueues on this instead of stamping the refusal onto the ack
817
+ * card as a false final state.
818
+ */
819
+ busyRefusal?: boolean
737
820
  /**
738
821
  * On a successful session switch, the live model name now running (parsed
739
822
  * from claude's confirmation, e.g. "Fable 5"). The gateway records this as
@@ -799,8 +882,9 @@ export async function handleModelMenuCallback(
799
882
  if (deps.isBusy()) {
800
883
  return {
801
884
  answer: '⏳ Agent is mid-turn — tap again when it’s idle',
802
- reply: busyReply(deps),
885
+ reply: { text: BUSY_REFUSAL_TEXT, html: true },
803
886
  toastOnly: true,
887
+ busyRefusal: true,
804
888
  }
805
889
  }
806
890
  let aliasResult: InjectResult
@@ -862,8 +946,9 @@ export async function handleModelMenuCallback(
862
946
  if (deps.isBusy()) {
863
947
  return {
864
948
  answer: '⏳ Agent is mid-turn — tap again when it’s idle',
865
- reply: busyReply(deps),
949
+ reply: { text: BUSY_REFUSAL_TEXT, html: true },
866
950
  toastOnly: true,
951
+ busyRefusal: true,
867
952
  }
868
953
  }
869
954
  try {
@@ -896,8 +981,9 @@ export async function handleModelMenuCallback(
896
981
  if (deps.isBusy()) {
897
982
  return {
898
983
  answer: '⏳ Agent is mid-turn — tap again when it’s idle',
899
- reply: busyReply(deps),
984
+ reply: { text: BUSY_REFUSAL_TEXT, html: true },
900
985
  toastOnly: true,
986
+ busyRefusal: true,
901
987
  }
902
988
  }
903
989
 
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Deterministic ack-queue-apply-confirm for the session-mutating Telegram
3
+ * commands (`/model`, `/effort`).
4
+ *
5
+ * The bug this closes (#TODO): a `/model` or `/effort` command issued while
6
+ * the agent is MID-TURN used to dead-end. The typed `/model <name>` path
7
+ * refused with "⏳ … Try again in a moment." and dropped the request; the
8
+ * menu taps toasted the same refusal; `/effort` had no busy gate at all and
9
+ * silently maybe-failed ("sent, but couldn't confirm it applied"). In every
10
+ * case the operator's choice was lost — they had to notice and re-issue it.
11
+ *
12
+ * The contract now, consistent across BOTH commands and BOTH their typed and
13
+ * menu surfaces:
14
+ *
15
+ * 1. immediately APPLICABLE (session idle) → apply + confirm, unchanged.
16
+ * 2. session MID-TURN → immediately ACK ("📥 … the moment this turn
17
+ * finishes"), deterministically QUEUE the request, then APPLY the moment
18
+ * the agent goes idle and EDIT the ack card into the confirmation.
19
+ *
20
+ * This is a PER-KIND slot store (one slot for `model`, one for `effort`):
21
+ * same-kind last-write-wins — a rapid `/model fable` then `/model opus`
22
+ * queues only the latest (the earlier ack card is edited to "superseded") —
23
+ * while cross-kind requests coexist (a queued `/effort` never displaces a
24
+ * queued `/model`; both apply at idle). It reuses the same idle-gate
25
+ * discipline as `pendingRestarts` / proactive `/compact` — drained at the
26
+ * model-idle gate (`activeTurnStartedAt.size === 0`) with a bounded reaper
27
+ * fallback so a session that never cleanly idles still applies-or-reports.
28
+ * The reaper cap DEFERS while a turn is genuinely in flight (see
29
+ * `drainCapDecision`): forcing mid-turn would hit the typed handler's busy
30
+ * refusal (dropping the choice) or type into the live claude input.
31
+ *
32
+ * The apply itself (running the real handler, recording the session-model
33
+ * override, editing the ack card) lives in the gateway — this module is the
34
+ * pure slot + the ack/confirm/superseded text so it is unit-testable without
35
+ * booting the bot. Mirrors the split shape of model-command.ts /
36
+ * effort-command.ts.
37
+ */
38
+
39
+ export type PendingCommandKind = 'model' | 'effort'
40
+ export type PendingCommandOrigin = 'typed' | 'menu'
41
+
42
+ export interface PendingSessionCommand {
43
+ kind: PendingCommandKind
44
+ /**
45
+ * How the request arrived — `typed` for `/model <name>` / `/effort <level>`,
46
+ * `menu` for an inline-keyboard tap. Determines which handler the drain
47
+ * replays (the typed handler vs the menu-callback handler).
48
+ */
49
+ origin: PendingCommandOrigin
50
+ /**
51
+ * The apply payload the drain replays verbatim:
52
+ * - typed model → the (alias-expanded) model token, e.g. `fable`, `sr-glm-5`
53
+ * - typed effort → the effort level, e.g. `high`
54
+ * - menu → the raw callback data (`mdl:s:<tag>` / `mdl:alias:<a>` /
55
+ * `mdl:sr:<name>` / `eff:s:<level>`), replayed through the
56
+ * menu-callback handler which re-discovers at idle.
57
+ */
58
+ arg: string
59
+ /**
60
+ * Human-facing label for the ack / superseded text (e.g. `fable`, `high`,
61
+ * `Gemini 2.5 Pro`). Display-only — never fed to `claude --model`.
62
+ */
63
+ targetLabel: string
64
+ /** The chat the command came from (for building model deps on the deferred apply). */
65
+ chatId: string
66
+ /** The forum topic, if any. */
67
+ threadId?: number
68
+ /** The chat the ack card was posted to (usually === chatId). */
69
+ ackChatId: string
70
+ /** The ack card's message id — the drain EDITS this into the confirmation. */
71
+ ackMessageId: number
72
+ requestedAt: number
73
+ }
74
+
75
+ export interface PendingSessionCommandSlots {
76
+ /** The queued command of that kind, or null when its slot is empty. */
77
+ get(kind: PendingCommandKind): PendingSessionCommand | null
78
+ /**
79
+ * Enqueue. SAME-KIND last-write-wins: returns the same-kind command it
80
+ * DISPLACED (if any) so the caller can edit that stale ack card into a
81
+ * "superseded" note. CROSS-KIND commands coexist — a `/effort` never
82
+ * displaces a queued `/model` and vice versa.
83
+ */
84
+ set(cmd: PendingSessionCommand): PendingSessionCommand | null
85
+ /** Atomically remove and return the queued command of that kind. */
86
+ take(kind: PendingCommandKind): PendingSessionCommand | null
87
+ /**
88
+ * Atomically remove and return ALL queued commands in enqueue order (a
89
+ * same-kind re-enqueue moves to the back). The drain's and shutdown's read.
90
+ */
91
+ takeAll(): PendingSessionCommand[]
92
+ /** Snapshot of every queued command WITHOUT removing (the reaper's overdue check). */
93
+ list(): PendingSessionCommand[]
94
+ /** Drop every queued command without applying it. */
95
+ clear(): void
96
+ /** 0..2 — mirrors the `pendingRestarts.size` gate shape. */
97
+ readonly size: number
98
+ }
99
+
100
+ /** Create an empty per-kind slot store. */
101
+ export function createPendingSessionCommandSlots(): PendingSessionCommandSlots {
102
+ const slots = new Map<PendingCommandKind, PendingSessionCommand>()
103
+ return {
104
+ get: (kind: PendingCommandKind) => slots.get(kind) ?? null,
105
+ set(cmd: PendingSessionCommand): PendingSessionCommand | null {
106
+ const displaced = slots.get(cmd.kind) ?? null
107
+ // Delete-then-set so a same-kind re-enqueue moves to the BACK of the
108
+ // enqueue order — takeAll applies in latest-request order.
109
+ slots.delete(cmd.kind)
110
+ slots.set(cmd.kind, cmd)
111
+ return displaced
112
+ },
113
+ take(kind: PendingCommandKind): PendingSessionCommand | null {
114
+ const cur = slots.get(kind) ?? null
115
+ slots.delete(kind)
116
+ return cur
117
+ },
118
+ takeAll(): PendingSessionCommand[] {
119
+ const all = [...slots.values()]
120
+ slots.clear()
121
+ return all
122
+ },
123
+ list: () => [...slots.values()],
124
+ clear(): void {
125
+ slots.clear()
126
+ },
127
+ get size(): number {
128
+ return slots.size
129
+ },
130
+ }
131
+ }
132
+
133
+ /**
134
+ * IO seams the drain iteration needs from the gateway. Extracted so the
135
+ * iteration ORDER + loss-safety invariants (#3042 blocker 1) are unit-testable
136
+ * without booting the bot.
137
+ */
138
+ export interface DrainIo {
139
+ /** Is a session relaunch pending (the live session is going away)? */
140
+ restartPending: () => boolean
141
+ /** Is a turn in flight right now (re-checked per command)? */
142
+ turnInFlight: () => boolean
143
+ /** Apply the command via the real handler; returns the reply body. */
144
+ apply: (cmd: PendingSessionCommand) => Promise<string>
145
+ /** Does this reply body mean "refused because busy" (not applied)? */
146
+ isBusyRefusal: (text: string) => boolean
147
+ /** Resolve a command that must ride the restart carriers; returns card text. */
148
+ resolveForRestartText: (cmd: PendingSessionCommand) => string
149
+ /** Edit the command's ack card. Best-effort. */
150
+ editCard: (cmd: PendingSessionCommand, text: string) => Promise<void>
151
+ /** Re-enqueue a command the drain could not safely apply. */
152
+ reEnqueue: (cmd: PendingSessionCommand) => void
153
+ /** Render the apply-threw failure card text. */
154
+ failureText: (cmd: PendingSessionCommand, err: unknown) => string
155
+ }
156
+
157
+ /**
158
+ * Drain iteration over the commands takeAll() returned. The loss-safety
159
+ * invariant (#3042 blocker 1): when the loop must stop early (turn raced in,
160
+ * or the handler returned a busy refusal), EVERY not-yet-applied taken
161
+ * command — including the current one — is re-enqueued. takeAll() can hold
162
+ * both a model AND an effort command; breaking after re-enqueueing only the
163
+ * current one would silently drop the other with its ack card stuck at
164
+ * "queued" forever.
165
+ */
166
+ export async function drainTakenCommands(
167
+ taken: readonly PendingSessionCommand[],
168
+ io: DrainIo,
169
+ ): Promise<void> {
170
+ for (let i = 0; i < taken.length; i++) {
171
+ const cmd = taken[i]
172
+ if (io.restartPending()) {
173
+ // The live session is going away — carry the choice across the bounce
174
+ // via the durable carriers (or the re-issue fallback).
175
+ await io.editCard(cmd, io.resolveForRestartText(cmd))
176
+ continue
177
+ }
178
+ // Race guard: a new turn may have started before the drain reached this
179
+ // command. Applying now would hit the handlers' busy refusals and stamp
180
+ // "not applied" onto the ack card as a false final state.
181
+ if (io.turnInFlight()) {
182
+ for (const rest of taken.slice(i)) io.reEnqueue(rest)
183
+ return
184
+ }
185
+ let body: string
186
+ try {
187
+ body = await io.apply(cmd)
188
+ } catch (err) {
189
+ await io.editCard(cmd, io.failureText(cmd, err))
190
+ continue
191
+ }
192
+ // The handler itself refused because a turn raced in — not applied.
193
+ if (io.isBusyRefusal(body)) {
194
+ for (const rest of taken.slice(i)) io.reEnqueue(rest)
195
+ return
196
+ }
197
+ await io.editCard(cmd, body)
198
+ }
199
+ }
200
+
201
+ /**
202
+ * What the reaper's bounded drain-cap should do this tick.
203
+ *
204
+ * - 'wait' — nothing queued long enough; keep waiting.
205
+ * - 'defer-turn-in-flight' — something IS overdue, but a turn is genuinely
206
+ * in flight. Forcing a drain now would destroy the queued command: the
207
+ * typed model path hits handleModelCommand's busy gate (the old "try
208
+ * again" refusal, choice dropped) and /effort would type into the live
209
+ * claude input. The cap only rescues the MISSED-IDLE-GATE case, so keep
210
+ * waiting — the idle gate drains at turn end.
211
+ * - 'force' — overdue AND idle: the idle gate was missed;
212
+ * force the apply-or-report so the ack card never dangles unresolved.
213
+ */
214
+ export type DrainCapDecision = 'wait' | 'defer-turn-in-flight' | 'force'
215
+
216
+ export function drainCapDecision(
217
+ queued: readonly PendingSessionCommand[],
218
+ now: number,
219
+ capMs: number,
220
+ turnInFlight: boolean,
221
+ ): DrainCapDecision {
222
+ const overdue = queued.some(c => now - c.requestedAt > capMs)
223
+ if (!overdue) return 'wait'
224
+ return turnInFlight ? 'defer-turn-in-flight' : 'force'
225
+ }
226
+
227
+ /** One best-effort ack-card edit the caller should perform. */
228
+ export interface PendingCommandCardEdit {
229
+ chatId: string
230
+ messageId: number
231
+ text: string
232
+ }
233
+
234
+ /**
235
+ * What the gateway should DURABLY record for a queued command that can't
236
+ * apply live because the session is going away (gateway shutdown or a pending
237
+ * session relaunch). #3039: instead of telling the operator to re-issue, the
238
+ * choice is persisted to the durable carriers (`.session-model` /
239
+ * `.session-effort`) that start.sh honors on every boot — so the queued
240
+ * command still deterministically applies, just via the relaunch.
241
+ *
242
+ * - 'model' — persist `arg` as the `.session-model` override
243
+ * - 'effort' — persist `arg` as the `.session-effort` override
244
+ * - 'clear-model' — the queued command was `/model default`: clear the carrier
245
+ * - 'clear-effort' — the queued command was `/effort default`: clear the carrier
246
+ * - null — not offline-resolvable (a `mdl:s:<tag>` menu selection
247
+ * needs live picker discovery to become a token); the
248
+ * operator is asked to re-issue after boot.
249
+ */
250
+ export type ShutdownPersistKind = 'model' | 'effort' | 'clear-model' | 'clear-effort' | null
251
+
252
+ export interface ShutdownResolutionAction {
253
+ cmd: PendingSessionCommand
254
+ persist: ShutdownPersistKind
255
+ /** The canonical token / allowlisted level to persist (when persist != null). */
256
+ arg: string
257
+ /** Ack-card edit when the durable write succeeds. */
258
+ persistedText: string
259
+ /** Ack-card edit when persist is null or the durable write failed. */
260
+ reissueText: string
261
+ }
262
+
263
+ const EFFORT_MENU_SELECT_PREFIX = 'eff:s:'
264
+
265
+ function classifyShutdownPersist(cmd: PendingSessionCommand): { persist: ShutdownPersistKind; arg: string } {
266
+ if (cmd.kind === 'effort') {
267
+ const level = cmd.origin === 'menu' && cmd.arg.startsWith(EFFORT_MENU_SELECT_PREFIX)
268
+ ? cmd.arg.slice(EFFORT_MENU_SELECT_PREFIX.length)
269
+ : cmd.arg
270
+ if (level === 'default') return { persist: 'clear-effort', arg: level }
271
+ return { persist: 'effort', arg: level }
272
+ }
273
+ // model
274
+ if (cmd.origin === 'menu') return { persist: null, arg: cmd.arg } // mdl:s:<tag> — needs live discovery
275
+ if (cmd.arg === 'default') return { persist: 'clear-model', arg: cmd.arg }
276
+ return { persist: 'model', arg: cmd.arg }
277
+ }
278
+
279
+ function persistedText(cmd: PendingSessionCommand, escapeHtml: (s: string) => string): string {
280
+ const noun = KIND_NOUN[cmd.kind]
281
+ const isClear = cmd.arg === 'default' || cmd.targetLabel === 'default'
282
+ if (isClear) {
283
+ return `💾 The session is restarting — your ${noun} override was cleared as requested; the agent boots on the configured default.`
284
+ }
285
+ return `💾 The session is restarting — your \`${escapeHtml(cmd.targetLabel)}\` ${noun} choice is saved and applies as the agent boots.`
286
+ }
287
+
288
+ /**
289
+ * Empty the slots and return, per queued command, what to persist and which
290
+ * ack-card edit to make. The gateway performs the durable writes (this module
291
+ * stays fs-free and unit-testable) and edits with `persistedText` on success
292
+ * or `reissueText` on a null/failed persist. Shared by the SIGTERM shutdown
293
+ * handler AND the drain's pending-restart branch — a queued choice is never
294
+ * dropped with a bare "re-issue" when it can be carried across the bounce.
295
+ */
296
+ export function shutdownResolutionActions(
297
+ slots: PendingSessionCommandSlots,
298
+ escapeHtml: (s: string) => string,
299
+ ): ShutdownResolutionAction[] {
300
+ return slots.takeAll().map(cmd => resolveForRestart(cmd, escapeHtml))
301
+ }
302
+
303
+ /** Single-command form of shutdownResolutionActions (the drain's pending-restart branch). */
304
+ export function resolveForRestart(
305
+ cmd: PendingSessionCommand,
306
+ escapeHtml: (s: string) => string,
307
+ ): ShutdownResolutionAction {
308
+ const { persist, arg } = classifyShutdownPersist(cmd)
309
+ return {
310
+ cmd,
311
+ persist,
312
+ arg,
313
+ persistedText: persistedText(cmd, escapeHtml),
314
+ reissueText: restartSupersededText(cmd, escapeHtml),
315
+ }
316
+ }
317
+
318
+ const KIND_NOUN: Record<PendingCommandKind, string> = {
319
+ model: 'model',
320
+ effort: 'effort',
321
+ }
322
+
323
+ const KIND_VERB: Record<PendingCommandKind, string> = {
324
+ model: 'switch to',
325
+ effort: 'set effort to',
326
+ }
327
+
328
+ /**
329
+ * The immediate ACK shown when a command is queued behind a live turn. Names
330
+ * the target so the operator sees their choice was captured, not dropped.
331
+ * `escapeHtml` guards the (already shape-gated) label for the HTML send path.
332
+ */
333
+ export function ackText(
334
+ kind: PendingCommandKind,
335
+ targetLabel: string,
336
+ escapeHtml: (s: string) => string,
337
+ ): string {
338
+ return `📥 Agent is mid-turn — I'll ${KIND_VERB[kind]} \`${escapeHtml(targetLabel)}\` the moment this turn finishes.`
339
+ }
340
+
341
+ /**
342
+ * Edited onto a stale ack card when a NEWER command of the same slot displaces
343
+ * it (last-write-wins). Tells the operator their earlier choice was replaced,
344
+ * not silently lost.
345
+ */
346
+ export function supersededText(
347
+ displaced: PendingSessionCommand,
348
+ next: PendingSessionCommand,
349
+ escapeHtml: (s: string) => string,
350
+ ): string {
351
+ return `↩️ Superseded — queued \`${escapeHtml(next.targetLabel)}\` instead (your earlier \`${escapeHtml(displaced.targetLabel)}\` ${KIND_NOUN[displaced.kind]} request was replaced before it applied).`
352
+ }
353
+
354
+ /**
355
+ * Edited onto the ack card when the queued command could not apply because the
356
+ * session is restarting (a Claude-model / effort session change does not
357
+ * survive the bounce). The operator is told to re-issue after boot rather than
358
+ * being left believing it landed.
359
+ */
360
+ export function restartSupersededText(
361
+ cmd: PendingSessionCommand,
362
+ escapeHtml: (s: string) => string,
363
+ ): string {
364
+ return `↩️ The session is restarting before your \`${escapeHtml(cmd.targetLabel)}\` ${KIND_NOUN[cmd.kind]} change could apply — re-issue \`/${cmd.kind} ${escapeHtml(cmd.targetLabel)}\` once the agent is back.`
365
+ }
@@ -150,6 +150,31 @@ export function buildTimedOutCardEdits(
150
150
  }))
151
151
  }
152
152
 
153
+ /**
154
+ * Suffix appended to a card body when its turn is halted by the operator
155
+ * (#3020 `/stop` / bare "stop" / empty `!`). Same keyboard-strip contract as
156
+ * the timeout path: the halted turn's MCP permission call is being denied,
157
+ * so a later Approve tap must not be able to dispatch into an idle session.
158
+ */
159
+ export const CANCELLED_FOOTER = '\n\n⏹ Cancelled — the turn was stopped'
160
+
161
+ /**
162
+ * Build the (pure) list of card edits the halt path applies when the
163
+ * operator stops the in-flight turn: re-render each recorded card with the
164
+ * cancelled footer and strip the keyboard. Mirrors buildTimedOutCardEdits.
165
+ */
166
+ export function buildCancelledCardEdits(
167
+ cardText: string,
168
+ cards: readonly PermissionCardRef[],
169
+ ): TimedOutCardEdit[] {
170
+ return cards.map(({ chatId, messageId }) => ({
171
+ chatId,
172
+ messageId,
173
+ text: `${cardText}${CANCELLED_FOOTER}`,
174
+ stripKeyboard: true,
175
+ }))
176
+ }
177
+
153
178
  /**
154
179
  * A tap on a permission card is STALE when no pending entry exists for its
155
180
  * request_id — the reaper already auto-denied + deleted it on TTL. A stale
@@ -140,6 +140,20 @@ export interface ResumeInboundContext {
140
140
  * Rendered into the inbound so the resumed session knows what to
141
141
  * re-dispatch. Omitted / empty → the inbound is unchanged. */
142
142
  subagents?: InterruptedSubagent[]
143
+ /** Why the framework itself triggered the restart, when it did (e.g. the
144
+ * bridge-dead escalation, #3038). Surfaced verbatim in the inbound text
145
+ * (`note`) and as `meta.restart_cause` (`reason`) so the resume is
146
+ * HONEST about the cause — a bridge-dead bounce must not read as an
147
+ * operator restart or masquerade as a watchdog timeout. Omitted → the
148
+ * inbound is unchanged (the common human-restart/crash case). */
149
+ restartCause?: { reason: string; note: string }
150
+ }
151
+
152
+ /** Render the optional framework-restart-cause block appended to a resume /
153
+ * report inbound. Empty string when no cause was recorded. */
154
+ function renderRestartCauseBlock(cause: { note: string } | undefined): string {
155
+ if (!cause || cause.note.trim().length === 0) return ''
156
+ return `\n\nWhy this restart happened: ${cause.note.trim()}`
143
157
  }
144
158
 
145
159
  /**
@@ -223,6 +237,7 @@ export function buildResumeInterruptedInbound(ctx: ResumeInboundContext): Inboun
223
237
  started_at: String(ctx.turn.started_at),
224
238
  }
225
239
  if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
240
+ if (ctx.restartCause) meta.restart_cause = ctx.restartCause.reason
226
241
  return {
227
242
  type: 'inbound',
228
243
  chatId: ctx.turn.chat_id,
@@ -247,7 +262,8 @@ export function buildResumeInterruptedInbound(ctx: ResumeInboundContext): Inboun
247
262
  `actual task. Do not ask whether to resume; just resume. If even after ` +
248
263
  `reading the recent messages you genuinely can't tell what the work was, ` +
249
264
  `say so and ask.` +
250
- renderInterruptedSubagentsBlock(ctx.subagents),
265
+ renderInterruptedSubagentsBlock(ctx.subagents) +
266
+ renderRestartCauseBlock(ctx.restartCause),
251
267
  meta,
252
268
  }
253
269
  }
@@ -287,6 +303,7 @@ export function buildResumeWatchdogReportInbound(
287
303
  }
288
304
  if (ctx.turn.tool_call_count != null) meta.tool_call_count = String(ctx.turn.tool_call_count)
289
305
  if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
306
+ if (ctx.restartCause) meta.restart_cause = ctx.restartCause.reason
290
307
  return {
291
308
  type: 'inbound',
292
309
  chatId: ctx.turn.chat_id,
@@ -309,7 +326,8 @@ export function buildResumeWatchdogReportInbound(
309
326
  `speculate about a deeper root cause you can't see.` +
310
327
  // Deferred (non-assertive) form: this is the ask-first path — the killed
311
328
  // workers are listed as facts, but re-dispatch waits on the user's call.
312
- renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }),
329
+ renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }) +
330
+ renderRestartCauseBlock(ctx.restartCause),
313
331
  meta,
314
332
  }
315
333
  }
@@ -398,6 +416,7 @@ export function buildResumeDeferredReportInbound(
398
416
  started_at: String(ctx.turn.started_at),
399
417
  }
400
418
  if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
419
+ if (ctx.restartCause) meta.restart_cause = ctx.restartCause.reason
401
420
  const cause =
402
421
  ctx.reason === 'loop-guard'
403
422
  ? `Your previous turn was ALREADY a resume of earlier interrupted work, ` +
@@ -428,7 +447,8 @@ export function buildResumeDeferredReportInbound(
428
447
  // Deferred (non-assertive) form, same as the watchdog path: this is an
429
448
  // ask-first inbound — killed workers are named as facts, but
430
449
  // re-dispatch waits on the user's call.
431
- renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }),
450
+ renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }) +
451
+ renderRestartCauseBlock(ctx.restartCause),
432
452
  meta,
433
453
  }
434
454
  }