switchroom 0.16.13 → 0.16.15

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.
@@ -15589,6 +15589,10 @@ var init_helpers = __esm(() => {
15589
15589
  function isKnownCheapModel(model) {
15590
15590
  return model !== undefined && CHEAP_MODEL_RE.test(model);
15591
15591
  }
15592
+ function isCheapCronEnabled(env2 = process.env) {
15593
+ const v = (env2.SWITCHROOM_CHEAP_CRON ?? "").toLowerCase();
15594
+ return !(v === "0" || v === "false" || v === "off");
15595
+ }
15592
15596
  function resolveCronModel(model) {
15593
15597
  return isKnownCheapModel(model) ? model : DEFAULT_CRON_MODEL;
15594
15598
  }
@@ -50753,6 +50757,10 @@ function dispatchTool(name, args) {
50753
50757
  cliArgs = buildArgs(["cron", "list"], args);
50754
50758
  parseMode = "json";
50755
50759
  break;
50760
+ case "cron_doctor":
50761
+ cliArgs = buildArgs(["cron", "doctor"], args);
50762
+ parseMode = "json";
50763
+ break;
50756
50764
  case "skill_list":
50757
50765
  cliArgs = buildArgs(["skill", "list"], args);
50758
50766
  parseMode = "json";
@@ -50970,7 +50978,17 @@ var init_server3 = __esm(() => {
50970
50978
  },
50971
50979
  {
50972
50980
  name: "cron_list",
50973
- description: "List the agent's scheduled cron entries (schedule array) as JSON.",
50981
+ description: "List the agent's scheduled cron entries (schedule array) as JSON. " + "Each entry is self-describing: `source` (base-config vs overlay), " + "`file` (switchroom.yaml or the schedule.d path), resolved " + "`tier`/`context`, and a `duplicate_of` back-reference when another " + "entry shares the same cron expression. Original entry fields are " + "preserved.",
50982
+ inputSchema: {
50983
+ type: "object",
50984
+ properties: {
50985
+ agent: { type: "string" }
50986
+ }
50987
+ }
50988
+ },
50989
+ {
50990
+ name: "cron_doctor",
50991
+ description: "Read-only cron health report: duplicate cron expressions (the " + "double-fire hazard), base-config-vs-overlay name conflicts, and " + "entries missing a resolved tier/context. Returns " + "{agent, entry_count, healthy, findings[]}. Call this BEFORE adding " + "a cron, or when a schedule misbehaves (firing twice, removing the " + "wrong entry).",
50974
50992
  inputSchema: {
50975
50993
  type: "object",
50976
50994
  properties: {
@@ -51674,8 +51692,8 @@ import { existsSync, readFileSync } from "node:fs";
51674
51692
  import { dirname, join } from "node:path";
51675
51693
 
51676
51694
  // src/build-info.ts
51677
- var VERSION = "0.16.13";
51678
- var COMMIT_SHA = "d4f3ac38";
51695
+ var VERSION = "0.16.15";
51696
+ var COMMIT_SHA = "a9c59169";
51679
51697
 
51680
51698
  // src/cli/resolve-version.ts
51681
51699
  function readPackageVersion() {
@@ -51828,6 +51846,138 @@ import {
51828
51846
  appendFileSync,
51829
51847
  readFileSync as readFileSync5
51830
51848
  } from "node:fs";
51849
+
51850
+ // src/scheduler/cron-introspect.ts
51851
+ init_overlay_loader();
51852
+ init_cron_routing();
51853
+ function normalizeCronExpr(expr) {
51854
+ if (typeof expr !== "string")
51855
+ return null;
51856
+ const t = expr.trim();
51857
+ if (t === "")
51858
+ return null;
51859
+ return t.replace(/\s+/g, " ");
51860
+ }
51861
+ function attributeEntry(raw) {
51862
+ const entry = raw ?? {};
51863
+ const isOverlay = entry[OVERLAY_SOURCE] === true;
51864
+ const overlayTitle = entry[OVERLAY_TITLE];
51865
+ const explicitName = typeof entry.name === "string" && entry.name.trim() !== "" ? entry.name : null;
51866
+ if (isOverlay) {
51867
+ const title = typeof overlayTitle === "string" && overlayTitle.trim() !== "" ? overlayTitle : explicitName;
51868
+ const cron = typeof entry.cron === "string" ? entry.cron : "";
51869
+ const prompt = typeof entry.prompt === "string" ? entry.prompt : undefined;
51870
+ const file = `schedule.d/cron-${cronUnitHash(cron, prompt)}.yaml`;
51871
+ return { entry, source: "overlay", file, name: title };
51872
+ }
51873
+ return { entry, source: "base-config", file: "switchroom.yaml", name: explicitName };
51874
+ }
51875
+ function resolveTierContext(entry, cheapCronEnabled) {
51876
+ const routing = resolveCronRouting({ kind: entry.kind, model: entry.model, context: entry.context }, { cheapCronEnabled });
51877
+ const ctx = routing.tier === "poll" || routing.tier === "action" ? null : routing.session === "cron" ? "fresh" : "agent";
51878
+ return { tier: routing.tier, context: ctx };
51879
+ }
51880
+ function buildCronList(schedule, opts) {
51881
+ const raws = (schedule ?? []).map(attributeEntry);
51882
+ const byExpr = new Map;
51883
+ raws.forEach((r, i) => {
51884
+ const expr = normalizeCronExpr(r.entry.cron);
51885
+ if (expr === null)
51886
+ return;
51887
+ const arr = byExpr.get(expr);
51888
+ if (arr)
51889
+ arr.push(i);
51890
+ else
51891
+ byExpr.set(expr, [i]);
51892
+ });
51893
+ return raws.map((r, i) => {
51894
+ const { tier, context } = resolveTierContext(r.entry, opts.cheapCronEnabled);
51895
+ const expr = normalizeCronExpr(r.entry.cron);
51896
+ const out = {
51897
+ ...r.entry,
51898
+ name: r.name,
51899
+ source: r.source,
51900
+ file: r.file,
51901
+ tier,
51902
+ context
51903
+ };
51904
+ if (expr !== null) {
51905
+ const siblings = (byExpr.get(expr) ?? []).filter((j) => j !== i);
51906
+ if (siblings.length > 0) {
51907
+ out.duplicate_of = siblings.map((j) => ({ name: raws[j].name, file: raws[j].file }));
51908
+ }
51909
+ }
51910
+ return out;
51911
+ });
51912
+ }
51913
+ function cronDoctor(agent, schedule, opts) {
51914
+ const raws = (schedule ?? []).map(attributeEntry);
51915
+ const findings = [];
51916
+ const byExpr = new Map;
51917
+ for (const r of raws) {
51918
+ const expr = normalizeCronExpr(r.entry.cron);
51919
+ if (expr === null)
51920
+ continue;
51921
+ const arr = byExpr.get(expr);
51922
+ if (arr)
51923
+ arr.push(r);
51924
+ else
51925
+ byExpr.set(expr, [r]);
51926
+ }
51927
+ for (const [expr, group] of byExpr) {
51928
+ if (group.length > 1) {
51929
+ findings.push({
51930
+ kind: "duplicate_cron",
51931
+ severity: "error",
51932
+ message: `${group.length} entries share cron expression "${expr}" \u2014 they will ` + `all fire together (double-fire). Remove the redundant one(s).`,
51933
+ cron: expr,
51934
+ entries: group.map((r) => ({ name: r.name, file: r.file }))
51935
+ });
51936
+ }
51937
+ }
51938
+ const byName = new Map;
51939
+ for (const r of raws) {
51940
+ if (r.name === null)
51941
+ continue;
51942
+ const arr = byName.get(r.name);
51943
+ if (arr)
51944
+ arr.push(r);
51945
+ else
51946
+ byName.set(r.name, [r]);
51947
+ }
51948
+ for (const [name, group] of byName) {
51949
+ const sources = new Set(group.map((r) => r.source));
51950
+ if (group.length > 1 && sources.size > 1) {
51951
+ findings.push({
51952
+ kind: "name_conflict",
51953
+ severity: "warn",
51954
+ message: `name "${name}" is used by both a base-config and an overlay entry \u2014 ` + `removing by name is ambiguous.`,
51955
+ conflicting_name: name,
51956
+ entries: group.map((r) => ({ name: r.name, file: r.file }))
51957
+ });
51958
+ }
51959
+ }
51960
+ for (const r of raws) {
51961
+ const expr = normalizeCronExpr(r.entry.cron);
51962
+ if (expr === null) {
51963
+ findings.push({
51964
+ kind: "missing_tier",
51965
+ severity: "warn",
51966
+ message: `entry ${r.name ? `"${r.name}"` : "(unnamed)"} has no valid cron ` + `expression, so its tier/context cannot be resolved.`,
51967
+ entries: [{ name: r.name, file: r.file }]
51968
+ });
51969
+ }
51970
+ }
51971
+ return {
51972
+ agent,
51973
+ entry_count: raws.length,
51974
+ findings,
51975
+ healthy: findings.length === 0
51976
+ };
51977
+ }
51978
+
51979
+ // src/cli/agent-config.ts
51980
+ init_cron_routing();
51831
51981
  var AUDIT_ROOT = join3(homedir2(), ".switchroom", "audit");
51832
51982
  function auditPathFor(agent) {
51833
51983
  return join3(AUDIT_ROOT, agent, "agent-config.jsonl");
@@ -52068,7 +52218,10 @@ function registerAgentConfigCommands(program2) {
52068
52218
  const cfg = getConfig(program2);
52069
52219
  try {
52070
52220
  const slice = getAgentSlice(cfg, agent);
52071
- const tasks = stripSecretValues(slice.schedule ?? []);
52221
+ const rows = buildCronList(slice.schedule ?? [], {
52222
+ cheapCronEnabled: isCheapCronEnabled()
52223
+ });
52224
+ const tasks = stripSecretValues(rows);
52072
52225
  process.stdout.write(JSON.stringify(tasks) + `
52073
52226
  `);
52074
52227
  appendAudit(agent, "cron.list", { ...opts }, 0);
@@ -52079,6 +52232,32 @@ function registerAgentConfigCommands(program2) {
52079
52232
  process.exit(1);
52080
52233
  }
52081
52234
  }));
52235
+ cron.command("doctor").description("Report cron health: duplicate cron expressions, base-vs-overlay " + "name conflicts, and entries missing a resolved tier/context. " + "Read-only.").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").action(withConfigError(async (opts) => {
52236
+ let agent;
52237
+ try {
52238
+ agent = resolveTargetAgent(opts.agent);
52239
+ } catch (err) {
52240
+ process.stderr.write(`${err.message}
52241
+ `);
52242
+ appendAudit(opts.agent ?? "<unknown>", "cron.doctor", { ...opts }, 7);
52243
+ process.exit(7);
52244
+ }
52245
+ const cfg = getConfig(program2);
52246
+ try {
52247
+ const slice = getAgentSlice(cfg, agent);
52248
+ const report = cronDoctor(agent, slice.schedule ?? [], {
52249
+ cheapCronEnabled: isCheapCronEnabled()
52250
+ });
52251
+ process.stdout.write(JSON.stringify(report) + `
52252
+ `);
52253
+ appendAudit(agent, "cron.doctor", { ...opts }, 0);
52254
+ } catch (err) {
52255
+ process.stderr.write(`${err.message}
52256
+ `);
52257
+ appendAudit(agent, "cron.doctor", { ...opts }, 1);
52258
+ process.exit(1);
52259
+ }
52260
+ }));
52082
52261
  const skill = program2.commands.find((c) => c.name() === "skill") ?? program2.command("skill").description("Read-only access to an agent's skill list");
52083
52262
  skill.command("list").description("List the agent's configured skills as JSON").option("--agent <name>", "Target agent (defaults to $SWITCHROOM_AGENT_NAME)").action(withConfigError(async (opts) => {
52084
52263
  let agent;
@@ -87819,6 +87998,7 @@ function exitCodeFor(code) {
87819
87998
  switch (code) {
87820
87999
  case "E_OVERLAY_SECRETS_REQUIRES_APPROVAL":
87821
88000
  case "E_CRON_TOO_FREQUENT":
88001
+ case "E_CRON_DUPLICATE":
87822
88002
  case "E_QUOTA_EXCEEDED":
87823
88003
  case "E_WRITE_REQUIRES_RECREATE":
87824
88004
  case "E_SLUG_COLLISION":
@@ -87834,6 +88014,24 @@ function exitCodeFor(code) {
87834
88014
  return 10;
87835
88015
  }
87836
88016
  }
88017
+ function parseOverlayCronAndName(e) {
88018
+ let cron;
88019
+ let name;
88020
+ const headerMatch = e.raw.match(/^#[^\S\n]*name:[^\S\n]*(\S.*?)[^\S\n]*$/m);
88021
+ if (headerMatch)
88022
+ name = headerMatch[1];
88023
+ try {
88024
+ const doc = import_yaml21.parse(e.raw);
88025
+ const first = doc?.schedule?.[0];
88026
+ if (first) {
88027
+ if (typeof first.cron === "string")
88028
+ cron = first.cron;
88029
+ if (!name && typeof first.name === "string")
88030
+ name = first.name;
88031
+ }
88032
+ } catch {}
88033
+ return { cron, name };
88034
+ }
87837
88035
  function scheduleAdd(opts) {
87838
88036
  let agent;
87839
88037
  try {
@@ -87903,6 +88101,28 @@ function scheduleAdd(opts) {
87903
88101
  meta: { current: existing.length }
87904
88102
  };
87905
88103
  }
88104
+ const wantExpr = normalizeCronExpr(opts.cronExpr);
88105
+ for (const e of existing) {
88106
+ const parsed = parseOverlayCronAndName(e);
88107
+ if (opts.name && parsed.name && parsed.name === opts.name) {
88108
+ return {
88109
+ ok: false,
88110
+ code: "E_CRON_DUPLICATE",
88111
+ message: `an overlay cron named "${opts.name}" already exists ` + `(${e.slug}.yaml) \u2014 remove it first or choose a different name`,
88112
+ exit: 9,
88113
+ meta: { conflicting_slug: e.slug, conflicting_name: parsed.name }
88114
+ };
88115
+ }
88116
+ if (wantExpr && parsed.cron && normalizeCronExpr(parsed.cron) === wantExpr) {
88117
+ return {
88118
+ ok: false,
88119
+ code: "E_CRON_DUPLICATE",
88120
+ message: `an overlay cron with expression "${wantExpr}" already exists ` + `(${e.slug}.yaml${parsed.name ? `, name "${parsed.name}"` : ""}) \u2014 ` + `adding another would double-fire. Remove the existing one first.`,
88121
+ exit: 9,
88122
+ meta: { conflicting_slug: e.slug, conflicting_cron: wantExpr }
88123
+ };
88124
+ }
88125
+ }
87906
88126
  const hash2 = cronUnitHash(opts.cronExpr, opts.prompt);
87907
88127
  const slug = `cron-${hash2}`;
87908
88128
  let priorContent = null;
@@ -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.13";
22590
+ var VERSION = "0.16.15";
22591
22591
 
22592
22592
  // src/cli/resolve-version.ts
22593
22593
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.16.13",
3
+ "version": "0.16.15",
4
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.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46272,7 +46272,7 @@ async function handleModelMenuCallback(data, deps) {
46272
46272
  `).map((l) => l.trim()).find((l) => /set model|switched/i.test(l)) ?? `Switched to ${friendlyName} (session)`;
46273
46273
  return {
46274
46274
  answer: confirmation,
46275
- reply: await menuWithBanner(deps, `\u2705 ${deps.escapeHtml(confirmation)}`),
46275
+ reply: await menuWithBannerStatic(deps, `\u2705 ${deps.escapeHtml(confirmation)}`),
46276
46276
  selectedModel: srName
46277
46277
  };
46278
46278
  }
@@ -46334,6 +46334,14 @@ async function menuWithBanner(deps, banner) {
46334
46334
  ...fresh.keyboard ? { keyboard: fresh.keyboard } : {}
46335
46335
  };
46336
46336
  }
46337
+ async function menuWithBannerStatic(deps, banner) {
46338
+ const v1 = await handleModelCommand({ kind: "show" }, deps);
46339
+ return {
46340
+ text: [banner, "", v1.text].join(`
46341
+ `),
46342
+ html: true
46343
+ };
46344
+ }
46337
46345
 
46338
46346
  // ../src/agents/model-picker.ts
46339
46347
  var HEADER_RE = /Select model/;
@@ -54753,6 +54761,7 @@ var ARG_SUMMARY_LINE_MAX = 180;
54753
54761
  var MCP_TOOL_DESCRIPTIONS = {
54754
54762
  "mcp__agent-config__config_get": "Read its own merged config",
54755
54763
  "mcp__agent-config__cron_list": "List its own scheduled tasks",
54764
+ "mcp__agent-config__cron_doctor": "Health-check its own cron schedule",
54756
54765
  "mcp__agent-config__skill_list": "List its own installed skills",
54757
54766
  "mcp__agent-config__audit_tail": "Read its own recent tool-call audit log",
54758
54767
  "mcp__agent-config__peers_list": "List the other agents on this instance",
@@ -56064,11 +56073,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
56064
56073
  }
56065
56074
 
56066
56075
  // ../src/build-info.ts
56067
- var VERSION = "0.16.13";
56068
- var COMMIT_SHA = "d4f3ac38";
56069
- var COMMIT_DATE = "2026-06-28T21:45:50+10:00";
56070
- var LATEST_PR = null;
56071
- var COMMITS_AHEAD_OF_TAG = 2;
56076
+ var VERSION = "0.16.15";
56077
+ var COMMIT_SHA = "a9c59169";
56078
+ var COMMIT_DATE = "2026-06-28T22:48:22Z";
56079
+ var LATEST_PR = 2643;
56080
+ var COMMITS_AHEAD_OF_TAG = 0;
56072
56081
 
56073
56082
  // gateway/boot-version.ts
56074
56083
  function formatRelativeAgo(iso) {
@@ -68285,13 +68294,19 @@ bot.on("callback_query:data", async (ctx) => {
68285
68294
  return;
68286
68295
  }
68287
68296
  await ctx.answerCallbackQuery({ text: "Switching\u2026" }).catch(() => {});
68297
+ let didInterimSrEdit = false;
68298
+ if (data.startsWith(MODEL_CALLBACK_SR)) {
68299
+ const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)));
68300
+ await ctx.editMessageText(`\u23F3 Switching session to <b>${srLabel}</b>\u2026`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
68301
+ didInterimSrEdit = true;
68302
+ }
68288
68303
  try {
68289
68304
  const prevSessionModel = activeSessionModelOverride;
68290
68305
  const outcome = await handleModelMenuCallback(data, modelDeps);
68291
68306
  if (outcome.selectedModel) {
68292
68307
  activeSessionModelOverride = outcome.selectedModel;
68293
68308
  }
68294
- if (outcome.toastOnly)
68309
+ if (outcome.toastOnly && !didInterimSrEdit)
68295
68310
  return;
68296
68311
  if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
68297
68312
  const agentName3 = getMyAgentName();
@@ -278,6 +278,8 @@ import {
278
278
  isSrToClaudeTransition,
279
279
  MODEL_CALLBACK_PREFIX,
280
280
  MODEL_CALLBACK_HEADER,
281
+ MODEL_CALLBACK_SR,
282
+ srFriendlyLabel,
281
283
  type ModelMenuDeps,
282
284
  type ModelCommandDeps,
283
285
  type ModelMenuReply,
@@ -20849,6 +20851,23 @@ bot.on('callback_query:data', async ctx => {
20849
20851
  return
20850
20852
  }
20851
20853
  await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
20854
+ // sr-* inject waits for claude to respond (can take 10-30s). Edit the
20855
+ // menu immediately to show a "working on it" state so the operator isn't
20856
+ // left looking at a stale menu with no feedback. The final edit (✅/❌)
20857
+ // replaces this once the inject returns.
20858
+ // NOTE: this await yields the event loop, so a new inbound turn could
20859
+ // start between here and handleModelMenuCallback's inner isBusy() check.
20860
+ // We track whether we applied the interim edit so we can skip the
20861
+ // toastOnly short-circuit if we did — a toastOnly return after the interim
20862
+ // edit would leave the menu stuck button-less.
20863
+ let didInterimSrEdit = false
20864
+ if (data.startsWith(MODEL_CALLBACK_SR)) {
20865
+ const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)))
20866
+ await ctx
20867
+ .editMessageText(`⏳ Switching session to <b>${srLabel}</b>…`, { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } })
20868
+ .catch(() => {})
20869
+ didInterimSrEdit = true
20870
+ }
20852
20871
  try {
20853
20872
  const prevSessionModel = activeSessionModelOverride
20854
20873
  const outcome = await handleModelMenuCallback(data, modelDeps)
@@ -20858,9 +20877,11 @@ bot.on('callback_query:data', async ctx => {
20858
20877
  if (outcome.selectedModel) {
20859
20878
  activeSessionModelOverride = outcome.selectedModel
20860
20879
  }
20861
- // toastOnly: a no-op outcome that should not disturb the menu (defence
20862
- // in depth the isBusy() short-circuit above is the live path).
20863
- if (outcome.toastOnly) return
20880
+ // toastOnly: leave the menu untouched but only if we haven't already
20881
+ // cleared its buttons with the interim sr-* edit. If we have, fall
20882
+ // through to the final edit so the message is recovered (busyReply or
20883
+ // the full menu) rather than left permanently button-less.
20884
+ if (outcome.toastOnly && !didInterimSrEdit) return
20864
20885
 
20865
20886
  // sr-* → Claude transition via the model menu: trigger a graceful restart.
20866
20887
  // Switching FROM an sr-* (LiteLLM/OpenRouter) model BACK to a Claude model
@@ -345,7 +345,7 @@ export function expandSrAlias(arg: string): string {
345
345
  return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
346
346
  }
347
347
 
348
- function srFriendlyLabel(srName: string): string {
348
+ export function srFriendlyLabel(srName: string): string {
349
349
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
350
350
  }
351
351
 
@@ -560,7 +560,11 @@ export async function handleModelMenuCallback(
560
560
  .find((l) => /set model|switched/i.test(l)) ?? `Switched to ${friendlyName} (session)`
561
561
  return {
562
562
  answer: confirmation,
563
- reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(confirmation)}`),
563
+ // Use the static (no-discover) path — after a text-inject the picker
564
+ // is in flux and discover() reliably fails, producing a spurious
565
+ // "(picker unavailable)" line that reads as an error when the switch
566
+ // actually succeeded.
567
+ reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
564
568
  selectedModel: srName,
565
569
  }
566
570
  }
@@ -683,3 +687,20 @@ async function menuWithBanner(
683
687
  ...(fresh.keyboard ? { keyboard: fresh.keyboard } : {}),
684
688
  }
685
689
  }
690
+
691
+ // Static variant — skips discover() entirely and uses the v1 text path.
692
+ // Use after a text-inject where the picker state is inherently uncertain:
693
+ // discover() reliably fails immediately post-inject, producing a spurious
694
+ // "(picker unavailable)" warning that reads as an error.
695
+ async function menuWithBannerStatic(
696
+ deps: ModelMenuDeps & ModelCommandDeps,
697
+ banner: string,
698
+ ): Promise<ModelMenuReply> {
699
+ const v1 = await handleModelCommand({ kind: 'show' }, deps)
700
+ return {
701
+ text: [banner, '', v1.text].join('\n'),
702
+ html: true,
703
+ // No keyboard — picker state is unknown after a text-inject; operator
704
+ // can tap /model to get a fresh interactive menu.
705
+ }
706
+ }
@@ -46,6 +46,7 @@ const MCP_TOOL_DESCRIPTIONS: Record<string, string> = {
46
46
  // agent-config — every agent's self-service surface (#1163, #1215)
47
47
  "mcp__agent-config__config_get": "Read its own merged config",
48
48
  "mcp__agent-config__cron_list": "List its own scheduled tasks",
49
+ "mcp__agent-config__cron_doctor": "Health-check its own cron schedule",
49
50
  "mcp__agent-config__skill_list": "List its own installed skills",
50
51
  "mcp__agent-config__audit_tail": "Read its own recent tool-call audit log",
51
52
  "mcp__agent-config__peers_list": "List the other agents on this instance",
@@ -713,7 +713,13 @@ describe("handleModelMenuCallback — sr-* selection", () => {
713
713
  expect(calls.select).toHaveLength(0);
714
714
  expect(out.answer).toContain("Set model to sonnet");
715
715
  expect(out.selectedModel).toBe("sr-gemini-2.5-pro");
716
- expect(out.reply.keyboard).toBeDefined();
716
+ // No keyboard on success: static reply path skips discover() to avoid the
717
+ // spurious "(picker unavailable)" line that discover() reliably produces
718
+ // immediately after an inject. Operator taps /model for a fresh menu.
719
+ expect(out.reply.keyboard).toBeUndefined();
720
+ // Banner present and text doesn't contain picker-unavailable noise
721
+ expect(out.reply.text).toContain('✅');
722
+ expect(out.reply.text).not.toContain('picker unavailable');
717
723
  });
718
724
 
719
725
  it("sr-* tap while busy returns toast-only with no inject", async () => {
@@ -140,11 +140,11 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
140
140
  const sc = await spinUp({ agent: AGENT });
141
141
  try {
142
142
  await sc.sendDM("/model");
143
- // 60s — test 2 runs after test 1's restore restart, which takes ~15s.
144
- // If the restart is still finishing when /model lands, it may be queued.
143
+ // 90s — test 2 runs after test 1's restore restart. The model-switch
144
+ // restart can take 30–60s to fully boot; 90s gives comfortable margin.
145
145
  const menu = await sc.expectMessage(/Default \(new sessions\):/i, {
146
146
  from: "bot",
147
- timeout: 60_000,
147
+ timeout: 90_000,
148
148
  });
149
149
  const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
150
150
  const flat = (kb ?? []).flat();