switchroom 0.18.7 → 0.18.9

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 (85) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +905 -758
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_base/start.sh.hbs +111 -34
  6. package/skills/switchroom-runtime/SKILL.md +2 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
  8. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  9. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  10. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  11. package/telegram-plugin/gateway/boot-card.ts +27 -0
  12. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  13. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  14. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  15. package/telegram-plugin/gateway/gateway.ts +1169 -3043
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  17. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  18. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  19. package/telegram-plugin/gateway/model-command.ts +23 -11
  20. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  21. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  22. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  23. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  24. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  25. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  26. package/telegram-plugin/hooks/hooks.json +10 -10
  27. package/telegram-plugin/hooks/run-hook.sh +84 -0
  28. package/telegram-plugin/model-unavailable.ts +26 -0
  29. package/telegram-plugin/pty-partial-handler.ts +39 -0
  30. package/telegram-plugin/render/rich-render.ts +79 -1
  31. package/telegram-plugin/retry-api-call.ts +62 -0
  32. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  33. package/telegram-plugin/silence-poke.ts +14 -0
  34. package/telegram-plugin/stream-controller.ts +156 -38
  35. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  36. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  37. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  38. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  39. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  40. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  41. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  42. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  43. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  44. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  45. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  46. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  47. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  48. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  49. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  50. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  51. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  52. package/telegram-plugin/tests/model-command.test.ts +2 -2
  53. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  54. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  55. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  56. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  57. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  58. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  59. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  60. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  61. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  62. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  63. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  64. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  65. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  67. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  68. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  69. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  70. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  71. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  72. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  73. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  74. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  75. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  76. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  77. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  78. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  79. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  80. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  81. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  82. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  83. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  84. package/telegram-plugin/voice-ondemand.ts +25 -1
  85. package/telegram-plugin/voice-send.ts +154 -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: [
@@ -27,6 +27,49 @@
27
27
  import { parseDocument } from "yaml";
28
28
  import { generateUnifiedDiff } from "../../src/web/config-diff.js";
29
29
 
30
+ /**
31
+ * Decode the canonical six HTML/XML entities to their literal characters — the
32
+ * config-write-boundary layer of the #2976 defense-in-depth.
33
+ *
34
+ * SCOPE / reachability (be honest about which half is load-bearing):
35
+ * - `source_query` decode is the REACHABLE half. A model can copy escaped
36
+ * entities out of its Telegram-HTML-rendered context into a proposal's
37
+ * free-form `source_query`, and undecoded it would steer recall on `R&D`
38
+ * instead of `R&D` — a durable, silent corruption of a memory-integrity
39
+ * field. Normalizing here means an escaped query can never LAND in
40
+ * `memory.mental_models[]`.
41
+ * - `name` decode is REDUNDANT belt-and-suspenders. The `mental_model_propose`
42
+ * gateway tool already slug-validates `name` against
43
+ * /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/ BEFORE this runs, so an entity-bearing
44
+ * name is rejected upstream and can't reach here. It's kept only so this
45
+ * write boundary is self-contained if that gate ever moves.
46
+ * - The observed in-NAME corruption (`Nutrition Protocol & Deficit Status`,
47
+ * klanker 2026-07-06) actually arrived via the DIRECT `create_mental_model`
48
+ * Hindsight tool (`ensureMentalModel`, undecoded), NOT this propose path.
49
+ * That vector is OUT OF SCOPE here — covered by the steering.ts context-
50
+ * hygiene + in-service `Dockerfile.hindsight` normalization follow-ups.
51
+ *
52
+ * SINGLE PASS by design (matches issue #2976's success criteria): a
53
+ * double-escaped `R&D` decodes ONE layer to `R&D`, not all the way
54
+ * to `R&D` — we only undo the escaping switchroom itself applied when it
55
+ * rendered the model's prior output, never the user's literal intent. `&`
56
+ * is decoded LAST so `&amp;lt;` collapses to `&lt;` (one layer), not `<`.
57
+ *
58
+ * Accepted tradeoff: a `source_query` a human genuinely intended to contain a
59
+ * literal `&amp;` / `&lt;` / etc. (rare — a reflection question, not markup)
60
+ * will have that one layer decoded. Steering queries reading as escaped HTML is
61
+ * the far more common and more harmful case, so we optimize for it.
62
+ */
63
+ export function decodeCanonicalEntities(s: string): string {
64
+ return s
65
+ .replace(/&lt;/g, "<")
66
+ .replace(/&gt;/g, ">")
67
+ .replace(/&quot;/g, '"')
68
+ .replace(/&apos;/g, "'")
69
+ .replace(/&#39;/g, "'")
70
+ .replace(/&amp;/g, "&");
71
+ }
72
+
30
73
  /**
31
74
  * A proposed mental model, in the same snake_case shape the
32
75
  * `memory.mental_models[]` schema (#2874) accepts. `name` + `source_query`
@@ -125,20 +168,33 @@ export function buildMentalModelAppendDiff(args: {
125
168
  }
126
169
 
127
170
  // Duplicate-name guard (defense-in-depth; the executor also checks up front
128
- // so it never even posts a card for a dupe).
171
+ // so it never even posts a card for a dupe). Compare on the DECODED name so a
172
+ // re-propose of an entity-escaped variant collides with the already-stored
173
+ // literal (#2976) rather than sneaking in a corrupted twin.
174
+ const decodedName = decodeCanonicalEntities(spec.name);
129
175
  const existing = readDeclaredMentalModelNames(configText, agentName);
130
- if (existing.includes(spec.name)) {
176
+ if (existing.includes(decodedName)) {
131
177
  return {
132
178
  ok: false,
133
179
  error: "duplicate",
134
- detail: `mental model "${spec.name}" is already declared for ${agentName}`,
180
+ detail: `mental model "${decodedName}" is already declared for ${agentName}`,
135
181
  };
136
182
  }
137
183
 
184
+ // #2976 write-boundary normalization: decode any HTML/XML entities the model
185
+ // may have copied out of its own escaped context so the LITERAL characters
186
+ // land in config — never `&amp;` / `&lt;` etc. The load-bearing target is the
187
+ // free-form `source_query`; the `name` decode is redundant with the gateway
188
+ // slug gate (an entity-bearing name is rejected upstream) and kept only so
189
+ // this boundary is self-contained. Duplicate-name guard runs on the decoded
190
+ // name above.
191
+ const name = decodedName;
192
+ const source_query = decodeCanonicalEntities(spec.source_query);
193
+
138
194
  // Assemble the minimal, schema-clean declaration node.
139
195
  const item: Record<string, unknown> = {
140
- name: spec.name,
141
- source_query: spec.source_query,
196
+ name,
197
+ source_query,
142
198
  };
143
199
  if (spec.refresh_after_consolidation !== undefined) {
144
200
  item.refresh_after_consolidation = spec.refresh_after_consolidation;
@@ -149,7 +149,7 @@ export interface ModelCommandDeps {
149
149
  * Schedule a session-only switch TO a non-Claude (`sr-*` LiteLLM/OpenRouter)
150
150
  * model. claude's in-REPL `/model` picker rejects unknown `sr-*` ids, so an
151
151
  * inject can't set them. Instead the gateway writes the chosen token to the
152
- * `.session-model-override` carrier file and gracefully restarts the agent;
152
+ * durable `.session-model` override and gracefully restarts the agent;
153
153
  * the next boot launches `claude --model <token>` directly (LiteLLM routes
154
154
  * it, no picker validation). Session-only: reverts to the configured default
155
155
  * on the following restart. Wired to the same restart dispatch as
@@ -175,7 +175,7 @@ export interface ModelCommandReply {
175
175
  }
176
176
 
177
177
  const PERSIST_NOTE =
178
- '_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._'
179
179
 
180
180
  function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
181
181
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' · ')
@@ -240,7 +240,7 @@ export async function handleModelCommand(
240
240
  // the proxy at session start, not by claude's own REPL. A graceful restart
241
241
  // is the only clean path back to the native OAuth route. Route it through the
242
242
  // SAME carrier mechanism as a Claude → sr-* switch (scheduleModelRelaunch)
243
- // so the requested Claude model is written to `.session-model-override` and
243
+ // so the requested Claude model is written to the durable `.session-model` and
244
244
  // survives the restart — otherwise boot launches the configured default and
245
245
  // the operator's choice is silently dropped. start.sh's LiteLLM-down guard
246
246
  // only special-cases `sr-*` overrides, so a Claude token is never dropped.
@@ -294,7 +294,7 @@ export async function handleModelCommand(
294
294
  return {
295
295
  text: [
296
296
  `Switching to \`${deps.escapeHtml(model)}\` — restarting session (~30s).`,
297
- '_Session-only — reverts to the configured default on the next restart._',
297
+ PERSIST_NOTE,
298
298
  ].join('\n'),
299
299
  html: true,
300
300
  }
@@ -744,13 +744,20 @@ export interface ModelCallbackOutcome {
744
744
  /**
745
745
  * The canonical `claude --model` token (alias or full `claude-*` id) for a
746
746
  * Claude selection, when derivable — distinct from `selectedModel` (a display
747
- * name for /status). The gateway writes this to the `.session-model-override`
748
- * carrier on an sr-* → Claude transition so the requested Claude model survives
749
- * the restart (otherwise boot launches the configured default). Absent when the
750
- * target has no derivable token (e.g. the "Default" row → boot the configured
751
- * default).
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.
752
751
  */
753
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
754
761
  /** Short toast for answerCallbackQuery. */
755
762
  answer: string
756
763
  /** Replacement dashboard (message edit). */
@@ -872,7 +879,7 @@ export async function handleModelMenuCallback(
872
879
  answer: `Switching to ${friendlyName} — restarting (~30s)`,
873
880
  reply: await menuWithBannerStatic(
874
881
  deps,
875
- `🔄 Switching session to **${deps.escapeHtml(friendlyName)}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`,
882
+ `🔄 Switching session to **${deps.escapeHtml(friendlyName)}** — restarting (~30s).\n${PERSIST_NOTE}`,
876
883
  ),
877
884
  selectedModel: srName,
878
885
  }
@@ -949,11 +956,16 @@ export async function handleModelMenuCallback(
949
956
  // "Default (recommended)". If neither resolves, record nothing rather than lie.
950
957
  const token = canonicalClaudeToken(target.label)
951
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())
952
963
  return {
953
964
  answer: deps.escapeHtml(result.confirmation),
954
965
  reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
955
966
  ...(selectedModel ? { selectedModel } : {}),
956
967
  ...(token ? { selectedModelToken: token } : {}),
968
+ ...(clearedDefault ? { clearedDefault: true } : {}),
957
969
  }
958
970
  }
959
971
 
@@ -1030,7 +1042,7 @@ export function isKeptModelConfirmation(confirmation: string): boolean {
1030
1042
 
1031
1043
  /**
1032
1044
  * Normalize a picker ROW LABEL to a canonical `claude --model` token suitable
1033
- * for the `.session-model-override` carrier (aliases and full `claude-*` ids —
1045
+ * for the durable `.session-model` override (aliases and full `claude-*` ids —
1034
1046
  * NOT display strings like "Default (recommended)" or "Opus 4.8", which the CLI
1035
1047
  * flag rejects). Returns null when the label is a pure display label with no
1036
1048
  * derivable token: for the "Default" row that correctly means "boot the