switchroom 0.16.14 → 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.14";
51678
- var COMMIT_SHA = "6daa5e37";
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.14";
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.14",
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,10 +56073,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
56064
56073
  }
56065
56074
 
56066
56075
  // ../src/build-info.ts
56067
- var VERSION = "0.16.14";
56068
- var COMMIT_SHA = "6daa5e37";
56069
- var COMMIT_DATE = "2026-06-28T19:14:51Z";
56070
- var LATEST_PR = 2639;
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;
56071
56080
  var COMMITS_AHEAD_OF_TAG = 0;
56072
56081
 
56073
56082
  // gateway/boot-version.ts
@@ -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 () => {