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.
- package/README.md +2 -2
- package/dist/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +1169 -3043
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- 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
|
-
*
|
|
11
|
-
* - drainBuffer →
|
|
12
|
-
* - redeliverPersistedPermVerdicts →
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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 `&lt;` collapses to `<` (one layer), not `<`.
|
|
57
|
+
*
|
|
58
|
+
* Accepted tradeoff: a `source_query` a human genuinely intended to contain a
|
|
59
|
+
* literal `&` / `<` / 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(/</g, "<")
|
|
66
|
+
.replace(/>/g, ">")
|
|
67
|
+
.replace(/"/g, '"')
|
|
68
|
+
.replace(/'/g, "'")
|
|
69
|
+
.replace(/'/g, "'")
|
|
70
|
+
.replace(/&/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(
|
|
176
|
+
if (existing.includes(decodedName)) {
|
|
131
177
|
return {
|
|
132
178
|
ok: false,
|
|
133
179
|
error: "duplicate",
|
|
134
|
-
detail: `mental model "${
|
|
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 `&` / `<` 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
|
|
141
|
-
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
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
* target has no derivable token
|
|
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)
|
|
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
|
|
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
|