switchroom 0.15.1 → 0.15.3

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.
@@ -24194,6 +24194,7 @@ var init_schema = __esm(() => {
24194
24194
  dangerous_mode: exports_external.boolean().optional().describe("If true, include --dangerously-skip-permissions in start.sh"),
24195
24195
  network_isolation: NetworkIsolationSchema,
24196
24196
  admin: exports_external.boolean().optional().describe("If true, the agent's Telegram gateway intercepts admin slash commands " + "(/agents, /logs, /restart, /delete, /update, /auth, /reconcile, etc.) " + "locally before forwarding to Claude. Commands are handled silently \u2014 " + "Claude never sees them. Requires the agent to use the switchroom-telegram " + "plugin. When false or absent, all messages pass through to Claude unchanged."),
24197
+ root: exports_external.boolean().optional().describe("If true, this is a ROOT-tier debugging agent: a root-privileged " + "container (runs as uid 0, mounts /var/run/docker.sock, the whole " + "~/.switchroom tree, and the host root filesystem at /host) so you " + "can DM it to debug the whole fleet \u2014 read any agent's logs, " + "docker exec into peers, edit host files \u2014 instead of SSHing into " + "the host as root. Implies admin: true (all admin slash commands). " + "Standing root power, audited via the agent's own session transcript " + "and shell history; there is no per-action approval tap. Per-agent " + "only (never set at defaults/profile layers). Grant to exactly one " + "trusted operator-private agent \u2014 it ingests other agents' output, " + "which is attacker-influenced text. See docs/root-agent.md."),
24197
24198
  settings_raw: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Escape hatch: raw object deep-merged into the generated " + "settings.json as the final step. Use for Claude Code settings " + "keys switchroom doesn't wrap directly (e.g. effort, apiKeyHelper). " + "Power-user-only \u2014 prefer the typed fields when they exist."),
24198
24199
  claude_md_raw: exports_external.string().optional().describe("Escape hatch: markdown text appended verbatim to CLAUDE.md on " + "initial scaffold. Not re-applied on reconcile (CLAUDE.md is " + "user-protected). Use for one-off persona tuning that isn't " + "worth a template."),
24199
24200
  cli_args: exports_external.array(exports_external.string()).optional().describe("Escape hatch: extra arguments appended to the `exec claude` " + "invocation in start.sh. Use for Claude Code CLI flags switchroom " + "doesn't expose directly (e.g. --effort high, " + "--exclude-dynamic-system-prompt-sections)."),
@@ -43042,6 +43043,7 @@ var TELEGRAM_MENU_COMMANDS = [
43042
43043
  { command: "version", description: "Show versions + running agent health" },
43043
43044
  { command: "logs", description: "Show recent agent logs" },
43044
43045
  { command: "inject", description: "Inject a Claude Code slash command (e.g. /cost)" },
43046
+ { command: "model", description: "Show or switch the Claude model" },
43045
43047
  { command: "doctor", description: "Health check (deps, services, MCP)" },
43046
43048
  { command: "usage", description: "Pro/Max plan quota (5h + 7d windows)" },
43047
43049
  { command: "vault", description: "Manage vault secrets + capability grants" },
@@ -43081,6 +43083,8 @@ function switchroomHelpText(agentName3) {
43081
43083
  `<code>/auth list [agent]</code> \u2014 list account slots and health`,
43082
43084
  `<code>/auth use [agent] &lt;slot&gt;</code> \u2014 switch active slot and restart`,
43083
43085
  `<code>/auth rm [agent] &lt;slot&gt; [--force]</code> \u2014 remove a slot`,
43086
+ `<code>/model</code> \u2014 show the configured Claude model`,
43087
+ `<code>/model &lt;name&gt;</code> \u2014 switch the live session's model (opus \u00b7 sonnet \u00b7 haiku or a full id; until restart)`,
43084
43088
  `<code>/topics</code> \u2014 topic-to-agent mappings`,
43085
43089
  `<code>/permissions [agent]</code> \u2014 show agent permissions`,
43086
43090
  `<code>/grant &lt;tool&gt;</code> \u2014 grant a tool permission`,
@@ -44324,6 +44328,10 @@ async function fetchQuota2(opts) {
44324
44328
  }
44325
44329
  return parsed;
44326
44330
  }
44331
+ function formatQuotaLine2(q) {
44332
+ const fmt = (n) => `${Math.round(n)}%`;
44333
+ return `${fmt(q.fiveHourUtilizationPct)} / 5h \u00b7 ${fmt(q.sevenDayUtilizationPct)} / 7d`;
44334
+ }
44327
44335
  function formatResetRelative2(target, now = new Date) {
44328
44336
  if (!target)
44329
44337
  return "\u2014";
@@ -44395,6 +44403,22 @@ var TOKEN_VALIDITY_MS2 = 365 * 24 * 60 * 60000;
44395
44403
  // ../src/agents/inject.ts
44396
44404
  import { execFile, execFileSync } from "node:child_process";
44397
44405
  import { promisify } from "node:util";
44406
+
44407
+ // ../src/agents/pane-lock.ts
44408
+ var tails = new Map;
44409
+ function withPaneLock(key, fn) {
44410
+ const tail = tails.get(key) ?? Promise.resolve();
44411
+ const run2 = tail.then(() => fn());
44412
+ const next = run2.then(() => {}, () => {});
44413
+ tails.set(key, next);
44414
+ next.then(() => {
44415
+ if (tails.get(key) === next)
44416
+ tails.delete(key);
44417
+ });
44418
+ return run2;
44419
+ }
44420
+
44421
+ // ../src/agents/inject.ts
44398
44422
  var execFileAsync = promisify(execFile);
44399
44423
  var INJECT_COMMANDS = new Map([
44400
44424
  ["/cost", { description: "Show session cost", expectsOutput: true }],
@@ -44544,13 +44568,13 @@ async function injectSlashCommand(agentName3, command, opts = {}) {
44544
44568
  const session = opts.sessionName ?? agentName3;
44545
44569
  const settleMs = opts.settleMs ?? 2000;
44546
44570
  const timeoutMs = opts.timeoutMs ?? 5000;
44547
- return injectSlashCommandWith(makeTmuxRunner(tmuxBin), {
44571
+ return withPaneLock(`${socket}:${session}`, () => injectSlashCommandWith(makeTmuxRunner(tmuxBin), {
44548
44572
  socket,
44549
44573
  session,
44550
44574
  command: command.trim(),
44551
44575
  settleMs,
44552
44576
  timeoutMs
44553
- });
44577
+ }));
44554
44578
  }
44555
44579
  async function injectSlashCommandWith(runner, args) {
44556
44580
  const { socket, session, command, settleMs, timeoutMs } = args;
@@ -44681,6 +44705,34 @@ var INJECT_BLOCKED2 = new Map([
44681
44705
  ["/quit", { reason: "would kill the agent process" }]
44682
44706
  ]);
44683
44707
  var INJECT_BLOCKLIST2 = new Set(INJECT_BLOCKED2.keys());
44708
+ function makeTmuxRunner2(tmuxBin) {
44709
+ return {
44710
+ capture(socket, session) {
44711
+ try {
44712
+ return execFileSync2(tmuxBin, ["-L", socket, "capture-pane", "-p", "-t", session, "-S", "-200"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
44713
+ } catch {
44714
+ return null;
44715
+ }
44716
+ },
44717
+ send(socket, session, args) {
44718
+ const [subcmd, ...rest] = args;
44719
+ const flagEnd = rest.findIndex((a) => !a.startsWith("-"));
44720
+ const flagsBeforeKeys = flagEnd === -1 ? rest : rest.slice(0, flagEnd);
44721
+ const keys = flagEnd === -1 ? [] : rest.slice(flagEnd);
44722
+ execFileSync2(tmuxBin, ["-L", socket, subcmd, ...flagsBeforeKeys, "-t", session, ...keys], { stdio: ["pipe", "pipe", "pipe"] });
44723
+ },
44724
+ hasSession(socket, session) {
44725
+ try {
44726
+ execFileSync2(tmuxBin, ["-L", socket, "has-session", "-t", session], {
44727
+ stdio: ["pipe", "pipe", "pipe"]
44728
+ });
44729
+ return true;
44730
+ } catch {
44731
+ return false;
44732
+ }
44733
+ }
44734
+ };
44735
+ }
44684
44736
 
44685
44737
  // stream-reply-handler.ts
44686
44738
  function buildAccentHeader2(accent) {
@@ -44811,6 +44863,430 @@ Allowed: <code>${deps.escapeHtml(allow)}</code>`, { html: true });
44811
44863
  await deps.reply(ctx, finalText, { html: true, accent });
44812
44864
  }
44813
44865
 
44866
+ // ../src/agents/model-picker.ts
44867
+ function labelTag(label) {
44868
+ let h = 2166136261;
44869
+ for (const ch of label) {
44870
+ h ^= ch.codePointAt(0);
44871
+ h = Math.imul(h, 16777619) >>> 0;
44872
+ }
44873
+ return h.toString(16).padStart(8, "0");
44874
+ }
44875
+
44876
+ // gateway/model-command.ts
44877
+ var MODEL_ALIASES = ["opus", "sonnet", "haiku", "default"];
44878
+ var MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._\[\]-]{0,99}$/;
44879
+ function isValidModelArg(arg) {
44880
+ return MODEL_ARG_RE.test(arg);
44881
+ }
44882
+ function parseModelCommand(text) {
44883
+ const m = text.match(/^\/model(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/);
44884
+ if (!m)
44885
+ return null;
44886
+ const rest = (m[1] ?? "").trim();
44887
+ if (rest.length === 0)
44888
+ return { kind: "show" };
44889
+ const parts = rest.split(/\s+/);
44890
+ if (parts.length > 1) {
44891
+ return { kind: "help", reason: "model takes a single argument" };
44892
+ }
44893
+ const arg = parts[0];
44894
+ if (arg.toLowerCase() === "help")
44895
+ return { kind: "help" };
44896
+ if (!isValidModelArg(arg)) {
44897
+ return { kind: "help", reason: `not a valid model name: ${arg}` };
44898
+ }
44899
+ return { kind: "set", model: arg };
44900
+ }
44901
+ var PERSIST_NOTE = "<i>Session-only \u2014 lasts until restart. To persist, set <code>model:</code> in switchroom.yaml and restart.</i>";
44902
+ function helpText2(deps, reason) {
44903
+ const lines = [];
44904
+ if (reason)
44905
+ lines.push(`\u26a0\ufe0f ${deps.escapeHtml(reason)}`);
44906
+ lines.push("<b>/model</b> \u2014 show or switch the Claude model", "<code>/model</code> \u2014 show the configured model", `<code>/model &lt;name&gt;</code> \u2014 switch the live session (${MODEL_ALIASES.map((a) => `<code>${a}</code>`).join(" \u00b7 ")} or a full model id)`, PERSIST_NOTE);
44907
+ return { text: lines.join(`
44908
+ `), html: true };
44909
+ }
44910
+ async function handleModelCommand(parsed, deps) {
44911
+ if (parsed.kind === "help")
44912
+ return helpText2(deps, parsed.reason);
44913
+ if (parsed.kind === "show") {
44914
+ const configured = deps.getConfiguredModel();
44915
+ const shown = configured && configured.length > 0 ? configured : "default";
44916
+ return {
44917
+ text: [
44918
+ `<b>Model \u2014 ${deps.escapeHtml(deps.getAgentName())}</b>`,
44919
+ `Configured: <code>${deps.escapeHtml(shown)}</code>`,
44920
+ `Switch the live session: ${MODEL_ALIASES.map((a) => `<code>/model ${a}</code>`).join(" \u00b7 ")}`,
44921
+ "or <code>/model &lt;full-model-id&gt;</code>",
44922
+ PERSIST_NOTE
44923
+ ].join(`
44924
+ `),
44925
+ html: true
44926
+ };
44927
+ }
44928
+ if (!isValidModelArg(parsed.model)) {
44929
+ return helpText2(deps, `not a valid model name: ${parsed.model}`);
44930
+ }
44931
+ const verbHtml = `<code>/model ${deps.escapeHtml(parsed.model)}</code>`;
44932
+ let result;
44933
+ try {
44934
+ result = await deps.inject(deps.getAgentName(), `/model ${parsed.model}`);
44935
+ } catch (err) {
44936
+ const msg = err instanceof Error ? err.message : String(err);
44937
+ return {
44938
+ text: `\u274c ${verbHtml} \u2014 inject failed: ${deps.escapeHtml(msg)}`,
44939
+ html: true
44940
+ };
44941
+ }
44942
+ if (result.outcome === "ok") {
44943
+ return {
44944
+ text: [
44945
+ `${verbHtml}`,
44946
+ deps.preBlock(result.output),
44947
+ ...result.truncated ? ["<i>truncated</i>"] : [],
44948
+ PERSIST_NOTE
44949
+ ].join(`
44950
+ `),
44951
+ html: true
44952
+ };
44953
+ }
44954
+ if (result.outcome === "ok_no_output") {
44955
+ return {
44956
+ text: [
44957
+ `${verbHtml} \u2014 sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active model.`,
44958
+ PERSIST_NOTE
44959
+ ].join(`
44960
+ `),
44961
+ html: true
44962
+ };
44963
+ }
44964
+ if (result.errorCode === "session_missing") {
44965
+ return {
44966
+ text: "\u274c tmux session not found \u2014 the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.",
44967
+ html: true
44968
+ };
44969
+ }
44970
+ return {
44971
+ text: `\u274c ${verbHtml} \u2014 ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`,
44972
+ html: true
44973
+ };
44974
+ }
44975
+ var MODEL_CALLBACK_PREFIX = "mdl:";
44976
+ var MODEL_CALLBACK_SELECT = "mdl:s:";
44977
+ var MODEL_CALLBACK_REFRESH = "mdl:r";
44978
+ function modelSelectCallbackData(label) {
44979
+ return `${MODEL_CALLBACK_SELECT}${labelTag(label)}`;
44980
+ }
44981
+ function busyReply(deps) {
44982
+ return {
44983
+ text: "\u23f3 The agent is mid-turn \u2014 the model picker needs an idle prompt. Try again in a moment.",
44984
+ html: true
44985
+ };
44986
+ }
44987
+ function menuKeyboard(options) {
44988
+ const rows = options.map((o) => [
44989
+ {
44990
+ text: o.current ? `\u2705 ${o.label}` : o.label,
44991
+ callback_data: modelSelectCallbackData(o.label)
44992
+ }
44993
+ ]);
44994
+ rows.push([{ text: "\uD83D\uDD04 Refresh", callback_data: MODEL_CALLBACK_REFRESH }]);
44995
+ return rows;
44996
+ }
44997
+ async function buildModelMenu(deps) {
44998
+ if (deps.isBusy())
44999
+ return busyReply(deps);
45000
+ const [discovered, quota] = await Promise.all([
45001
+ deps.discover(deps.getAgentName()),
45002
+ deps.getQuotaBrief().catch(() => null)
45003
+ ]);
45004
+ if (!discovered.ok) {
45005
+ const v1 = await handleModelCommand({ kind: "show" }, deps);
45006
+ return {
45007
+ text: [`<i>(picker unavailable: ${deps.escapeHtml(discovered.reason)})</i>`, v1.text].join(`
45008
+ `),
45009
+ html: true
45010
+ };
45011
+ }
45012
+ const current = discovered.options.find((o) => o.current);
45013
+ const lines = [`<b>Model \u2014 ${deps.escapeHtml(deps.getAgentName())}</b>`];
45014
+ if (discovered.dismissFailed) {
45015
+ lines.push("\u26a0\ufe0f <i>The picker may still be open on the agent pane \u2014 check it before switching.</i>");
45016
+ }
45017
+ if (current) {
45018
+ const detail = current.detail ? ` \u00b7 ${deps.escapeHtml(current.detail)}` : "";
45019
+ lines.push(`Now: <b>${deps.escapeHtml(current.label)}</b>${detail}`);
45020
+ } else {
45021
+ lines.push("Now: <i>unknown (no \u2714 row in picker)</i>");
45022
+ }
45023
+ if (quota)
45024
+ lines.push(`Quota: ${deps.escapeHtml(quota)}`);
45025
+ lines.push("", "Tap to switch (applies to the live session):");
45026
+ lines.push(PERSIST_NOTE);
45027
+ return { text: lines.join(`
45028
+ `), html: true, keyboard: menuKeyboard(discovered.options) };
45029
+ }
45030
+ async function handleModelMenuCallback(data, deps) {
45031
+ if (data === MODEL_CALLBACK_REFRESH) {
45032
+ return { answer: "Refreshed", reply: await buildModelMenu(deps) };
45033
+ }
45034
+ if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
45035
+ return { answer: "Unknown action", reply: await buildModelMenu(deps) };
45036
+ }
45037
+ if (deps.isBusy()) {
45038
+ return { answer: "Agent is mid-turn \u2014 try again shortly", reply: busyReply(deps) };
45039
+ }
45040
+ const tag = data.slice(MODEL_CALLBACK_SELECT.length);
45041
+ const discovered = await deps.discover(deps.getAgentName());
45042
+ if (!discovered.ok) {
45043
+ return {
45044
+ answer: "Picker unavailable",
45045
+ reply: {
45046
+ text: `\u274c Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`,
45047
+ html: true
45048
+ }
45049
+ };
45050
+ }
45051
+ const target = discovered.options.find((o) => labelTag(o.label) === tag);
45052
+ if (!target) {
45053
+ const fresh2 = await buildModelMenu(deps);
45054
+ return { answer: "Model list changed \u2014 menu refreshed", reply: fresh2 };
45055
+ }
45056
+ if (target.current) {
45057
+ const fresh2 = await buildModelMenu(deps);
45058
+ return { answer: `Already on ${target.label}`, reply: fresh2 };
45059
+ }
45060
+ const result = await deps.select(deps.getAgentName(), target.label);
45061
+ if (!result.ok) {
45062
+ return {
45063
+ answer: "Switch failed",
45064
+ reply: {
45065
+ text: `\u274c Switch to <b>${deps.escapeHtml(target.label)}</b> failed: ${deps.escapeHtml(result.reason)}`,
45066
+ html: true
45067
+ }
45068
+ };
45069
+ }
45070
+ const fresh = await buildModelMenu(deps);
45071
+ const confirmed = {
45072
+ text: [`\u2705 ${deps.escapeHtml(result.confirmation)}`, "", fresh.text].join(`
45073
+ `),
45074
+ html: true,
45075
+ ...fresh.keyboard ? { keyboard: fresh.keyboard } : {}
45076
+ };
45077
+ return { answer: result.confirmation, reply: confirmed };
45078
+ }
45079
+
45080
+ // ../src/agents/model-picker.ts
45081
+ var HEADER_RE = /Select model/;
45082
+ var OPTION_RE = /^\s*(\u276f)?\s*(\d+)\.\s+(.*)$/;
45083
+ var FOOTER_RE = /Esc to cancel/i;
45084
+ var EFFORT_RE = /^\s*\u25cb\s/;
45085
+ function parseModelPicker(pane) {
45086
+ const lines = pane.split(`
45087
+ `);
45088
+ let headerIdx = -1;
45089
+ for (let i = lines.length - 1;i >= 0; i--) {
45090
+ if (HEADER_RE.test(lines[i])) {
45091
+ headerIdx = i;
45092
+ break;
45093
+ }
45094
+ }
45095
+ if (headerIdx < 0)
45096
+ return null;
45097
+ const options = [];
45098
+ let cursorIndex = -1;
45099
+ let footerSeen = false;
45100
+ for (let i = headerIdx + 1;i < lines.length; i++) {
45101
+ const raw = lines[i];
45102
+ if (FOOTER_RE.test(raw)) {
45103
+ footerSeen = true;
45104
+ break;
45105
+ }
45106
+ if (EFFORT_RE.test(raw))
45107
+ continue;
45108
+ const m = OPTION_RE.exec(raw);
45109
+ if (m) {
45110
+ const index = Number(m[2]);
45111
+ const rest = m[3].trimEnd();
45112
+ const gap = rest.search(/\s{2,}/);
45113
+ let label = (gap >= 0 ? rest.slice(0, gap) : rest).trim();
45114
+ const detail = gap >= 0 ? rest.slice(gap).trim() : "";
45115
+ let current = false;
45116
+ if (/[\u2714\u2713]$/.test(label)) {
45117
+ current = true;
45118
+ label = label.replace(/\s*[\u2714\u2713]$/, "").trim();
45119
+ }
45120
+ if (label.length === 0)
45121
+ continue;
45122
+ options.push({ index, label, detail, current });
45123
+ if (m[1])
45124
+ cursorIndex = index;
45125
+ continue;
45126
+ }
45127
+ if (options.length > 0 && /^\s{4,}\S/.test(raw)) {
45128
+ const last = options[options.length - 1];
45129
+ last.detail = `${last.detail} ${raw.trim()}`.trim();
45130
+ continue;
45131
+ }
45132
+ }
45133
+ if (options.length === 0)
45134
+ return null;
45135
+ return { options, cursorIndex, footerSeen };
45136
+ }
45137
+ var realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
45138
+ function makeIo(agentName3, opts) {
45139
+ return {
45140
+ runner: opts._runner ?? makeTmuxRunner2(opts.tmuxBin ?? "tmux"),
45141
+ socket: opts.socketName ?? `switchroom-${agentName3}`,
45142
+ session: opts.sessionName ?? agentName3,
45143
+ stepMs: opts.stepMs ?? 600,
45144
+ timeoutMs: opts.timeoutMs ?? 1e4,
45145
+ sleep: opts._sleep ?? realSleep,
45146
+ log: opts._log ?? ((line) => process.stderr.write(`${line}
45147
+ `)),
45148
+ startedAt: Date.now()
45149
+ };
45150
+ }
45151
+ async function dismissOrWarn(io, verb) {
45152
+ const dismissed = await dismissPicker(io);
45153
+ if (!dismissed) {
45154
+ io.log(`model-picker: ${verb}: picker may still be open after Esc retries ` + `(socket=${io.socket} session=${io.session}) \u2014 pane needs eyes`);
45155
+ }
45156
+ return dismissed;
45157
+ }
45158
+ function expired(io) {
45159
+ return Date.now() - io.startedAt >= io.timeoutMs;
45160
+ }
45161
+ function sendLiteral(io, text) {
45162
+ io.runner.send(io.socket, io.session, ["send-keys", "-l", text]);
45163
+ }
45164
+ function sendKey(io, key) {
45165
+ io.runner.send(io.socket, io.session, ["send-keys", key]);
45166
+ }
45167
+ async function openPicker(io) {
45168
+ sendLiteral(io, "/model");
45169
+ sendKey(io, "Enter");
45170
+ for (;; ) {
45171
+ await io.sleep(io.stepMs);
45172
+ const pane = io.runner.capture(io.socket, io.session) ?? "";
45173
+ const parsed = parseModelPicker(pane);
45174
+ if (parsed?.footerSeen)
45175
+ return parsed;
45176
+ if (expired(io))
45177
+ return parsed;
45178
+ }
45179
+ }
45180
+ async function dismissPicker(io) {
45181
+ for (let attempt = 0;attempt < 2; attempt++) {
45182
+ try {
45183
+ sendKey(io, "Escape");
45184
+ } catch {
45185
+ return false;
45186
+ }
45187
+ await io.sleep(io.stepMs);
45188
+ const pane = io.runner.capture(io.socket, io.session) ?? "";
45189
+ const parsed = parseModelPicker(pane);
45190
+ if (!parsed || !parsed.footerSeen)
45191
+ return true;
45192
+ const tail = pane.slice(pane.lastIndexOf("Esc to cancel"));
45193
+ if (tail.split(`
45194
+ `).slice(1).some((l) => l.trim().length > 0))
45195
+ return true;
45196
+ }
45197
+ return false;
45198
+ }
45199
+ async function discoverModels(agentName3, opts = {}) {
45200
+ const io = makeIo(agentName3, opts);
45201
+ if (!io.runner.hasSession(io.socket, io.session)) {
45202
+ return { ok: false, reason: "tmux session not found" };
45203
+ }
45204
+ return withPaneLock(`${io.socket}:${io.session}`, async () => {
45205
+ io.startedAt = Date.now();
45206
+ let parsed = null;
45207
+ let dismissed = true;
45208
+ try {
45209
+ parsed = await openPicker(io);
45210
+ } finally {
45211
+ dismissed = await dismissOrWarn(io, "discover");
45212
+ }
45213
+ const tail = dismissed ? {} : { dismissFailed: true };
45214
+ if (!parsed || !parsed.footerSeen) {
45215
+ return {
45216
+ ok: false,
45217
+ reason: "picker did not render \u2014 agent may be mid-turn or the CLI changed its /model UI",
45218
+ ...tail
45219
+ };
45220
+ }
45221
+ const current = parsed.options.find((o) => o.current) ?? null;
45222
+ return { ok: true, options: parsed.options, currentLabel: current?.label ?? null, ...tail };
45223
+ });
45224
+ }
45225
+ async function selectModel(agentName3, targetLabel, opts = {}) {
45226
+ const io = makeIo(agentName3, opts);
45227
+ if (!io.runner.hasSession(io.socket, io.session)) {
45228
+ return { ok: false, reason: "tmux session not found" };
45229
+ }
45230
+ return withPaneLock(`${io.socket}:${io.session}`, async () => {
45231
+ io.startedAt = Date.now();
45232
+ let selected = false;
45233
+ try {
45234
+ const parsed = await openPicker(io);
45235
+ if (!parsed || !parsed.footerSeen) {
45236
+ return { ok: false, reason: "picker did not render \u2014 agent may be mid-turn" };
45237
+ }
45238
+ const target = parsed.options.find((o) => o.label === targetLabel);
45239
+ if (!target) {
45240
+ const have = parsed.options.map((o) => o.label).join(", ");
45241
+ return { ok: false, reason: `option "${targetLabel}" not offered (have: ${have})` };
45242
+ }
45243
+ if (parsed.cursorIndex < 0) {
45244
+ return { ok: false, reason: "cursor marker not visible \u2014 refusing blind navigation" };
45245
+ }
45246
+ const delta = target.index - parsed.cursorIndex;
45247
+ const key = delta > 0 ? "Down" : "Up";
45248
+ for (let i = 0;i < Math.abs(delta); i++) {
45249
+ if (expired(io))
45250
+ return { ok: false, reason: "timed out navigating picker" };
45251
+ sendKey(io, key);
45252
+ await io.sleep(Math.min(io.stepMs, 250));
45253
+ }
45254
+ await io.sleep(io.stepMs);
45255
+ const verifyPane = io.runner.capture(io.socket, io.session) ?? "";
45256
+ const verify = parseModelPicker(verifyPane);
45257
+ const atRow = verify?.options.find((o) => o.index === verify.cursorIndex);
45258
+ if (!verify || !atRow || atRow.label !== targetLabel) {
45259
+ return {
45260
+ ok: false,
45261
+ reason: `cursor verification failed (on "${atRow?.label ?? "?"}", wanted "${targetLabel}")`
45262
+ };
45263
+ }
45264
+ sendLiteral(io, "s");
45265
+ selected = true;
45266
+ await io.sleep(io.stepMs);
45267
+ const after = io.runner.capture(io.socket, io.session) ?? "";
45268
+ const confirmation = extractConfirmation(after) ?? `Switched to ${targetLabel} (session)`;
45269
+ return { ok: true, confirmation };
45270
+ } catch (err) {
45271
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
45272
+ } finally {
45273
+ if (!selected) {
45274
+ await dismissOrWarn(io, "select");
45275
+ }
45276
+ }
45277
+ });
45278
+ }
45279
+ function extractConfirmation(pane) {
45280
+ const lines = pane.split(`
45281
+ `);
45282
+ for (let i = lines.length - 1;i >= 0; i--) {
45283
+ const t = lines[i].replace(/^\s*\u23bf\s*/, "").trim();
45284
+ if (/^(Set model to|Kept model as|Switched to)/i.test(t))
45285
+ return t;
45286
+ }
45287
+ return null;
45288
+ }
45289
+
44814
45290
  // ../src/config/loader.ts
44815
45291
  init_dist();
44816
45292
  init_zod();
@@ -50872,7 +51348,7 @@ function startSubagentWatcher(config) {
50872
51348
  watch
50873
51349
  };
50874
51350
  const registry = new Map;
50875
- const tails = new Map;
51351
+ const tails2 = new Map;
50876
51352
  const dirWatchers = new Map;
50877
51353
  const knownFiles = new Set;
50878
51354
  const pendingCloses = new Map;
@@ -50918,7 +51394,7 @@ function startSubagentWatcher(config) {
50918
51394
  hasEmittedStart: false,
50919
51395
  watcher: null
50920
51396
  };
50921
- tails.set(agentId, tail);
51397
+ tails2.set(agentId, tail);
50922
51398
  readSubTail(entry, tail, n, (desc) => {
50923
51399
  log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
50924
51400
  }, fs2, log, db2, parentStateDir, config.onUnstall, undefined, config.onProgress);
@@ -50956,7 +51432,7 @@ function startSubagentWatcher(config) {
50956
51432
  if (stopped)
50957
51433
  return;
50958
51434
  const entry2 = registry.get(agentId);
50959
- const t = tails.get(agentId);
51435
+ const t = tails2.get(agentId);
50960
51436
  if (!entry2 || !t)
50961
51437
  return;
50962
51438
  readSubTail(entry2, t, nowFn(), (desc) => {
@@ -51025,14 +51501,14 @@ function startSubagentWatcher(config) {
51025
51501
  pendingCloses.set(agentId, handle);
51026
51502
  }
51027
51503
  function cleanupTerminalAgent(agentId) {
51028
- const tail = tails.get(agentId);
51504
+ const tail = tails2.get(agentId);
51029
51505
  if (tail?.watcher) {
51030
51506
  try {
51031
51507
  tail.watcher.close();
51032
51508
  } catch {}
51033
51509
  tail.watcher = null;
51034
51510
  }
51035
- tails.delete(agentId);
51511
+ tails2.delete(agentId);
51036
51512
  const entry = registry.get(agentId);
51037
51513
  if (entry?.filePath) {
51038
51514
  knownFiles.delete(entry.filePath);
@@ -51202,7 +51678,7 @@ function startSubagentWatcher(config) {
51202
51678
  for (const [agentId, entry] of registry) {
51203
51679
  if (entry.state !== "running")
51204
51680
  continue;
51205
- const tail = tails.get(agentId);
51681
+ const tail = tails2.get(agentId);
51206
51682
  if (!tail)
51207
51683
  continue;
51208
51684
  readSubTail(entry, tail, n, (desc) => {
@@ -51244,7 +51720,7 @@ function startSubagentWatcher(config) {
51244
51720
  } catch {}
51245
51721
  }
51246
51722
  dirWatchers.clear();
51247
- for (const tail of tails.values()) {
51723
+ for (const tail of tails2.values()) {
51248
51724
  if (tail.watcher) {
51249
51725
  try {
51250
51726
  tail.watcher.close();
@@ -51252,7 +51728,7 @@ function startSubagentWatcher(config) {
51252
51728
  tail.watcher = null;
51253
51729
  }
51254
51730
  }
51255
- tails.clear();
51731
+ tails2.clear();
51256
51732
  registry.clear();
51257
51733
  knownFiles.clear();
51258
51734
  terminatedAgentIds.clear();
@@ -53078,10 +53554,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
53078
53554
  }
53079
53555
 
53080
53556
  // ../src/build-info.ts
53081
- var VERSION = "0.14.72";
53082
- var COMMIT_SHA = "0e840d59";
53083
- var COMMIT_DATE = "2026-06-06T00:39:32Z";
53084
- var LATEST_PR = 2183;
53557
+ var VERSION = "0.15.3";
53558
+ var COMMIT_SHA = "af652154";
53559
+ var COMMIT_DATE = "2026-06-10T09:15:23Z";
53560
+ var LATEST_PR = 2266;
53085
53561
  var COMMITS_AHEAD_OF_TAG = 0;
53086
53562
 
53087
53563
  // gateway/boot-version.ts
@@ -60725,6 +61201,62 @@ bot.command("inject", async (ctx) => {
60725
61201
  formatOutput: formatSwitchroomOutput
60726
61202
  });
60727
61203
  });
61204
+ function buildModelDeps() {
61205
+ return {
61206
+ discover: (a) => discoverModels(a),
61207
+ select: (a, label) => selectModel(a, label),
61208
+ isBusy: () => currentTurn !== null,
61209
+ getAgentName: getMyAgentName,
61210
+ getQuotaBrief: async () => {
61211
+ try {
61212
+ const probed = await probeQuotaForBootCard(getMyAgentName(), 4000);
61213
+ if (probed?.ok)
61214
+ return formatQuotaLine2(probed.data);
61215
+ } catch {}
61216
+ try {
61217
+ const agentDir = resolveAgentDirFromEnv();
61218
+ if (agentDir) {
61219
+ const local = await fetchQuota2({ claudeConfigDir: join35(agentDir, ".claude") });
61220
+ if (local.ok)
61221
+ return formatQuotaLine2(local.data);
61222
+ }
61223
+ } catch {}
61224
+ return null;
61225
+ },
61226
+ inject: injectSlashCommand,
61227
+ getConfiguredModel: () => {
61228
+ const data = switchroomExecJson(["agent", "list"]);
61229
+ return data?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
61230
+ },
61231
+ escapeHtml: escapeHtmlForTg,
61232
+ preBlock
61233
+ };
61234
+ }
61235
+ function modelMenuReplyMarkup(reply) {
61236
+ if (!reply.keyboard)
61237
+ return;
61238
+ const kb = new import_grammy9.InlineKeyboard;
61239
+ for (const row of reply.keyboard) {
61240
+ for (const btn of row)
61241
+ kb.text(btn.text, btn.callback_data);
61242
+ kb.row();
61243
+ }
61244
+ return kb;
61245
+ }
61246
+ bot.command("model", async (ctx) => {
61247
+ if (!isAuthorizedSender(ctx))
61248
+ return;
61249
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? "";
61250
+ const parsed = parseModelCommand(text) ?? { kind: "show" };
61251
+ const deps = buildModelDeps();
61252
+ if (parsed.kind === "show" && process.env.SWITCHROOM_MODEL_MENU !== "0") {
61253
+ const menu = await buildModelMenu(deps);
61254
+ await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) });
61255
+ return;
61256
+ }
61257
+ const reply = await handleModelCommand(parsed, deps);
61258
+ await switchroomReply(ctx, reply.text, { html: reply.html });
61259
+ });
60728
61260
  bot.command("agentstart", async (ctx) => {
60729
61261
  if (!isAuthorizedSender(ctx))
60730
61262
  return;
@@ -61604,7 +62136,7 @@ bot.command("connect", async (ctx) => {
61604
62136
  try {
61605
62137
  const cfg = loadConfig2();
61606
62138
  const me = cfg?.agents?.[getMyAgentName()];
61607
- isAdmin2 = me?.admin === true;
62139
+ isAdmin2 = me?.admin === true || me?.root === true;
61608
62140
  } catch {}
61609
62141
  if (!isAuthAdmin({ isAdmin: isAdmin2 })) {
61610
62142
  await switchroomReply(ctx, `<b>Not authorized.</b> <code>/connect</code> requires this agent to have <code>admin: true</code> in switchroom.yaml.`, { html: true });
@@ -61693,7 +62225,7 @@ bot.command("auth", async (ctx) => {
61693
62225
  try {
61694
62226
  const cfg = loadConfig2();
61695
62227
  const me = cfg?.agents?.[currentAgent];
61696
- isAdmin2 = me?.admin === true;
62228
+ isAdmin2 = me?.admin === true || me?.root === true;
61697
62229
  } catch {}
61698
62230
  const chatId = String(ctx.chat?.id ?? "");
61699
62231
  if (parsed.kind === "add" || parsed.kind === "cancel") {
@@ -63452,6 +63984,34 @@ bot.on("callback_query:data", async (ctx) => {
63452
63984
  await handleAuthDashboardCallback(ctx);
63453
63985
  return;
63454
63986
  }
63987
+ if (data.startsWith(MODEL_CALLBACK_PREFIX)) {
63988
+ const access2 = loadAccess();
63989
+ const senderId2 = String(ctx.from?.id ?? "");
63990
+ if (!access2.allowFrom.includes(senderId2)) {
63991
+ await ctx.answerCallbackQuery({ text: "Not authorized." });
63992
+ return;
63993
+ }
63994
+ if (process.env.SWITCHROOM_MODEL_MENU === "0") {
63995
+ await ctx.answerCallbackQuery({ text: "Model menu is disabled (SWITCHROOM_MODEL_MENU=0)." }).catch(() => {});
63996
+ await ctx.editMessageText("Model menu is disabled on this agent. Use <code>/model &lt;name&gt;</code>.", {
63997
+ parse_mode: "HTML",
63998
+ reply_markup: { inline_keyboard: [] }
63999
+ }).catch(() => {});
64000
+ return;
64001
+ }
64002
+ await ctx.answerCallbackQuery({ text: "Working\u2026" }).catch(() => {});
64003
+ try {
64004
+ const outcome = await handleModelMenuCallback(data, buildModelDeps());
64005
+ await ctx.editMessageText(outcome.reply.text, {
64006
+ parse_mode: "HTML",
64007
+ reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] }
64008
+ }).catch(() => {});
64009
+ } catch (err) {
64010
+ process.stderr.write(`telegram gateway: model-menu callback failed: ${err?.message ?? String(err)}
64011
+ `);
64012
+ }
64013
+ return;
64014
+ }
63455
64015
  if (data.startsWith("cn:")) {
63456
64016
  const access2 = loadAccess();
63457
64017
  const senderId2 = String(ctx.from?.id ?? "");