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.
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1172 -812
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- 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/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -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/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- 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 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -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/resume-inbound-builder.test.ts +286 -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/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- 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/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- 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
- 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
|
|
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
|
-
'
|
|
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.
|
|
212
|
-
//
|
|
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.
|
|
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
|
-
|
|
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>`
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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} —
|
|
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
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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
|
|
763
|
-
//
|
|
764
|
-
//
|
|
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
|
-
|
|
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
|
|
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:
|
|
806
|
-
reply: await
|
|
879
|
+
answer: `Switching to ${friendlyName} — restarting (~30s)`,
|
|
880
|
+
reply: await menuWithBannerStatic(
|
|
807
881
|
deps,
|
|
808
|
-
|
|
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:
|
|
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*[
|
|
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"
|
|
923
|
-
*
|
|
924
|
-
*
|
|
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
|
+
}
|