switchroom 0.18.8 → 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 (93) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-scheduler/index.js +1 -0
  3. package/dist/auth-broker/index.js +198 -13
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +28 -4
  6. package/dist/host-control/main.js +3 -2
  7. package/dist/vault/approvals/kernel-server.js +2 -1
  8. package/dist/vault/broker/server.js +2 -1
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +119 -37
  11. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  12. package/skills/dev-protocol/SKILL.md +131 -0
  13. package/telegram-plugin/README.md +2 -1
  14. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  15. package/telegram-plugin/admin-commands/index.ts +6 -1
  16. package/telegram-plugin/bridge/bridge.ts +23 -1
  17. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  18. package/telegram-plugin/chat-lock.ts +13 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
  21. package/telegram-plugin/dist/server.js +29 -2
  22. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  23. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  24. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  25. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  26. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  27. package/telegram-plugin/gateway/effort-command.ts +47 -3
  28. package/telegram-plugin/gateway/gateway.ts +2051 -3180
  29. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  30. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  31. package/telegram-plugin/gateway/model-command.ts +94 -8
  32. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  33. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  34. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  35. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  36. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  37. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  38. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  39. package/telegram-plugin/gateway/stop-command.ts +56 -0
  40. package/telegram-plugin/photo-precheck.ts +201 -0
  41. package/telegram-plugin/quota-watch.ts +141 -2
  42. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  43. package/telegram-plugin/registry/subagents.test.ts +67 -0
  44. package/telegram-plugin/retry-api-call.ts +31 -0
  45. package/telegram-plugin/subagent-watcher.ts +392 -1
  46. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  47. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  48. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  49. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  50. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  51. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  52. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  53. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  54. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  55. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  56. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  57. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  58. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  59. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  60. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  61. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  62. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  63. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  64. package/telegram-plugin/tests/model-command.test.ts +46 -3
  65. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  66. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  67. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  68. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  69. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  70. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  71. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  72. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  73. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  74. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  75. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  76. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  77. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  78. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  79. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  80. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  81. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  82. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  83. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  84. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  85. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  86. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  87. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  88. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  89. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  90. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  91. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  92. package/telegram-plugin/welcome-text.ts +4 -3
  93. package/telegram-plugin/worker-activity-feed.ts +27 -0
@@ -7,17 +7,41 @@
7
7
  * (b) execute the returned effects against real I/O. This module owns
8
8
  * step (b) for the cutover.
9
9
  *
10
- * Scope of THIS PR bridgeUp only:
11
- * - drainBuffer → executed
12
- * - redeliverPersistedPermVerdicts → executed
10
+ * Effects wired to real executors (all effect kinds now handled):
11
+ * - drainBuffer → redeliverBufferedInbound
12
+ * - redeliverPersistedPermVerdicts → pendingPermissionBuffer.drain → send
13
+ * - deliverToBridge → client.send / ipcServer.sendToAgent
14
+ * - bufferInbound → pendingInboundBuffer.push
15
+ * - persistInbound → inboundSpool.put
16
+ * - deliverPermVerdict → client.send / ipcServer.sendToAgent
17
+ * - persistPermVerdict → pendingPermissionBuffer.push
18
+ * - setTurnStarted / clearTurnStarted → onSetTurnStarted / onClearTurnStarted
19
+ * - noteOutbound → onNoteOutbound
20
+ * - firePoke → onFirePoke
13
21
  * - logTrace → executed
14
22
  *
15
- * Other effects (deliverToBridge, bufferInbound, persistInbound,
16
- * setTurnStarted, clearTurnStarted, noteOutbound, firePoke,
17
- * deliverPermVerdict, persistPermVerdict) still flow through their
18
- * existing imperative paths in `gateway.ts`. The dispatcher logs them
19
- * as `not-yet-cutover` so a future PR can wire them without grep-and-
20
- * pray. NEVER silently no-op: the trace is the gate.
23
+ * Live call sites (post PR3c flip, #2794):
24
+ * - bridgeUp (gateway onClientRegistered): drainBuffer /
25
+ * redeliverPersistedPermVerdicts / logTrace.
26
+ * - handleInbound DEFERRED_INBOUND_EMIT: the inbound-routing effects
27
+ * (deliverToBridge / bufferInbound / persistInbound / setTurnStarted)
28
+ * are AUTHORITATIVE when the cutover is on — the imperative twin in
29
+ * gateway.ts runs only under the kill switch or the documented
30
+ * carve-outs (interrupt-while-in-turn, bridge_dead). The twin is
31
+ * deleted in PR4 after the 48h bake (see the "Hard removal plan"
32
+ * checklist in the RFC: PR3b step 2 / PR4 remain open).
33
+ * - turnEnd lifecycle effects remain shadow-only: the imperative
34
+ * turn-end sites (purgeReactionTracking / releaseTurnBufferGate)
35
+ * already execute the effect-equivalents with gates the machine does
36
+ * not model yet (serialize-until-replied drain); PR3b step 2 tracks
37
+ * that flip.
38
+ *
39
+ * The gateway-internal turn/poke effects (setTurnStarted,
40
+ * clearTurnStarted, noteOutbound, firePoke) map to gateway-scope state
41
+ * (`claudeBusyKeys`, the silence-poke ladder) that this decoupled module
42
+ * cannot reach directly, so they are dispatched through OPTIONAL
43
+ * callbacks on the ctx. When a callback is absent the effect logs an
44
+ * `unwired` trace rather than silently no-opping — the trace is the gate.
21
45
  *
22
46
  * Kill switch: `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` disables
23
47
  * dispatcher execution and the gateway falls back to imperative-only.
@@ -26,10 +50,11 @@
26
50
 
27
51
  import type {
28
52
  Effect,
53
+ ChatKey,
29
54
  InboundMessage as MachineInboundMessage,
30
55
  } from './inbound-delivery-machine.js'
31
56
  import type { IpcServer, IpcClient } from './ipc-server.js'
32
- import type { InboundMessage } from './ipc-protocol.js'
57
+ import type { InboundMessage, PermissionEvent } from './ipc-protocol.js'
33
58
  import type { PendingInboundBuffer } from './pending-inbound-buffer.js'
34
59
  import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
35
60
  import type { InboundSpool } from './inbound-spool.js'
@@ -54,6 +79,26 @@ export interface DispatchCtx {
54
79
  * (clerk lost-message incident, 2026-06-03.)
55
80
  */
56
81
  readonly onUserInboundDelivered?: (merged: InboundMessage) => void
82
+ // ── Gateway-internal turn/poke effect callbacks ──────────────────
83
+ // These effects map to gateway-scope state (`claudeBusyKeys`, the
84
+ // silence-poke ladder) that this decoupled module cannot reach. The
85
+ // gateway supplies them when it flips inbound routing through the
86
+ // machine (PR3c). Absent → the effect logs an `unwired` trace.
87
+ readonly onSetTurnStarted?: (key: ChatKey, at: number) => void
88
+ /**
89
+ * Optional: observe the outcome of a `deliverToBridge` effect. The
90
+ * imperative twin branches on `sendToAgent`'s boolean (delivered →
91
+ * steer-ack + busy-mark + delivery-confirm tracking; miss → release
92
+ * reservation + durable-buffer + restart notice). The machine cannot
93
+ * model a send that fails while the bridge is nominally alive, so the
94
+ * PR3c live call site needs the result to run the same post-send
95
+ * branches the twin runs. Called once per deliverToBridge effect,
96
+ * after the send attempt, before `onUserInboundDelivered` enrolment.
97
+ */
98
+ readonly onDeliverResult?: (key: ChatKey, ok: boolean, msg: InboundMessage) => void
99
+ readonly onClearTurnStarted?: (key: ChatKey) => void
100
+ readonly onNoteOutbound?: (key: ChatKey, at: number) => void
101
+ readonly onFirePoke?: (key: ChatKey, level: 'soft' | 'firm' | 'fallback') => void
57
102
  }
58
103
 
59
104
  const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
@@ -168,20 +213,133 @@ function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
168
213
  return
169
214
  }
170
215
 
171
- // The cases below are KNOWN effect kinds that this PR does NOT
172
- // cut over. The imperative paths still run for them; the
173
- // dispatcher logs the event so future cutover PRs can grep for
174
- // exactly the call sites to migrate.
175
- case 'deliverToBridge':
176
- case 'bufferInbound':
177
- case 'persistInbound':
178
- case 'setTurnStarted':
179
- case 'clearTurnStarted':
180
- case 'noteOutbound':
181
- case 'firePoke':
182
- case 'deliverPermVerdict':
216
+ case 'deliverToBridge': {
217
+ // Route a single inbound to the live bridge. The machine's
218
+ // InboundMessage.payload carries the real ipc-protocol
219
+ // InboundMessage; the machine treats it as opaque.
220
+ const msg = effect.msg.payload as InboundMessage
221
+ let ok = false
222
+ try {
223
+ if (ctx.client) {
224
+ ctx.client.send(msg)
225
+ ok = true
226
+ } else {
227
+ ok = ctx.ipcServer.sendToAgent(ctx.selfAgent, msg)
228
+ }
229
+ } catch (err) {
230
+ log(
231
+ `telegram gateway: dispatch deliverToBridge send threw agent=${ctx.selfAgent} ` +
232
+ `key=${effect.key}: ${(err as Error).message}\n`,
233
+ )
234
+ ok = false
235
+ }
236
+ if (ctx.onDeliverResult) {
237
+ try {
238
+ ctx.onDeliverResult(effect.key, ok, msg)
239
+ } catch {
240
+ /* observer is best-effort; never breaks delivery */
241
+ }
242
+ }
243
+ if (ok && ctx.onUserInboundDelivered) {
244
+ // Enrol in the deliver-until-acked sweep — a socket write is not
245
+ // proof claude consumed it (see onUserInboundDelivered doc).
246
+ // Pass UNCONDITIONALLY, mirroring the drainBuffer path above:
247
+ // the callback self-gates via shouldTrackDelivery (inbound-
248
+ // delivery-confirm.ts), which REJECTS sourced messages and
249
+ // tracks real user inbounds (meta.source undefined) — exactly
250
+ // the messages the sweep protects. Do NOT pre-filter on
251
+ // meta.source here; an inverted guard would skip user inbounds
252
+ // and enrol only messages the inner gate discards.
253
+ try {
254
+ ctx.onUserInboundDelivered(msg)
255
+ } catch {
256
+ /* enrolment is best-effort; never breaks delivery */
257
+ }
258
+ }
259
+ log(`gw-trace dispatch deliverToBridge key=${effect.key} ok=${ok}\n`)
260
+ return
261
+ }
262
+
263
+ case 'bufferInbound': {
264
+ const msg = effect.msg.payload as InboundMessage
265
+ const buffered = ctx.pendingInboundBuffer.push(ctx.selfAgent, msg)
266
+ log(`gw-trace dispatch bufferInbound key=${effect.key} buffered=${buffered}\n`)
267
+ return
268
+ }
269
+
270
+ case 'persistInbound': {
271
+ // Durable spool. `pendingInboundBuffer.push` already spools when a
272
+ // spool is attached to the buffer, so a persistInbound paired with
273
+ // a bufferInbound is idempotent (put() is idempotent by spoolId).
274
+ const msg = effect.msg.payload as InboundMessage
275
+ const put = ctx.inboundSpool ? ctx.inboundSpool.put(ctx.selfAgent, msg) : false
276
+ log(`gw-trace dispatch persistInbound key=${effect.key} put=${put}\n`)
277
+ return
278
+ }
279
+
280
+ case 'deliverPermVerdict': {
281
+ // Branch order differs from the imperative twin
282
+ // (gateway.ts dispatchPermissionVerdict), which always goes via
283
+ // `ipcServer.sendToAgent` — it runs outside any bridgeUp context,
284
+ // so it has no just-registered client handle. Here `ctx.client`
285
+ // is preferred WHEN PRESENT for the same reason as drainBuffer /
286
+ // redeliverPersistedPermVerdicts above: on the bridgeUp path the
287
+ // registry lookup may not yet observe the just-registered client,
288
+ // and a direct send to the connecting socket is the reliable
289
+ // route. PR3c's live call site for machine-driven permVerdict
290
+ // events (non-bridgeUp) must construct the ctx WITHOUT `client`,
291
+ // which makes this branch behave exactly like the twin
292
+ // (sendToAgent). Note the twin additionally buffers on a failed
293
+ // send; the machine models that as a separate persistPermVerdict
294
+ // effect (bridge_dead state), so no fallback push here.
295
+ const ev = effect.verdict.payload as PermissionEvent
296
+ let ok = false
297
+ try {
298
+ if (ctx.client) {
299
+ ctx.client.send(ev as never)
300
+ ok = true
301
+ } else {
302
+ ok = ctx.ipcServer.sendToAgent(ctx.selfAgent, ev as never)
303
+ }
304
+ } catch (err) {
305
+ log(
306
+ `telegram gateway: dispatch deliverPermVerdict send threw agent=${ctx.selfAgent} ` +
307
+ `request=${effect.verdict.requestId}: ${(err as Error).message}\n`,
308
+ )
309
+ ok = false
310
+ }
311
+ log(`gw-trace dispatch deliverPermVerdict request=${effect.verdict.requestId} ok=${ok}\n`)
312
+ return
313
+ }
314
+
183
315
  case 'persistPermVerdict': {
184
- log(`gw-trace dispatch not-yet-cutover effect=${effect.kind}\n`)
316
+ const ev = effect.verdict.payload as PermissionEvent
317
+ const pushed = ctx.pendingPermissionBuffer.push(ctx.selfAgent, ev)
318
+ log(`gw-trace dispatch persistPermVerdict request=${effect.verdict.requestId} pushed=${pushed}\n`)
319
+ return
320
+ }
321
+
322
+ case 'setTurnStarted': {
323
+ if (ctx.onSetTurnStarted) ctx.onSetTurnStarted(effect.key, effect.at)
324
+ else log(`gw-trace dispatch unwired effect=setTurnStarted key=${effect.key}\n`)
325
+ return
326
+ }
327
+
328
+ case 'clearTurnStarted': {
329
+ if (ctx.onClearTurnStarted) ctx.onClearTurnStarted(effect.key)
330
+ else log(`gw-trace dispatch unwired effect=clearTurnStarted key=${effect.key}\n`)
331
+ return
332
+ }
333
+
334
+ case 'noteOutbound': {
335
+ if (ctx.onNoteOutbound) ctx.onNoteOutbound(effect.key, effect.at)
336
+ else log(`gw-trace dispatch unwired effect=noteOutbound key=${effect.key}\n`)
337
+ return
338
+ }
339
+
340
+ case 'firePoke': {
341
+ if (ctx.onFirePoke) ctx.onFirePoke(effect.key, effect.level)
342
+ else log(`gw-trace dispatch unwired effect=firePoke key=${effect.key} level=${effect.level}\n`)
185
343
  return
186
344
  }
187
345
  }
@@ -261,6 +261,14 @@ export function transition(state: State, event: Event): Transition {
261
261
  const alive = state.global.kind !== 'bridge_dead'
262
262
 
263
263
  if (!alive) {
264
+ // ANCHORED STRING — `inbound_bridge_dead_buffer` is load-bearing:
265
+ // gateway.ts's PR3c bridge-dead carve-out keys off this exact stage
266
+ // string to route the inbound to the imperative twin (whose
267
+ // shouldTrackDelivery drop semantics + restart notice are the
268
+ // contract the machine doesn't model). Renaming it silently reroutes
269
+ // that carve-out; a pin test in inbound-delivery-cutover-flip.test.ts
270
+ // breaks on rename. Both the string key-off and this anchor are
271
+ // deleted in PR4 with the twin.
264
272
  return {
265
273
  state,
266
274
  effects: [
@@ -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