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.
- package/dist/cli/switchroom.js +1039 -608
- package/dist/host-control/main.js +5 -5
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +35 -0
- package/telegram-plugin/dist/gateway/gateway.js +91 -8
- package/telegram-plugin/gateway/gateway.ts +92 -3
- package/telegram-plugin/gateway/model-command.ts +73 -0
- package/telegram-plugin/permission-title.ts +5 -0
- package/telegram-plugin/tests/model-command.test.ts +124 -1
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +24 -10
|
@@ -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.
|
|
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
|
|
22927
|
-
const scratchFile = join4(scratchDir,
|
|
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.
|
|
4
|
-
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw
|
|
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.
|
|
56017
|
-
var COMMIT_SHA = "
|
|
56018
|
-
var COMMIT_DATE = "2026-06-
|
|
56019
|
-
var LATEST_PR =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|