switchroom 0.18.6 → 0.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1172 -812
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +146 -50
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-runtime/SKILL.md +2 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  15. package/telegram-plugin/dist/gateway/gateway.js +2965 -862
  16. package/telegram-plugin/dist/server.js +24 -0
  17. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  18. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  19. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  20. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  21. package/telegram-plugin/gateway/boot-card.ts +27 -0
  22. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  23. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  24. package/telegram-plugin/gateway/gateway.ts +1618 -198
  25. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  26. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  27. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  28. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  29. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  30. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  31. package/telegram-plugin/gateway/model-command.ts +227 -54
  32. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  33. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  35. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  36. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  37. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  38. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  39. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  40. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  41. package/telegram-plugin/hooks/hooks.json +10 -10
  42. package/telegram-plugin/hooks/run-hook.sh +84 -0
  43. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  44. package/telegram-plugin/model-label.ts +69 -0
  45. package/telegram-plugin/model-unavailable.ts +26 -0
  46. package/telegram-plugin/operator-events.ts +24 -0
  47. package/telegram-plugin/permission-diff.ts +128 -0
  48. package/telegram-plugin/pty-partial-handler.ts +39 -0
  49. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  50. package/telegram-plugin/registry/subagents.test.ts +90 -0
  51. package/telegram-plugin/render/rich-render.ts +79 -1
  52. package/telegram-plugin/retry-api-call.ts +62 -0
  53. package/telegram-plugin/session-tail.ts +28 -0
  54. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  55. package/telegram-plugin/silence-poke.ts +14 -0
  56. package/telegram-plugin/silent-end.ts +49 -4
  57. package/telegram-plugin/stream-controller.ts +156 -38
  58. package/telegram-plugin/subagent-watcher.ts +222 -37
  59. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  60. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  61. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  62. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  63. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  64. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  65. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  66. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  67. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  68. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  69. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
  70. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  71. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  72. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  73. package/telegram-plugin/tests/model-command.test.ts +203 -43
  74. package/telegram-plugin/tests/model-label.test.ts +64 -0
  75. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  76. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  77. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  78. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  79. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  80. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  81. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  82. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  83. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  84. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  85. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  86. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  87. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  88. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  89. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  90. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  92. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  93. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  94. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  96. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  97. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  98. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  99. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  100. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  101. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  102. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  103. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  104. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  105. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  106. package/telegram-plugin/tool-activity-summary.ts +22 -2
  107. package/telegram-plugin/typing-wrap.ts +72 -25
  108. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  109. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  110. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  111. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  112. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  113. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  114. package/telegram-plugin/voice-ondemand.ts +25 -1
  115. package/telegram-plugin/voice-send.ts +154 -0
  116. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -111,6 +111,15 @@ export function parseModelCommand(text: string): ParsedModelCommand | null {
111
111
  export interface ModelCommandDeps {
112
112
  /** Inject primitive — wired to injectSlashCommand in the gateway. */
113
113
  inject: (agent: string, command: string) => Promise<InjectResult>
114
+ /**
115
+ * True while the agent is mid-turn. A typed `/model <name>` switch drives
116
+ * claude's session (either an inject into the input box, or a carrier-backed
117
+ * restart) — doing either mid-turn is unsafe: an inject queues `/model` as
118
+ * user text instead of switching, and a restart would tear down the live
119
+ * turn. So the set path refuses when this is true and asks the operator to
120
+ * retry, exactly like the menu callback path (`ModelMenuDeps.isBusy`).
121
+ */
122
+ isBusy: () => boolean
114
123
  getAgentName: () => string
115
124
  /**
116
125
  * The agent's configured model from `switchroom agent list` (the
@@ -140,7 +149,7 @@ export interface ModelCommandDeps {
140
149
  * Schedule a session-only switch TO a non-Claude (`sr-*` LiteLLM/OpenRouter)
141
150
  * model. claude's in-REPL `/model` picker rejects unknown `sr-*` ids, so an
142
151
  * inject can't set them. Instead the gateway writes the chosen token to the
143
- * `.session-model-override` carrier file and gracefully restarts the agent;
152
+ * durable `.session-model` override and gracefully restarts the agent;
144
153
  * the next boot launches `claude --model <token>` directly (LiteLLM routes
145
154
  * it, no picker validation). Session-only: reverts to the configured default
146
155
  * on the following restart. Wired to the same restart dispatch as
@@ -153,10 +162,20 @@ export interface ModelCommandDeps {
153
162
  export interface ModelCommandReply {
154
163
  text: string
155
164
  html: true
165
+ /**
166
+ * On a POSITIVELY-CONFIRMED typed switch, the model now running this session
167
+ * (parsed from claude's confirmation, falling back to the requested token).
168
+ * The gateway records this as the session-model override so `/status` reflects
169
+ * what's actually running — the SAME code path the menu callback uses via
170
+ * `ModelCallbackOutcome.selectedModel`. Absent on every unverified / non-switch
171
+ * outcome (silent capture, error, busy refusal) so an unconfirmed switch never
172
+ * lies to `/status`.
173
+ */
174
+ selectedModel?: string
156
175
  }
157
176
 
158
177
  const PERSIST_NOTE =
159
- '_Session-only lasts until restart. To persist, set \`model:\` in switchroom.yaml and restart._'
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._'
160
179
 
161
180
  function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
162
181
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' · ')
@@ -205,16 +224,37 @@ export async function handleModelCommand(
205
224
  // Expand short aliases: `flash` → `sr-gemini-2.5-flash`, `codex` → `sr-codex-5.5`, etc.
206
225
  const model = expandSrAlias(parsed.model)
207
226
 
227
+ // Busy gate: a typed switch either injects into claude's input box or triggers
228
+ // a carrier-backed restart. Both are unsafe mid-turn — an inject queues the
229
+ // `/model` text instead of switching, and a restart kills the live turn. Refuse
230
+ // and ask the operator to retry (parity with the menu callback's isBusy check).
231
+ if (deps.isBusy()) {
232
+ return {
233
+ text: '⏳ The agent is mid-turn — a model switch needs an idle session. Try again in a moment.',
234
+ html: true,
235
+ }
236
+ }
237
+
208
238
  // sr-* → Claude: an in-place `/model` inject would leave LiteLLM routing
209
239
  // active in the live session because the sr-* model context was set by
210
240
  // the proxy at session start, not by claude's own REPL. A graceful restart
211
- // is the only clean path back to the native OAuth route. This matches the
212
- // behaviour of the `/restart` command (same mechanism, same marker logic).
241
+ // is the only clean path back to the native OAuth route. Route it through the
242
+ // SAME carrier mechanism as a Claude sr-* switch (scheduleModelRelaunch)
243
+ // so the requested Claude model is written to the durable `.session-model` and
244
+ // survives the restart — otherwise boot launches the configured default and
245
+ // the operator's choice is silently dropped. start.sh's LiteLLM-down guard
246
+ // only special-cases `sr-*` overrides, so a Claude token is never dropped.
213
247
  const currentSession = deps.getActiveSessionModel()
214
248
  if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(model)) {
215
249
  try {
216
- await deps.scheduleRestart(`user: /model ${model} (sr-to-claude restart)`)
250
+ await deps.scheduleModelRelaunch(model, `user: /model ${model} (sr-to-claude restart)`)
217
251
  } catch (err) {
252
+ if (isRestartInFlight(err)) {
253
+ return {
254
+ text: `⏳ A restart is already in flight — your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
255
+ html: true,
256
+ }
257
+ }
218
258
  const msg = err instanceof Error ? err.message : String(err)
219
259
  return {
220
260
  text: `❌ Could not schedule restart: ${deps.escapeHtml(msg)}`,
@@ -239,6 +279,12 @@ export async function handleModelCommand(
239
279
  try {
240
280
  await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`)
241
281
  } catch (err) {
282
+ if (isRestartInFlight(err)) {
283
+ return {
284
+ text: `⏳ A restart is already in flight — your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
285
+ html: true,
286
+ }
287
+ }
242
288
  const msg = err instanceof Error ? err.message : String(err)
243
289
  return {
244
290
  text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`,
@@ -248,7 +294,7 @@ export async function handleModelCommand(
248
294
  return {
249
295
  text: [
250
296
  `Switching to \`${deps.escapeHtml(model)}\` — restarting session (~30s).`,
251
- '_Session-only — reverts to the configured default on the next restart._',
297
+ PERSIST_NOTE,
252
298
  ].join('\n'),
253
299
  html: true,
254
300
  }
@@ -267,17 +313,33 @@ export async function handleModelCommand(
267
313
  }
268
314
 
269
315
  if (result.outcome === 'ok') {
270
- // claude's `/model <name>` switches the session SILENTLY it does not
271
- // print a confirmation line. So `result.output` on this path is almost
272
- // always just whatever pane scrollback sat below the command echo (the
273
- // agent's previous prose answer). `isTuiChromeLine` strips borders/glyphs
274
- // but NOT ordinary prose, so blindly `preBlock`-ing `result.output` here
275
- // dumped that unrelated scrollback back to the user as a code block
276
- // (screenshot-confirmed on klanker, v0.16.47). Only relay output when it
277
- // actually looks like a model-switch acknowledgement; otherwise suppress
278
- // it and send a clean confirmation.
316
+ // claude's `/model <name>` either prints a "Set model to X" acknowledgement
317
+ // or switches SILENTLY (no line). `result.output` on the silent path is just
318
+ // whatever pane scrollback sat below the command echo (the agent's previous
319
+ // prose answer) — NOT a confirmation. We MUST NOT claim success on that
320
+ // scrollback, and we must never dump it back as a code block (screenshot-
321
+ // confirmed leak on klanker, v0.16.47).
322
+ //
323
+ // Honest reporting: (1) if claude printed an error ("Model not found" /
324
+ // "Invalid model"), the switch FAILED — say so. (2) if an anchored
325
+ // confirmation line is present, the switch is verified — relay it and record
326
+ // the live model for /status. (3) otherwise we cannot positively verify the
327
+ // switch (silent success or nothing) — say "sent, but couldn't confirm" and
328
+ // record NOTHING, so /status is never lied to.
329
+ const errLine = modelSwitchErrorLine(result.output)
330
+ if (errLine) {
331
+ return {
332
+ text: [
333
+ `❌ ${verbHtml} — the switch did not take:`,
334
+ deps.preBlock(errLine),
335
+ 'Check \`/model\` for valid model names.',
336
+ ].join('\n'),
337
+ html: true,
338
+ }
339
+ }
279
340
  const confirmation = modelSwitchConfirmationLine(result.output)
280
341
  if (confirmation) {
342
+ const confirmed = sessionModelFromConfirmation(confirmation) ?? model
281
343
  return {
282
344
  text: [
283
345
  `${verbHtml}`,
@@ -286,11 +348,14 @@ export async function handleModelCommand(
286
348
  PERSIST_NOTE,
287
349
  ].join('\n'),
288
350
  html: true,
351
+ // Only record when the confirmation carries a real switch (Set/Switched);
352
+ // a "Kept model as" line means no change — don't overwrite the override.
353
+ ...(isKeptModelConfirmation(confirmation) ? {} : { selectedModel: confirmed }),
289
354
  }
290
355
  }
291
356
  return {
292
357
  text: [
293
- `${verbHtml} — switched (session).`,
358
+ `${verbHtml} — sent, but couldn't confirm the switch — check \`/status\`.`,
294
359
  PERSIST_NOTE,
295
360
  ].join('\n'),
296
361
  html: true,
@@ -676,6 +741,23 @@ export interface ModelCallbackOutcome {
676
741
  * Absent on every non-switch outcome.
677
742
  */
678
743
  selectedModel?: string
744
+ /**
745
+ * The canonical `claude --model` token (alias or full `claude-*` id) for a
746
+ * Claude selection, when derivable — distinct from `selectedModel` (a display
747
+ * name for /status). The gateway persists this to the durable
748
+ * `.session-model` override so the confirmed switch survives
749
+ * switchroom-managed relaunches (and, on an sr-* → Claude transition, its own
750
+ * restart). Absent when the target has no derivable token.
751
+ */
752
+ selectedModelToken?: string
753
+ /**
754
+ * True when the confirmed selection was the "Default (recommended)" row —
755
+ * i.e. the session is now on the configured default and any sticky
756
+ * `.session-model` override must be CLEARED (there is no token to persist;
757
+ * persisting nothing while leaving a stale override would re-apply the old
758
+ * model on the next keep-relaunch).
759
+ */
760
+ clearedDefault?: boolean
679
761
  /** Short toast for answerCallbackQuery. */
680
762
  answer: string
681
763
  /** Replacement dashboard (message edit). */
@@ -732,15 +814,19 @@ export async function handleModelMenuCallback(
732
814
  }
733
815
  }
734
816
  if (aliasResult.outcome === 'ok') {
817
+ // Anchored confirmation only — a loose /set model|switched/ match false-
818
+ // positives on ordinary scrollback prose ("I switched the deploy…").
735
819
  const confirmation =
736
- aliasResult.output
737
- .split('\n')
738
- .map((l) => l.trim())
739
- .find((l) => /set model|switched/i.test(l)) ?? `Switched to ${alias} (session)`
820
+ modelSwitchConfirmationLine(aliasResult.output) ?? `Switched to ${alias} (session)`
821
+ // "Kept model as X" means no change — don't overwrite the override.
822
+ const kept = isKeptModelConfirmation(confirmation)
740
823
  return {
741
824
  answer: confirmation,
742
825
  reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
743
- selectedModel: sessionModelFromConfirmation(confirmation) ?? alias,
826
+ ...(kept ? {} : {
827
+ selectedModel: sessionModelFromConfirmation(confirmation) ?? alias,
828
+ selectedModelToken: alias,
829
+ }),
744
830
  }
745
831
  }
746
832
  return {
@@ -759,11 +845,17 @@ export async function handleModelMenuCallback(
759
845
  return { answer: 'Tap a model in this section to switch', reply: { text: '', html: true }, toastOnly: true }
760
846
  }
761
847
 
762
- // sr-* model tap: text-inject `/model sr-<name>` rather than cursor-nav.
763
- // Text-inject is more reliable when the picker has many models; sr-* names
764
- // are safe (no entry in model_group_settings no OAuth forwarding). See I6.
848
+ // sr-* model tap. In the live gateway this branch is DEAD — the gateway
849
+ // intercepts `mdl:sr:` at its callback dispatcher (before calling this
850
+ // function) and routes it straight to scheduleModelRelaunch (carrier + restart).
851
+ // The old body here text-injected `/model sr-<name>`, which is doubly broken if
852
+ // ever reached: claude's picker rejects unknown sr-* ids AND the ANTHROPIC_BASE_URL
853
+ // is never repointed at the LiteLLM router, so the request 4xxs against Anthropic.
854
+ // Delegate to the SAME carrier mechanism so a direct caller (tests, a future
855
+ // refactor that drops the gateway intercept) still does the safe thing.
765
856
  if (data.startsWith(MODEL_CALLBACK_SR)) {
766
857
  const srName = data.slice(MODEL_CALLBACK_SR.length)
858
+ const friendlyName = srFriendlyLabel(srName)
767
859
  if (!isValidModelArg(srName)) {
768
860
  return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
769
861
  }
@@ -774,39 +866,22 @@ export async function handleModelMenuCallback(
774
866
  toastOnly: true,
775
867
  }
776
868
  }
777
- let srResult: InjectResult
778
869
  try {
779
- srResult = await deps.inject(deps.getAgentName(), `/model ${srName}`)
870
+ await deps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
780
871
  } catch (err) {
781
872
  const msg = err instanceof Error ? err.message : String(err)
782
873
  return {
783
874
  answer: 'Switch failed',
784
- reply: await menuWithBanner(deps, `❌ Switch to **${deps.escapeHtml(srName)}** failed: ${deps.escapeHtml(msg)}`),
785
- }
786
- }
787
- if (srResult.outcome === 'ok') {
788
- const friendlyName = srFriendlyLabel(srName)
789
- const confirmation =
790
- srResult.output
791
- .split('\n')
792
- .map((l) => l.trim())
793
- .find((l) => /set model|switched/i.test(l)) ?? `Switched to ${friendlyName} (session)`
794
- return {
795
- answer: confirmation,
796
- // Use the static (no-discover) path — after a text-inject the picker
797
- // is in flux and discover() reliably fails, producing a spurious
798
- // "(picker unavailable)" line that reads as an error when the switch
799
- // actually succeeded.
800
- reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
801
- selectedModel: srName,
875
+ reply: await menuWithBannerStatic(deps, `❌ Switch to **${deps.escapeHtml(friendlyName)}** failed: ${deps.escapeHtml(msg)}`),
802
876
  }
803
877
  }
804
878
  return {
805
- answer: 'Switch failed',
806
- reply: await menuWithBanner(
879
+ answer: `Switching to ${friendlyName} — restarting (~30s)`,
880
+ reply: await menuWithBannerStatic(
807
881
  deps,
808
- `❌ Switch to **${deps.escapeHtml(srFriendlyLabel(srName))}** failed agent may be mid-turn`,
882
+ `🔄 Switching session to **${deps.escapeHtml(friendlyName)}** — restarting (~30s).\n${PERSIST_NOTE}`,
809
883
  ),
884
+ selectedModel: srName,
810
885
  }
811
886
  }
812
887
 
@@ -866,10 +941,31 @@ export async function handleModelMenuCallback(
866
941
  }
867
942
  }
868
943
 
944
+ // "Kept model as X" means the tapped model was ALREADY the session model —
945
+ // nothing changed. Do NOT overwrite the override (and never store the display
946
+ // label). Tapping the "Default (recommended)" row on the already-default model
947
+ // previously stored "Default (recommended)" verbatim into /status.
948
+ if (isKeptModelConfirmation(result.confirmation)) {
949
+ return {
950
+ answer: deps.escapeHtml(result.confirmation),
951
+ reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
952
+ }
953
+ }
954
+ // Normalize what we store: prefer the model name claude confirmed, else a
955
+ // canonical token derived from the row label — never a pure display label like
956
+ // "Default (recommended)". If neither resolves, record nothing rather than lie.
957
+ const token = canonicalClaudeToken(target.label)
958
+ const selectedModel = sessionModelFromConfirmation(result.confirmation) ?? token ?? undefined
959
+ // The "Default (recommended)" row has no derivable token BY DESIGN — a
960
+ // confirmed switch to it means "back on the configured default", which the
961
+ // gateway must translate into clearing the sticky override.
962
+ const clearedDefault = token == null && /^default\b/i.test(target.label.trim())
869
963
  return {
870
964
  answer: deps.escapeHtml(result.confirmation),
871
965
  reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
872
- selectedModel: sessionModelFromConfirmation(result.confirmation) ?? target.label,
966
+ ...(selectedModel ? { selectedModel } : {}),
967
+ ...(token ? { selectedModelToken: token } : {}),
968
+ ...(clearedDefault ? { clearedDefault: true } : {}),
873
969
  }
874
970
  }
875
971
 
@@ -904,27 +1000,104 @@ export function modelSwitchConfirmationLine(output: string): string | null {
904
1000
  return line && line.length > 0 ? line : null
905
1001
  }
906
1002
 
1003
+ /**
1004
+ * claude's failure output for a bad `/model <name>` — the CLI rejects an
1005
+ * unknown id with "Model not found" / "Invalid model" / "Unknown model".
1006
+ * Detecting it lets the typed set path report an HONEST failure instead of
1007
+ * falsely claiming "switched (session)". Anchored to LINE START (behind the
1008
+ * same optional status glyph + optional "Error:" prefix as
1009
+ * MODEL_SWITCH_CONFIRMATION_PREFIX) so ordinary scrollback prose that merely
1010
+ * CONTAINS the phrase mid-sentence (e.g. "deploy failed: model not found in
1011
+ * registry") can never false-positive a successful switch into a reported
1012
+ * failure — the false-FAILURE variant of the scrollback-leak class.
1013
+ *
1014
+ * Empirically verified against claude v2.1.205 (disposable TUI probe,
1015
+ * 2026-07-10): `/model claude-bogus-99` prints
1016
+ * `⎿ Model 'claude-bogus-99' not found` — glyph prefix `⎿`, quoted model
1017
+ * name between "Model" and "not found". Both shapes are covered.
1018
+ */
1019
+ const MODEL_SWITCH_ERROR_RE =
1020
+ /^\s*[⏺●•>⎿-]?\s*(?:Error:\s*)?(?:Model(?:\s+'[^']+')?\s+not found|Invalid model|Unknown model|No such model)\b/i
1021
+
1022
+ /** The single capture line that reads as a claude model-switch error, or null. */
1023
+ export function modelSwitchErrorLine(output: string): string | null {
1024
+ const line = output
1025
+ .split('\n')
1026
+ .map((l) => l.trim())
1027
+ .find((l) => MODEL_SWITCH_ERROR_RE.test(l))
1028
+ return line && line.length > 0 ? line : null
1029
+ }
1030
+
1031
+ /**
1032
+ * True when a confirmation line is claude's "Kept model as X" — i.e. the model
1033
+ * was ALREADY the session model and nothing changed. The caller must NOT record
1034
+ * this as a session-override (there is nothing to override), and must not store
1035
+ * a display label in its place. See the menu-select bug where tapping the
1036
+ * "Default (recommended)" row on the already-default model stored the display
1037
+ * label verbatim into /status.
1038
+ */
1039
+ export function isKeptModelConfirmation(confirmation: string): boolean {
1040
+ return /^\s*[⏺●•>⎿-]?\s*Kept model as\b/i.test(confirmation.trim())
1041
+ }
1042
+
1043
+ /**
1044
+ * Normalize a picker ROW LABEL to a canonical `claude --model` token suitable
1045
+ * for the durable `.session-model` override (aliases and full `claude-*` ids —
1046
+ * NOT display strings like "Default (recommended)" or "Opus 4.8", which the CLI
1047
+ * flag rejects). Returns null when the label is a pure display label with no
1048
+ * derivable token: for the "Default" row that correctly means "boot the
1049
+ * configured default" (write no carrier).
1050
+ */
1051
+ export function canonicalClaudeToken(label: string): string | null {
1052
+ const l = label.trim()
1053
+ if (l.toLowerCase().startsWith('claude-')) return l
1054
+ const first = l.toLowerCase().split(/\s+/)[0]
1055
+ if (first === 'default') return null
1056
+ if ((MODEL_ALIASES as readonly string[]).includes(first)) return first
1057
+ return null
1058
+ }
1059
+
1060
+ /**
1061
+ * True when an error thrown by scheduleRestart / scheduleModelRelaunch signals
1062
+ * "a restart is already in flight" (the gateway's 15s debounce) rather than a
1063
+ * genuine dispatch failure. Carried as a `code` property so model-command.ts
1064
+ * need not import the gateway's error type.
1065
+ */
1066
+ export function isRestartInFlight(err: unknown): boolean {
1067
+ return !!err && typeof err === 'object' && (err as { code?: unknown }).code === 'restart_in_flight'
1068
+ }
1069
+
907
1070
  /**
908
1071
  * claude's real model-switch confirmation always begins the line (optionally
909
- * behind a status glyph like `⏺` + whitespace) with one of these exact
1072
+ * behind a status glyph like `⏺` or `⎿` + whitespace) with one of these exact
910
1073
  * phrasings. Anchoring to the line start keeps ordinary scrollback prose that
911
1074
  * merely *contains* words like "switched" or "set model" (e.g. "I switched the
912
1075
  * deploy to blue-green") from false-positiving as a confirmation worth
913
1076
  * relaying. Shared by `modelSwitchConfirmationLine` (does this line qualify?)
914
1077
  * and `sessionModelFromConfirmation` (pull the name out).
1078
+ *
1079
+ * Empirically verified against claude v2.1.205 (disposable TUI probe,
1080
+ * 2026-07-10): the arg form `/model opus` is NOT silent — it prints
1081
+ * `⎿ Set model to Opus 4.8 and saved as your default for new sessions`.
1082
+ * The `⎿` glyph survives the inject capture (isTuiChromeLine doesn't strip
1083
+ * it), so it must be in the glyph class or every typed switch would fall
1084
+ * through to the "couldn't confirm" branch and never record the override.
915
1085
  */
916
1086
  const MODEL_SWITCH_CONFIRMATION_PREFIX =
917
- /^\s*[⏺●•>-]?\s*(?:Set model to|Switched to|Kept model as)\b/i
1087
+ /^\s*[⏺●•>⎿-]?\s*(?:Set model to|Switched to|Kept model as)\b/i
918
1088
 
919
1089
  /**
920
1090
  * Pull the model NAME out of claude's session-switch confirmation so it can
921
1091
  * be shown in `/status` as the live session model. claude phrases it as
922
- * "Set model to <name> for this session only" (or "Switched to <name>").
923
- * Returns null when the confirmation doesn't carry a recognizable name (the
924
- * caller falls back to the tapped picker label).
1092
+ * "Set model to <name> for this session only" / "Switched to <name>" /
1093
+ * (v2.1.205 arg form) "Set model to <name> and saved as your default for new
1094
+ * sessions" the "and saved" tail must terminate the name capture or the
1095
+ * whole sentence would be stored as the model. Returns null when the
1096
+ * confirmation doesn't carry a recognizable name (the caller falls back to
1097
+ * the tapped picker label).
925
1098
  */
926
1099
  export function sessionModelFromConfirmation(confirmation: string): string | null {
927
- const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s*\(|\s*$)/i.exec(
1100
+ const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s+and saved\b|\s*\(|\s*$)/i.exec(
928
1101
  confirmation.trim(),
929
1102
  )
930
1103
  const name = m?.[1]?.trim()
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Pure, injectable core for expiring an agent-initiated approval card
3
+ * (vault_request_access / vault_request_save / request_secret /
4
+ * mental_model_propose) whose TTL elapsed with no operator tap.
5
+ *
6
+ * Extracted from gateway.ts so the ORDERING and FAULT-ISOLATION contract is
7
+ * unit-testable behaviorally (pending-card-expiry.test.ts), not just pinned by
8
+ * source-text regex:
9
+ *
10
+ * 1. remove() FIRST — the in-memory map entry + durable store record are
11
+ * dropped before anything else, so the expiry is single-shot: a second
12
+ * reaper tick (or a concurrent lazy sweep) can never double-fire the
13
+ * synthetic wake for the same card.
14
+ * 2. editCard() — best-effort ⌛ card strip; a Telegram failure never blocks
15
+ * the wake.
16
+ * 3. recordMiss() — the missed-approvals re-offer entry is written BEFORE
17
+ * the deliver attempt, so a throwing IPC socket can't lose the re-offer:
18
+ * even if the wake never lands, the operator's return re-surfaces it.
19
+ * 4. deliver() — the timeout synthetic, wrapped in try/catch. A half-dead
20
+ * client socket that throws on write is contained here: the error is
21
+ * logged, `delivered: false` is returned, and the caller's sweep loop
22
+ * continues to the remaining entries/families.
23
+ *
24
+ * Every step is individually guarded — one failing dependency never skips the
25
+ * later steps or escapes to the caller (the reaper's setInterval callback,
26
+ * where an escaped throw would take the whole gateway down via
27
+ * uncaughtException).
28
+ */
29
+
30
+ import type { InboundMessage } from './ipc-protocol.js'
31
+
32
+ export interface ExpireCardDeps {
33
+ /** Drop the in-memory map entry AND the durable store record. Runs first. */
34
+ remove: () => void
35
+ /** Best-effort ⌛ card edit (strip keyboard). Failures are swallowed. */
36
+ editCard: () => void
37
+ /** Build the timeout synthetic inbound for this card's family. */
38
+ buildInbound: () => InboundMessage
39
+ /** Inject the synthetic (turn-safe gate). May throw on a dead socket. */
40
+ deliver: (inbound: InboundMessage) => boolean
41
+ /** Record the missed-approvals re-offer entry. Runs BEFORE deliver. */
42
+ recordMiss: () => void
43
+ /** Error sink (stderr in production). */
44
+ log: (msg: string) => void
45
+ }
46
+
47
+ export interface ExpireCardResult {
48
+ delivered: boolean
49
+ }
50
+
51
+ export function expirePendingCard(deps: ExpireCardDeps): ExpireCardResult {
52
+ // 1. Single-shot: entry gone before any fallible side effect.
53
+ deps.remove()
54
+ // 2. Card strip is cosmetic — never let it block the wake.
55
+ try {
56
+ deps.editCard()
57
+ } catch (err) {
58
+ deps.log(`card-expiry: card edit failed: ${(err as Error).message}`)
59
+ }
60
+ // 3. Re-offer entry BEFORE the deliver attempt so a throwing deliver can't
61
+ // lose it (the operator's return still re-surfaces the missed card).
62
+ try {
63
+ deps.recordMiss()
64
+ } catch (err) {
65
+ deps.log(`card-expiry: missed-approval record failed: ${(err as Error).message}`)
66
+ }
67
+ // 4. The wake itself — contained so one dead socket doesn't skip the
68
+ // remaining entries in the caller's sweep loop.
69
+ let delivered = false
70
+ try {
71
+ delivered = deps.deliver(deps.buildInbound())
72
+ } catch (err) {
73
+ deps.log(`card-expiry: timeout synthetic delivery failed: ${(err as Error).message}`)
74
+ }
75
+ return { delivered }
76
+ }
77
+
78
+ /**
79
+ * Sweep one pending-card map: expire every entry past its TTL via `expire`,
80
+ * guarding each entry so one throwing expiry can't skip the rest of the map
81
+ * (or, at the caller, the remaining families).
82
+ */
83
+ export function sweepExpiredEntries<T>(
84
+ map: Map<string, T>,
85
+ isExpired: (value: T, now: number) => boolean,
86
+ expire: (stageId: string, value: T, now: number) => void,
87
+ now: number,
88
+ log: (msg: string) => void,
89
+ ): void {
90
+ for (const [k, v] of map) {
91
+ if (!isExpired(v, now)) continue
92
+ try {
93
+ expire(k, v, now)
94
+ } catch (err) {
95
+ log(`card-expiry: expire threw for stage=${k}: ${(err as Error).message}`)
96
+ }
97
+ }
98
+ }