switchroom 0.16.7 → 0.16.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.
@@ -22587,7 +22587,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
22587
22587
  import { dirname as dirname4, join as join2 } from "node:path";
22588
22588
 
22589
22589
  // src/build-info.ts
22590
- var VERSION = "0.16.7";
22590
+ var VERSION = "0.16.9";
22591
22591
 
22592
22592
  // src/cli/resolve-version.ts
22593
22593
  function readPackageVersion() {
@@ -22827,7 +22827,7 @@ function parseUpdateResultLine(stdout) {
22827
22827
  // src/host-control/config-edit-validator.ts
22828
22828
  import { mkdtempSync, writeFileSync as writeFileSync2, rmSync as rmSync2, existsSync as existsSync7, readFileSync as readFileSync5 } from "node:fs";
22829
22829
  import { tmpdir } from "node:os";
22830
- import { join as join4, isAbsolute as isAbsolute2, normalize } from "node:path";
22830
+ import { join as join4, isAbsolute as isAbsolute2, normalize, basename as basename2 } from "node:path";
22831
22831
  import { spawnSync as spawnSync2 } from "node:child_process";
22832
22832
  import { isDeepStrictEqual } from "node:util";
22833
22833
  var MAX_PATCH_BYTES = 1024 * 1024;
@@ -22847,7 +22847,7 @@ function isTargetPathHeader(headerPath, targetBasename) {
22847
22847
  const norm = normalize(p);
22848
22848
  if (norm.includes("..") || isAbsolute2(norm))
22849
22849
  return false;
22850
- return norm === targetBasename;
22850
+ return norm === targetBasename || basename2(norm) === targetBasename;
22851
22851
  }
22852
22852
  function validateShape(unifiedDiff, targetPath) {
22853
22853
  const byteLen = Buffer.byteLength(unifiedDiff, "utf8");
@@ -22923,8 +22923,8 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
22923
22923
  const liveContent = readFileSync5(configPath, "utf8");
22924
22924
  const scratchDir = mkdtempSync(join4(tmpdir(), "config-propose-edit-"));
22925
22925
  try {
22926
- const basename2 = configPath.split("/").pop() ?? "switchroom.yaml";
22927
- const scratchFile = join4(scratchDir, basename2);
22926
+ const basename3 = configPath.split("/").pop() ?? "switchroom.yaml";
22927
+ const scratchFile = join4(scratchDir, basename3);
22928
22928
  writeFileSync2(scratchFile, liveContent);
22929
22929
  const patchFile = join4(scratchDir, "proposal.patch");
22930
22930
  writeFileSync2(patchFile, unifiedDiff);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.16.7",
4
- "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw no API keys.",
3
+ "version": "0.16.9",
4
+ "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw \u2014 no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "switchroom": "./dist/cli/switchroom.js"
@@ -51,6 +51,41 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
51
51
  {{/each}}
52
52
  {{/if}}
53
53
 
54
+ # LiteLLM virtual-key fetch for the GATEWAY process (outer-pass hoist).
55
+ # The INNER block below fetches this same key for the `claude` process;
56
+ # the gateway daemon forks HERE (in the outer pass) so it needs
57
+ # ANTHROPIC_CUSTOM_HEADERS exported BEFORE the gateway fork below, or
58
+ # discoverSrModels() returns [] and /model never shows OpenRouter entries.
59
+ #
60
+ # Mirrors the INNER block's fail-open logic exactly:
61
+ # - missing key → log + skip export (gateway talks direct OAuth)
62
+ # - proxy down → log + strip ALL routing env (fail-open)
63
+ # - ANTHROPIC_CUSTOM_HEADERS already set → skip (idempotent)
64
+ #
65
+ # SWITCHROOM_AGENT_NAME is injected by compose env (compose.ts:1816)
66
+ # so it is available here, before the inner-pass export at line ~320.
67
+ if [ -n "$SWITCHROOM_LITELLM" ] && [ -z "$ANTHROPIC_CUSTOM_HEADERS" ] && command -v switchroom >/dev/null 2>&1; then
68
+ sr_ll_key="$(switchroom vault get "litellm/$SWITCHROOM_AGENT_NAME/api-key" 2>/dev/null || true)"
69
+ sr_ll_ok=""
70
+ if [ -z "$sr_ll_key" ]; then
71
+ echo "litellm(outer): no virtual key for agent '$SWITCHROOM_AGENT_NAME' — gateway will use direct OAuth (no tracking/guardrail)" >&2
72
+ elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ] \
73
+ && ! curl -fsS -m 5 -o /dev/null "${ANTHROPIC_BASE_URL%/}/health/liveliness" 2>/dev/null; then
74
+ echo "litellm(outer): proxy unreachable at $ANTHROPIC_BASE_URL — falling back to direct OAuth (no tracking/guardrail this session)" >&2
75
+ else
76
+ sr_ll_ok="1"
77
+ fi
78
+ if [ -n "$sr_ll_ok" ]; then
79
+ export ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: Bearer $sr_ll_key
80
+ x-litellm-customer-id: $SWITCHROOM_AGENT_NAME
81
+ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:-default}"
82
+ export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
83
+ else
84
+ unset ANTHROPIC_BASE_URL ANTHROPIC_SMALL_FAST_MODEL SWITCHROOM_LITELLM
85
+ fi
86
+ unset sr_ll_key sr_ll_ok
87
+ fi
88
+
54
89
  # Tiny in-process supervisor: runs cmd in a respawn loop with
55
90
  # exponential backoff (1→2→4…→60s cap) and NEVER permanently gives
56
91
  # up. Rationale (RFC J / install-validation 2026-05-17): the
@@ -46004,6 +46004,15 @@ var MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/;
46004
46004
  function isValidModelArg(arg) {
46005
46005
  return MODEL_ARG_RE.test(arg);
46006
46006
  }
46007
+ function isSrModel(name) {
46008
+ return name.startsWith("sr-");
46009
+ }
46010
+ function isClaudeModel(name) {
46011
+ const lower = name.toLowerCase();
46012
+ if (MODEL_ALIASES.includes(lower))
46013
+ return true;
46014
+ return lower.startsWith("claude-");
46015
+ }
46007
46016
  function parseModelCommand(text) {
46008
46017
  const m = text.match(/^\/model(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/);
46009
46018
  if (!m)
@@ -46053,6 +46062,26 @@ async function handleModelCommand(parsed, deps) {
46053
46062
  if (!isValidModelArg(parsed.model)) {
46054
46063
  return helpText2(deps, `not a valid model name: ${parsed.model}`);
46055
46064
  }
46065
+ const currentSession = deps.getActiveSessionModel();
46066
+ if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(parsed.model)) {
46067
+ try {
46068
+ await deps.scheduleRestart(`user: /model ${parsed.model} (sr-to-claude restart)`);
46069
+ } catch (err) {
46070
+ const msg = err instanceof Error ? err.message : String(err);
46071
+ return {
46072
+ text: `\u274c Could not schedule restart: ${deps.escapeHtml(msg)}`,
46073
+ html: true
46074
+ };
46075
+ }
46076
+ return {
46077
+ text: [
46078
+ `Switching from <code>${deps.escapeHtml(currentSession)}</code> back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`,
46079
+ PERSIST_NOTE
46080
+ ].join(`
46081
+ `),
46082
+ html: true
46083
+ };
46084
+ }
46056
46085
  const verbHtml = `<code>/model ${deps.escapeHtml(parsed.model)}</code>`;
46057
46086
  let result;
46058
46087
  try {
@@ -46272,6 +46301,9 @@ async function handleModelMenuCallback(data, deps) {
46272
46301
  selectedModel: sessionModelFromConfirmation(result.confirmation) ?? target.label
46273
46302
  };
46274
46303
  }
46304
+ function isSrToClaudeTransition(prevModel, nextModel) {
46305
+ return !!prevModel?.startsWith("sr-") && !nextModel.startsWith("sr-");
46306
+ }
46275
46307
  function sessionModelFromConfirmation(confirmation) {
46276
46308
  const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s*\(|\s*$)/i.exec(confirmation.trim());
46277
46309
  const name = m?.[1]?.trim();
@@ -54803,6 +54835,8 @@ function naturalAction(toolName, inputPreview) {
54803
54835
  return "update its task list";
54804
54836
  case "ExitPlanMode":
54805
54837
  return "exit plan mode";
54838
+ case "config_propose_edit":
54839
+ return "edit switchroom config";
54806
54840
  default:
54807
54841
  return `use ${toolName}`;
54808
54842
  }
@@ -56013,10 +56047,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
56013
56047
  }
56014
56048
 
56015
56049
  // ../src/build-info.ts
56016
- var VERSION = "0.16.7";
56017
- var COMMIT_SHA = "696eea91";
56018
- var COMMIT_DATE = "2026-06-28T04:56:27Z";
56019
- var LATEST_PR = 2615;
56050
+ var VERSION = "0.16.9";
56051
+ var COMMIT_SHA = "1a926737";
56052
+ var COMMIT_DATE = "2026-06-28T06:20:58Z";
56053
+ var LATEST_PR = 2622;
56020
56054
  var COMMITS_AHEAD_OF_TAG = 0;
56021
56055
 
56022
56056
  // gateway/boot-version.ts
@@ -65207,7 +65241,7 @@ bot.command("compact", async (ctx) => {
65207
65241
  bot.command("clear", async (ctx) => {
65208
65242
  await handleInjectCommand(ctx, buildInjectDeps({ open: true, fixedVerb: "/clear" }));
65209
65243
  });
65210
- function buildModelDeps() {
65244
+ function buildModelDeps(restartCtx) {
65211
65245
  return {
65212
65246
  discover: (a) => discoverModels(a),
65213
65247
  discoverSrModels: async () => {
@@ -65256,7 +65290,39 @@ function buildModelDeps() {
65256
65290
  return data?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
65257
65291
  },
65258
65292
  escapeHtml: escapeHtmlForTg,
65259
- preBlock
65293
+ preBlock,
65294
+ getActiveSessionModel: () => activeSessionModelOverride,
65295
+ scheduleRestart: async (reason) => {
65296
+ const name = getMyAgentName();
65297
+ const existing = readRestartMarker();
65298
+ if (existing && Date.now() - existing.ts < 15000)
65299
+ return;
65300
+ if (restartCtx) {
65301
+ writeRestartMarker({
65302
+ chat_id: restartCtx.chatId,
65303
+ thread_id: restartCtx.threadId ?? null,
65304
+ ack_message_id: null,
65305
+ ts: Date.now()
65306
+ });
65307
+ }
65308
+ stampUserRestartReason(reason);
65309
+ await sweepBeforeSelfRestart();
65310
+ const hostdResp = await tryHostdDispatch(name, {
65311
+ v: 1,
65312
+ op: "agent_restart",
65313
+ request_id: hostdRequestId("gw-model-restart"),
65314
+ args: { name, force: true, reason }
65315
+ });
65316
+ if (hostdResp === "not-configured") {
65317
+ warnLegacySpawnIfHostdDisabled("agent_restart");
65318
+ spawnSwitchroomDetached(["agent", "restart", name, "--force"], notifyDetachedFailure(restartCtx?.chatId ?? "", restartCtx?.threadId ?? null, `restart ${name}`));
65319
+ return;
65320
+ }
65321
+ if (hostdResp.result !== "started" && hostdResp.result !== "completed") {
65322
+ clearRestartMarker();
65323
+ throw new Error(`hostd restart failed (result=${hostdResp.result}): ${hostdResp.error ?? "(no details)"}`);
65324
+ }
65325
+ }
65260
65326
  };
65261
65327
  }
65262
65328
  function modelMenuReplyMarkup(reply) {
@@ -65275,7 +65341,9 @@ bot.command("model", async (ctx) => {
65275
65341
  return;
65276
65342
  const text2 = ctx.message?.text ?? ctx.channelPost?.text ?? "";
65277
65343
  const parsed = parseModelCommand(text2) ?? { kind: "show" };
65278
- const deps = buildModelDeps();
65344
+ const chatId = String(ctx.chat.id);
65345
+ const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id);
65346
+ const deps = buildModelDeps({ chatId, threadId });
65279
65347
  if (parsed.kind === "show" && process.env.SWITCHROOM_MODEL_MENU !== "0") {
65280
65348
  const menu = await buildModelMenu(deps);
65281
65349
  await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) });
@@ -68172,7 +68240,9 @@ bot.on("callback_query:data", async (ctx) => {
68172
68240
  }).catch(() => {});
68173
68241
  return;
68174
68242
  }
68175
- const modelDeps = buildModelDeps();
68243
+ const cbChatId = String(ctx.chat?.id ?? "");
68244
+ const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id);
68245
+ const modelDeps = buildModelDeps({ chatId: cbChatId, threadId: cbThreadId });
68176
68246
  if (modelDeps.isBusy()) {
68177
68247
  await ctx.answerCallbackQuery({ text: "\u23F3 Agent is mid-turn \u2014 tap again when it\u2019s idle", show_alert: false }).catch(() => {});
68178
68248
  return;
@@ -68183,12 +68253,25 @@ bot.on("callback_query:data", async (ctx) => {
68183
68253
  }
68184
68254
  await ctx.answerCallbackQuery({ text: "Switching\u2026" }).catch(() => {});
68185
68255
  try {
68256
+ const prevSessionModel = activeSessionModelOverride;
68186
68257
  const outcome = await handleModelMenuCallback(data, modelDeps);
68187
68258
  if (outcome.selectedModel) {
68188
68259
  activeSessionModelOverride = outcome.selectedModel;
68189
68260
  }
68190
68261
  if (outcome.toastOnly)
68191
68262
  return;
68263
+ if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
68264
+ const agentName3 = getMyAgentName();
68265
+ await ctx.editMessageText(`\uD83D\uDD04 Switching from <b>${escapeHtmlForTg(prevSessionModel)}</b> back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
68266
+ writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() });
68267
+ stampUserRestartReason("user: sr-to-claude model switch (menu)");
68268
+ if (turnInFlightForGate()) {
68269
+ pendingRestarts.set(agentName3, Date.now());
68270
+ } else {
68271
+ sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName3, "sr-to-claude-model-switch", 1500));
68272
+ }
68273
+ return;
68274
+ }
68192
68275
  await ctx.editMessageText(outcome.reply.text, {
68193
68276
  parse_mode: "HTML",
68194
68277
  reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] }
@@ -275,6 +275,7 @@ import {
275
275
  handleModelCommand,
276
276
  buildModelMenu,
277
277
  handleModelMenuCallback,
278
+ isSrToClaudeTransition,
278
279
  MODEL_CALLBACK_PREFIX,
279
280
  MODEL_CALLBACK_HEADER,
280
281
  type ModelMenuDeps,
@@ -15978,7 +15979,12 @@ bot.command('clear', async ctx => {
15978
15979
  // text. The typed argument form rides the allowlisted inject
15979
15980
  // primitive unchanged. Implementation in model-command.ts so it's
15980
15981
  // unit-testable without booting the bot.
15981
- function buildModelDeps(): ModelMenuDeps & ModelCommandDeps {
15982
+ interface ModelDepsRestartContext {
15983
+ chatId: string
15984
+ threadId: number | undefined
15985
+ }
15986
+
15987
+ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & ModelCommandDeps {
15982
15988
  return {
15983
15989
  discover: (a) => discoverModels(a),
15984
15990
  discoverSrModels: async () => {
@@ -16029,6 +16035,55 @@ function buildModelDeps(): ModelMenuDeps & ModelCommandDeps {
16029
16035
  },
16030
16036
  escapeHtml: escapeHtmlForTg,
16031
16037
  preBlock,
16038
+ getActiveSessionModel: () => activeSessionModelOverride,
16039
+ /**
16040
+ * Graceful restart for sr-* → Claude model switch. Same mechanism as
16041
+ * the /restart command: writes a restart marker (so the post-restart
16042
+ * greeting lands in the originating chat), stamps the reason, and
16043
+ * dispatches via hostd (or legacy detached spawn as fallback).
16044
+ */
16045
+ scheduleRestart: async (reason: string) => {
16046
+ const name = getMyAgentName()
16047
+ // Debounce: mirror the /restart command's 15 s guard to prevent
16048
+ // double-dispatch on a rapid double-tap of /model <claude-alias>.
16049
+ const existing = readRestartMarker()
16050
+ if (existing && Date.now() - existing.ts < 15_000) return
16051
+ if (restartCtx) {
16052
+ writeRestartMarker({
16053
+ chat_id: restartCtx.chatId,
16054
+ thread_id: restartCtx.threadId ?? null,
16055
+ ack_message_id: null,
16056
+ ts: Date.now(),
16057
+ })
16058
+ }
16059
+ stampUserRestartReason(reason)
16060
+ await sweepBeforeSelfRestart()
16061
+ const hostdResp = await tryHostdDispatch(name, {
16062
+ v: 1,
16063
+ op: 'agent_restart',
16064
+ request_id: hostdRequestId('gw-model-restart'),
16065
+ args: { name, force: true, reason },
16066
+ })
16067
+ if (hostdResp === 'not-configured') {
16068
+ warnLegacySpawnIfHostdDisabled('agent_restart')
16069
+ spawnSwitchroomDetached(
16070
+ ['agent', 'restart', name, '--force'],
16071
+ notifyDetachedFailure(
16072
+ restartCtx?.chatId ?? '',
16073
+ restartCtx?.threadId ?? null,
16074
+ `restart ${name}`,
16075
+ ),
16076
+ )
16077
+ return
16078
+ }
16079
+ // hostd is configured but returned an error/denied result.
16080
+ if (hostdResp.result !== 'started' && hostdResp.result !== 'completed') {
16081
+ clearRestartMarker()
16082
+ throw new Error(
16083
+ `hostd restart failed (result=${hostdResp.result}): ${hostdResp.error ?? '(no details)'}`,
16084
+ )
16085
+ }
16086
+ },
16032
16087
  }
16033
16088
  }
16034
16089
 
@@ -16046,7 +16101,9 @@ bot.command('model', async ctx => {
16046
16101
  if (!isAuthorizedSender(ctx)) return
16047
16102
  const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
16048
16103
  const parsed = parseModelCommand(text) ?? { kind: 'show' as const }
16049
- const deps = buildModelDeps()
16104
+ const chatId = String(ctx.chat!.id)
16105
+ const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
16106
+ const deps = buildModelDeps({ chatId, threadId })
16050
16107
  if (parsed.kind === 'show' && process.env.SWITCHROOM_MODEL_MENU !== '0') {
16051
16108
  const menu = await buildModelMenu(deps)
16052
16109
  await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) })
@@ -20726,7 +20783,9 @@ bot.on('callback_query:data', async ctx => {
20726
20783
  .catch(() => {})
20727
20784
  return
20728
20785
  }
20729
- const modelDeps = buildModelDeps()
20786
+ const cbChatId = String(ctx.chat?.id ?? '')
20787
+ const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
20788
+ const modelDeps = buildModelDeps({ chatId: cbChatId, threadId: cbThreadId })
20730
20789
  // Mid-turn refusal is INSTANT (a sync isBusy() check, no picker drive),
20731
20790
  // so handle it before the "Working…" ack: toast WHY and leave the menu
20732
20791
  // message untouched (buttons intact) so the operator taps again when
@@ -20750,6 +20809,7 @@ bot.on('callback_query:data', async ctx => {
20750
20809
  }
20751
20810
  await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
20752
20811
  try {
20812
+ const prevSessionModel = activeSessionModelOverride
20753
20813
  const outcome = await handleModelMenuCallback(data, modelDeps)
20754
20814
  // Record a successful session switch so /status reflects what's
20755
20815
  // actually running. In-memory only → clears when the gateway (and thus
@@ -20760,6 +20820,35 @@ bot.on('callback_query:data', async ctx => {
20760
20820
  // toastOnly: a no-op outcome that should not disturb the menu (defence
20761
20821
  // in depth — the isBusy() short-circuit above is the live path).
20762
20822
  if (outcome.toastOnly) return
20823
+
20824
+ // sr-* → Claude transition via the model menu: trigger a graceful restart.
20825
+ // Switching FROM an sr-* (LiteLLM/OpenRouter) model BACK to a Claude model
20826
+ // via the picker requires a session restart — the picker-select only changes
20827
+ // the session model label, but the sr-* LiteLLM routing context persists
20828
+ // until the session is torn down. Same mechanism as the /restart command.
20829
+ if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
20830
+ const agentName = getMyAgentName()
20831
+ // Replace the menu with a restart notice (no buttons — session is ending).
20832
+ await ctx
20833
+ .editMessageText(
20834
+ `🔄 Switching from <b>${escapeHtmlForTg(prevSessionModel!)}</b> back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
20835
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
20836
+ )
20837
+ .catch(() => {})
20838
+ // Write the restart marker so the post-restart boot card edits into this chat.
20839
+ writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
20840
+ stampUserRestartReason('user: sr-to-claude model switch (menu)')
20841
+ if (turnInFlightForGate()) {
20842
+ // Defer restart until the in-flight turn completes (same gate as /restart).
20843
+ pendingRestarts.set(agentName, Date.now())
20844
+ } else {
20845
+ void sweepBeforeSelfRestart().finally(() =>
20846
+ triggerSelfRestart(agentName, 'sr-to-claude-model-switch', 1500),
20847
+ )
20848
+ }
20849
+ return
20850
+ }
20851
+
20763
20852
  await ctx
20764
20853
  .editMessageText(outcome.reply.text, {
20765
20854
  parse_mode: 'HTML',
@@ -62,6 +62,23 @@ export function isValidModelArg(arg: string): boolean {
62
62
  return MODEL_ARG_RE.test(arg)
63
63
  }
64
64
 
65
+ /** True when `name` is an sr-* (LiteLLM/OpenRouter) model identifier. */
66
+ export function isSrModel(name: string): boolean {
67
+ return name.startsWith('sr-')
68
+ }
69
+
70
+ /**
71
+ * True when `name` is a Claude model — either a well-known alias or a
72
+ * full `claude-*` id (including `[1m]` variants). This is intentionally
73
+ * broader than MODEL_ALIASES: any `claude-…` string from claude's own
74
+ * picker (e.g. `claude-opus-4-8`) qualifies.
75
+ */
76
+ export function isClaudeModel(name: string): boolean {
77
+ const lower = name.toLowerCase()
78
+ if ((MODEL_ALIASES as readonly string[]).includes(lower)) return true
79
+ return lower.startsWith('claude-')
80
+ }
81
+
65
82
  export type ParsedModelCommand =
66
83
  | { kind: 'show' }
67
84
  | { kind: 'set'; model: string }
@@ -100,6 +117,22 @@ export interface ModelCommandDeps {
100
117
  getConfiguredModel: () => string | null
101
118
  escapeHtml: (s: string) => string
102
119
  preBlock: (s: string) => string
120
+ /**
121
+ * The active session-model override set by a prior `/model` switch.
122
+ * Null when no session override is active (using configured/default model).
123
+ * Used to detect whether the current session is on an sr-* (OpenRouter)
124
+ * model so a switch back to Claude can trigger a graceful restart instead
125
+ * of an in-place inject (which would leave stale sr-* routing in place).
126
+ */
127
+ getActiveSessionModel: () => string | null
128
+ /**
129
+ * Schedule a graceful restart of this agent. Called instead of inject
130
+ * when switching from an sr-* model back to Claude — the restart clears
131
+ * the sr-* session context cleanly, whereas an in-place inject would
132
+ * leave LiteLLM routing active. The gateway wires this to the same
133
+ * mechanism as the `/restart` command (hostd-first, SIGTERM fallback).
134
+ */
135
+ scheduleRestart: (reason: string) => Promise<void>
103
136
  }
104
137
 
105
138
  export interface ModelCommandReply {
@@ -148,6 +181,32 @@ export async function handleModelCommand(
148
181
  if (!isValidModelArg(parsed.model)) {
149
182
  return helpText(deps, `not a valid model name: ${parsed.model}`)
150
183
  }
184
+
185
+ // sr-* → Claude: an in-place `/model` inject would leave LiteLLM routing
186
+ // active in the live session because the sr-* model context was set by
187
+ // the proxy at session start, not by claude's own REPL. A graceful restart
188
+ // is the only clean path back to the native OAuth route. This matches the
189
+ // behaviour of the `/restart` command (same mechanism, same marker logic).
190
+ const currentSession = deps.getActiveSessionModel()
191
+ if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(parsed.model)) {
192
+ try {
193
+ await deps.scheduleRestart(`user: /model ${parsed.model} (sr-to-claude restart)`)
194
+ } catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err)
196
+ return {
197
+ text: `❌ Could not schedule restart: ${deps.escapeHtml(msg)}`,
198
+ html: true,
199
+ }
200
+ }
201
+ return {
202
+ text: [
203
+ `Switching from <code>${deps.escapeHtml(currentSession)}</code> back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
204
+ PERSIST_NOTE,
205
+ ].join('\n'),
206
+ html: true,
207
+ }
208
+ }
209
+
151
210
  const verbHtml = `<code>/model ${deps.escapeHtml(parsed.model)}</code>`
152
211
  let result: InjectResult
153
212
  try {
@@ -549,6 +608,20 @@ export async function handleModelMenuCallback(
549
608
  }
550
609
  }
551
610
 
611
+ /**
612
+ * True when the transition from `prevModel` to `nextModel` is a switch FROM
613
+ * an sr-* (LiteLLM/OpenRouter) model BACK TO a native Claude model. This
614
+ * signals that a session restart is required — an in-place model-picker select
615
+ * cannot undo the LiteLLM routing that the sr-* switch established in the live
616
+ * session. Null / undefined prev means no prior sr-* session — not a transition.
617
+ */
618
+ export function isSrToClaudeTransition(
619
+ prevModel: string | null | undefined,
620
+ nextModel: string,
621
+ ): boolean {
622
+ return !!prevModel?.startsWith('sr-') && !nextModel.startsWith('sr-')
623
+ }
624
+
552
625
  /**
553
626
  * Pull the model NAME out of claude's session-switch confirmation so it can
554
627
  * be shown in `/status` as the live session model. claude phrases it as
@@ -212,6 +212,11 @@ export function naturalAction(
212
212
  return "update its task list";
213
213
  case "ExitPlanMode":
214
214
  return "exit plan mode";
215
+ // hostd config-edit verb (#2605) — not mcp__-prefixed (it's a direct
216
+ // wire call from the agent-config MCP server), so it falls through to
217
+ // the switch rather than naturalMcpAction. Give it a readable title.
218
+ case "config_propose_edit":
219
+ return "edit switchroom config";
215
220
  default:
216
221
  return `use ${toolName}`;
217
222
  }