switchroom 0.15.16 → 0.15.18

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.
@@ -10978,16 +10978,8 @@ var HttpDiffPollSchema = exports_external.object({
10978
10978
  diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
10979
10979
  state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
10980
10980
  });
10981
- var TelegramReactionsPollSchema = exports_external.object({
10982
- type: exports_external.literal("telegram-reactions"),
10983
- chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
10984
- emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68‍\uD83D\uDCBB)."),
10985
- lookback: exports_external.number().int().positive().max(200).default(40),
10986
- state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
10987
- });
10988
10981
  var PollSpecSchema = exports_external.discriminatedUnion("type", [
10989
- HttpDiffPollSchema,
10990
- TelegramReactionsPollSchema
10982
+ HttpDiffPollSchema
10991
10983
  ]);
10992
10984
  var TelegramMessageActionSchema = exports_external.object({
10993
10985
  type: exports_external.literal("telegram-message"),
@@ -11025,7 +11017,7 @@ var ScheduleEntrySchema = exports_external.object({
11025
11017
  ctx.addIssue({
11026
11018
  code: exports_external.ZodIssueCode.custom,
11027
11019
  path: ["poll"],
11028
- message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11020
+ message: "kind: poll requires a `poll` spec (http-diff)."
11029
11021
  });
11030
11022
  }
11031
11023
  if (kind !== "poll" && entry.poll) {
@@ -12432,7 +12424,7 @@ function estimateCronGapMin(expr) {
12432
12424
  var DEFAULT_FREQUENT_GAP_MIN = 60;
12433
12425
  function resolveCronAutoTier(env = process.env) {
12434
12426
  const v = (env.SWITCHROOM_CRON_AUTO_TIER ?? "").toLowerCase();
12435
- return v === "1" || v === "true" || v === "on";
12427
+ return !(v === "0" || v === "false" || v === "off");
12436
12428
  }
12437
12429
  function recommendCronTier(input, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
12438
12430
  if (input.kind === "action") {
@@ -13315,7 +13307,7 @@ function buildCheapCronHooks(config, env, deps = {}) {
13315
13307
  now
13316
13308
  });
13317
13309
  }
13318
- return { hit: false, baseline: false, error: `poll type '${spec.type}' not yet wired (staged)` };
13310
+ return { hit: false, baseline: false, error: "poll type not yet wired" };
13319
13311
  };
13320
13312
  const runAction2 = async (spec, ctx) => {
13321
13313
  return runAction(spec, {
@@ -10978,16 +10978,8 @@ var HttpDiffPollSchema = exports_external.object({
10978
10978
  diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
10979
10979
  state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
10980
10980
  });
10981
- var TelegramReactionsPollSchema = exports_external.object({
10982
- type: exports_external.literal("telegram-reactions"),
10983
- chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
10984
- emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68‍\uD83D\uDCBB)."),
10985
- lookback: exports_external.number().int().positive().max(200).default(40),
10986
- state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
10987
- });
10988
10981
  var PollSpecSchema = exports_external.discriminatedUnion("type", [
10989
- HttpDiffPollSchema,
10990
- TelegramReactionsPollSchema
10982
+ HttpDiffPollSchema
10991
10983
  ]);
10992
10984
  var TelegramMessageActionSchema = exports_external.object({
10993
10985
  type: exports_external.literal("telegram-message"),
@@ -11025,7 +11017,7 @@ var ScheduleEntrySchema = exports_external.object({
11025
11017
  ctx.addIssue({
11026
11018
  code: exports_external.ZodIssueCode.custom,
11027
11019
  path: ["poll"],
11028
- message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11020
+ message: "kind: poll requires a `poll` spec (http-diff)."
11029
11021
  });
11030
11022
  }
11031
11023
  if (kind !== "poll" && entry.poll) {
@@ -11726,16 +11726,8 @@ var HttpDiffPollSchema = exports_external.object({
11726
11726
  diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
11727
11727
  state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
11728
11728
  });
11729
- var TelegramReactionsPollSchema = exports_external.object({
11730
- type: exports_external.literal("telegram-reactions"),
11731
- chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
11732
- emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68\u200d\uD83D\uDCBB)."),
11733
- lookback: exports_external.number().int().positive().max(200).default(40),
11734
- state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
11735
- });
11736
11729
  var PollSpecSchema = exports_external.discriminatedUnion("type", [
11737
- HttpDiffPollSchema,
11738
- TelegramReactionsPollSchema
11730
+ HttpDiffPollSchema
11739
11731
  ]);
11740
11732
  var TelegramMessageActionSchema = exports_external.object({
11741
11733
  type: exports_external.literal("telegram-message"),
@@ -11773,7 +11765,7 @@ var ScheduleEntrySchema = exports_external.object({
11773
11765
  ctx.addIssue({
11774
11766
  code: exports_external.ZodIssueCode.custom,
11775
11767
  path: ["poll"],
11776
- message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11768
+ message: "kind: poll requires a `poll` spec (http-diff)."
11777
11769
  });
11778
11770
  }
11779
11771
  if (kind !== "poll" && entry.poll) {
@@ -13520,7 +13520,7 @@ var init_zod = __esm(() => {
13520
13520
  });
13521
13521
 
13522
13522
  // src/config/schema.ts
13523
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
13523
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
13524
13524
  var init_schema = __esm(() => {
13525
13525
  init_zod();
13526
13526
  CodeRepoEntrySchema = exports_external.object({
@@ -13542,16 +13542,8 @@ var init_schema = __esm(() => {
13542
13542
  diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
13543
13543
  state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
13544
13544
  });
13545
- TelegramReactionsPollSchema = exports_external.object({
13546
- type: exports_external.literal("telegram-reactions"),
13547
- chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
13548
- emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68\u200d\uD83D\uDCBB)."),
13549
- lookback: exports_external.number().int().positive().max(200).default(40),
13550
- state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
13551
- });
13552
13545
  PollSpecSchema = exports_external.discriminatedUnion("type", [
13553
- HttpDiffPollSchema,
13554
- TelegramReactionsPollSchema
13546
+ HttpDiffPollSchema
13555
13547
  ]);
13556
13548
  TelegramMessageActionSchema = exports_external.object({
13557
13549
  type: exports_external.literal("telegram-message"),
@@ -13589,7 +13581,7 @@ var init_schema = __esm(() => {
13589
13581
  ctx.addIssue({
13590
13582
  code: exports_external.ZodIssueCode.custom,
13591
13583
  path: ["poll"],
13592
- message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
13584
+ message: "kind: poll requires a `poll` spec (http-diff)."
13593
13585
  });
13594
13586
  }
13595
13587
  if (kind !== "poll" && entry.poll) {
@@ -15551,7 +15543,7 @@ function estimateCronGapMin(expr) {
15551
15543
  // src/scheduler/tier-selector.ts
15552
15544
  function resolveCronAutoTier(env2 = process.env) {
15553
15545
  const v = (env2.SWITCHROOM_CRON_AUTO_TIER ?? "").toLowerCase();
15554
- return v === "1" || v === "true" || v === "on";
15546
+ return !(v === "0" || v === "false" || v === "off");
15555
15547
  }
15556
15548
  function recommendCronTier(input, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
15557
15549
  if (input.kind === "action") {
@@ -49579,9 +49571,9 @@ __export(exports_server, {
49579
49571
  dispatchTool: () => dispatchTool,
49580
49572
  TOOLS: () => TOOLS
49581
49573
  });
49582
- import { spawnSync as spawnSync13 } from "node:child_process";
49574
+ import { spawnSync as spawnSync14 } from "node:child_process";
49583
49575
  function execCli(args, stdin) {
49584
- const r = spawnSync13(CLI_BIN, args, {
49576
+ const r = spawnSync14(CLI_BIN, args, {
49585
49577
  encoding: "utf-8",
49586
49578
  env: process.env,
49587
49579
  timeout: 15000,
@@ -50467,8 +50459,8 @@ var {
50467
50459
  } = import__.default;
50468
50460
 
50469
50461
  // src/build-info.ts
50470
- var VERSION = "0.15.16";
50471
- var COMMIT_SHA = "a96f10cc";
50462
+ var VERSION = "0.15.18";
50463
+ var COMMIT_SHA = "d7c044b9";
50472
50464
 
50473
50465
  // src/cli/agent.ts
50474
50466
  init_source();
@@ -76817,6 +76809,194 @@ function registerUpdateCommand(program3) {
76817
76809
  });
76818
76810
  }
76819
76811
 
76812
+ // src/cli/rollout.ts
76813
+ init_helpers();
76814
+ import { spawnSync as spawnSync10 } from "node:child_process";
76815
+ function normalizeVersion(v) {
76816
+ return v.trim().replace(/^v/, "");
76817
+ }
76818
+ function isVersionAssertable(target) {
76819
+ return /^v?\d+\.\d+\.\d+$/.test(target.trim());
76820
+ }
76821
+ function orderAgentsCanaryFirst(agents) {
76822
+ const canary = agents.filter((a) => a === "test-harness");
76823
+ const rest = agents.filter((a) => a !== "test-harness");
76824
+ return [...canary, ...rest];
76825
+ }
76826
+ function planRollout(agents, opts = {}) {
76827
+ const steps = [{ kind: "apply" }];
76828
+ for (const agent of orderAgentsCanaryFirst(agents)) {
76829
+ steps.push({ kind: "restart-agent", agent });
76830
+ }
76831
+ if (!opts.skipWeb) {
76832
+ steps.push({ kind: "refresh-web" });
76833
+ steps.push({ kind: "refresh-hostd" });
76834
+ }
76835
+ steps.push({ kind: "sweep" });
76836
+ return steps;
76837
+ }
76838
+ function formatRolloutPlan(steps, target) {
76839
+ const lines = [`Rollout plan \u2192 ${target}:`];
76840
+ let n = 0;
76841
+ for (const s of steps) {
76842
+ n += 1;
76843
+ switch (s.kind) {
76844
+ case "apply":
76845
+ lines.push(` ${n}. apply \u2014 regenerate compose with ${target} image refs`);
76846
+ break;
76847
+ case "restart-agent":
76848
+ lines.push(` ${n}. restart ${s.agent} (--wait --force) + assert --version=${target}`);
76849
+ break;
76850
+ case "refresh-web":
76851
+ lines.push(` ${n}. webd install --tag ${target} (separate compose project)`);
76852
+ break;
76853
+ case "refresh-hostd":
76854
+ lines.push(` ${n}. hostd install --tag ${target} (separate compose project)`);
76855
+ break;
76856
+ case "sweep":
76857
+ lines.push(` ${n}. sweep \u2014 print per-agent version table`);
76858
+ break;
76859
+ }
76860
+ }
76861
+ lines.push("");
76862
+ lines.push("Stops on the first agent that doesn't come back on the target version.");
76863
+ return lines.join(`
76864
+ `);
76865
+ }
76866
+ function executeRollout(steps, target, deps, pinOnApply) {
76867
+ const targetNorm = normalizeVersion(target);
76868
+ const rolled = [];
76869
+ const warnings = [];
76870
+ for (const step of steps) {
76871
+ switch (step.kind) {
76872
+ case "apply": {
76873
+ deps.log(`\u2192 apply \u2014 regenerating compose for ${target}`);
76874
+ const args = pinOnApply ? ["apply", "--pin", target] : ["apply"];
76875
+ const r = deps.run(args);
76876
+ if (r.status !== 0) {
76877
+ return { ok: false, rolled, failedStep: "apply", warnings };
76878
+ }
76879
+ break;
76880
+ }
76881
+ case "restart-agent": {
76882
+ deps.log(`\u2192 restart ${step.agent} (--wait --force)`);
76883
+ deps.run(["agent", "restart", step.agent, "--wait", "--force"]);
76884
+ const got = deps.probeVersion(step.agent);
76885
+ if (got === null || normalizeVersion(got) !== targetNorm) {
76886
+ deps.log(` \u2717 ${step.agent} \u2192 ${got ?? "<unreachable>"} (expected ${target}) \u2014 STOPPING`);
76887
+ return {
76888
+ ok: false,
76889
+ rolled,
76890
+ failedStep: "restart-agent",
76891
+ failedAgent: step.agent,
76892
+ got,
76893
+ warnings
76894
+ };
76895
+ }
76896
+ rolled.push(step.agent);
76897
+ deps.log(` \u2713 ${step.agent} \u2192 ${got}`);
76898
+ break;
76899
+ }
76900
+ case "refresh-web": {
76901
+ deps.log(`\u2192 webd install --tag ${target}`);
76902
+ const r = deps.run(["webd", "install", "--tag", target]);
76903
+ if (r.status !== 0)
76904
+ warnings.push(`web refresh failed (non-fatal); agents already rolled`);
76905
+ break;
76906
+ }
76907
+ case "refresh-hostd": {
76908
+ deps.log(`\u2192 hostd install --tag ${target}`);
76909
+ const r = deps.run(["hostd", "install", "--tag", target]);
76910
+ if (r.status !== 0)
76911
+ warnings.push(`hostd refresh failed (non-fatal); agents already rolled`);
76912
+ break;
76913
+ }
76914
+ case "sweep": {
76915
+ deps.log(`\u2192 sweep`);
76916
+ for (const a of rolled) {
76917
+ const v = deps.probeVersion(a);
76918
+ deps.log(` ${a}: ${v ?? "<unreachable>"}`);
76919
+ }
76920
+ break;
76921
+ }
76922
+ }
76923
+ }
76924
+ return { ok: true, rolled, warnings };
76925
+ }
76926
+ function registerRolloutCommand(program3) {
76927
+ program3.command("rollout").description("Deploy a pinned version to the fleet, safely (staggered restart + " + "per-agent version assert + web/hostd refresh). Run with sudo.").option("--pin <version>", "Version to roll (e.g. v0.15.18). Defaults to release.pin from config.").option("--agents <list>", "Comma-separated subset of agents to roll (default: all configured).").option("--skip-web", "Skip the web + hostd refresh step.").option("--dry-run", "Print the plan and exit without changing anything.").action(async (opts) => {
76928
+ const config = getConfig(program3);
76929
+ const target = opts.pin ?? config.release?.pin;
76930
+ if (!target) {
76931
+ process.stderr.write("rollout needs a pinned version: pass --pin vX.Y.Z, or set " + "`release.pin` in switchroom.yaml. (A floating channel has no " + `fixed version to assert against.)
76932
+ `);
76933
+ process.exitCode = 2;
76934
+ return;
76935
+ }
76936
+ if (!isVersionAssertable(target)) {
76937
+ process.stderr.write(`rollout asserts the in-container \`switchroom --version\` (always a ` + `semver), so the target must be a tagged release like v0.15.18 \u2014 ` + `\`${target}\` isn't version-assertable. Pass --pin vX.Y.Z.
76938
+ `);
76939
+ process.exitCode = 2;
76940
+ return;
76941
+ }
76942
+ const allAgents = Object.keys(config.agents ?? {});
76943
+ const requested = opts.agents ? opts.agents.split(",").map((s) => s.trim()).filter(Boolean) : allAgents;
76944
+ const unknown = requested.filter((a) => !allAgents.includes(a));
76945
+ if (unknown.length > 0) {
76946
+ process.stderr.write(`unknown agent(s): ${unknown.join(", ")}
76947
+ `);
76948
+ process.exitCode = 2;
76949
+ return;
76950
+ }
76951
+ if (requested.length === 0) {
76952
+ process.stderr.write(`no agents to roll.
76953
+ `);
76954
+ process.exitCode = 2;
76955
+ return;
76956
+ }
76957
+ const steps = planRollout(requested, { skipWeb: opts.skipWeb });
76958
+ if (opts.dryRun) {
76959
+ process.stdout.write(formatRolloutPlan(steps, target) + `
76960
+ `);
76961
+ return;
76962
+ }
76963
+ const scriptPath = process.argv[1] ?? "switchroom";
76964
+ const deps = {
76965
+ run: (args) => {
76966
+ const r = spawnSync10(process.execPath, [scriptPath, ...args], { stdio: "inherit" });
76967
+ return { status: r.status ?? 1 };
76968
+ },
76969
+ probeVersion: (agent) => {
76970
+ const r = spawnSync10("docker", ["exec", `switchroom-${agent}`, "sh", "-lc", "switchroom --version"], { encoding: "utf8" });
76971
+ if (r.status !== 0)
76972
+ return null;
76973
+ return (r.stdout ?? "").trim().split(`
76974
+ `).pop()?.trim() ?? null;
76975
+ },
76976
+ log: (line) => process.stdout.write(line + `
76977
+ `)
76978
+ };
76979
+ process.stdout.write(`Rolling ${requested.length} agent(s) to ${target}\u2026
76980
+ `);
76981
+ const result = executeRollout(steps, target, deps, opts.pin != null);
76982
+ for (const w of result.warnings)
76983
+ process.stderr.write(`\u26a0\ufe0f ${w}
76984
+ `);
76985
+ if (!result.ok) {
76986
+ process.stderr.write(`
76987
+ \u2717 Rollout STOPPED at ${result.failedStep}` + (result.failedAgent ? ` (${result.failedAgent} \u2192 ${result.got ?? "unreachable"})` : "") + `.
76988
+ Rolled before stop: ${result.rolled.join(", ") || "none"}.
76989
+ ` + ` Fix the cause, then re-run \`switchroom rollout --pin ${target}\` ` + `(idempotent \u2014 already-current agents bounce back to the same version).
76990
+ `);
76991
+ process.exitCode = 1;
76992
+ return;
76993
+ }
76994
+ process.stdout.write(`
76995
+ \u2705 Rollout complete \u2014 ${result.rolled.length} agent(s) on ${target}` + `${result.warnings.length ? ` (with ${result.warnings.length} warning(s) above)` : ""}.
76996
+ `);
76997
+ });
76998
+ }
76999
+
76820
77000
  // src/cli/restart.ts
76821
77001
  init_source();
76822
77002
  init_helpers();
@@ -78272,7 +78452,7 @@ init_helpers();
78272
78452
  init_loader();
78273
78453
  import { existsSync as existsSync64 } from "node:fs";
78274
78454
  import { resolve as resolve39, sep as sep3 } from "node:path";
78275
- import { spawnSync as spawnSync10 } from "node:child_process";
78455
+ import { spawnSync as spawnSync11 } from "node:child_process";
78276
78456
 
78277
78457
  // src/agents/workspace.ts
78278
78458
  import { readFile as readFile2, stat } from "node:fs/promises";
@@ -78985,7 +79165,7 @@ function registerWorkspaceCommand(program3) {
78985
79165
  process.exit(1);
78986
79166
  }
78987
79167
  const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? "vi";
78988
- const child = spawnSync10(editor, [target], { stdio: "inherit" });
79168
+ const child = spawnSync11(editor, [target], { stdio: "inherit" });
78989
79169
  if (child.status !== 0 && child.status !== null) {
78990
79170
  process.exit(child.status);
78991
79171
  }
@@ -79052,7 +79232,7 @@ function registerWorkspaceCommand(program3) {
79052
79232
  `);
79053
79233
  return;
79054
79234
  }
79055
- const statusResult = spawnSync10("git", ["status", "--short"], {
79235
+ const statusResult = spawnSync11("git", ["status", "--short"], {
79056
79236
  cwd: dir,
79057
79237
  encoding: "utf-8"
79058
79238
  });
@@ -79067,7 +79247,7 @@ function registerWorkspaceCommand(program3) {
79067
79247
  return;
79068
79248
  }
79069
79249
  const message = opts.message || `checkpoint: ${new Date().toISOString()}`;
79070
- const addResult = spawnSync10("git", ["add", "-A"], {
79250
+ const addResult = spawnSync11("git", ["add", "-A"], {
79071
79251
  cwd: dir,
79072
79252
  encoding: "utf-8"
79073
79253
  });
@@ -79076,7 +79256,7 @@ function registerWorkspaceCommand(program3) {
79076
79256
  `);
79077
79257
  process.exit(1);
79078
79258
  }
79079
- const commitResult = spawnSync10("git", ["commit", "-m", message], {
79259
+ const commitResult = spawnSync11("git", ["commit", "-m", message], {
79080
79260
  cwd: dir,
79081
79261
  encoding: "utf-8"
79082
79262
  });
@@ -79085,7 +79265,7 @@ function registerWorkspaceCommand(program3) {
79085
79265
  `);
79086
79266
  process.exit(1);
79087
79267
  }
79088
- const shaResult = spawnSync10("git", ["rev-parse", "--short", "HEAD"], {
79268
+ const shaResult = spawnSync11("git", ["rev-parse", "--short", "HEAD"], {
79089
79269
  cwd: dir,
79090
79270
  encoding: "utf-8"
79091
79271
  });
@@ -79106,7 +79286,7 @@ function registerWorkspaceCommand(program3) {
79106
79286
  `);
79107
79287
  return;
79108
79288
  }
79109
- const child = spawnSync10("git", ["status", "--short"], {
79289
+ const child = spawnSync11("git", ["status", "--short"], {
79110
79290
  cwd: dir,
79111
79291
  stdio: "inherit"
79112
79292
  });
@@ -84101,7 +84281,7 @@ import {
84101
84281
  } from "node:fs";
84102
84282
  import { tmpdir as tmpdir5, homedir as homedir45 } from "node:os";
84103
84283
  import { dirname as dirname23, join as join79, relative as relative2, resolve as resolve47 } from "node:path";
84104
- import { spawnSync as spawnSync11 } from "node:child_process";
84284
+ import { spawnSync as spawnSync12 } from "node:child_process";
84105
84285
 
84106
84286
  // src/cli/skill-common.ts
84107
84287
  var import_yaml22 = __toESM(require_dist(), 1);
@@ -84354,7 +84534,7 @@ function loadFromDir(dir) {
84354
84534
  function loadFromTarball(tarPath) {
84355
84535
  const isGz = tarPath.endsWith(".gz") || tarPath.endsWith(".tgz");
84356
84536
  const listFlags = isGz ? ["-tzf"] : ["-tf"];
84357
- const list2 = spawnSync11("tar", [...listFlags, tarPath], {
84537
+ const list2 = spawnSync12("tar", [...listFlags, tarPath], {
84358
84538
  encoding: "utf-8",
84359
84539
  stdio: ["ignore", "pipe", "pipe"]
84360
84540
  });
@@ -84371,7 +84551,7 @@ function loadFromTarball(tarPath) {
84371
84551
  const staging = mkdtempSync5(join79(tmpdir5(), "skill-apply-extract-"));
84372
84552
  try {
84373
84553
  const flags = isGz ? ["-xzf"] : ["-xf"];
84374
- const r = spawnSync11("tar", [
84554
+ const r = spawnSync12("tar", [
84375
84555
  ...flags,
84376
84556
  tarPath,
84377
84557
  "-C",
@@ -84446,7 +84626,7 @@ function validatePayload(name, files) {
84446
84626
  if (errors2.length === 0) {
84447
84627
  for (const [path8, content] of Object.entries(files)) {
84448
84628
  if (SH_SCRIPT_RE2.test(path8)) {
84449
- const r = spawnSync11("bash", ["-n"], {
84629
+ const r = spawnSync12("bash", ["-n"], {
84450
84630
  input: content,
84451
84631
  encoding: "utf-8"
84452
84632
  });
@@ -84458,7 +84638,7 @@ function validatePayload(name, files) {
84458
84638
  const tmpPy = join79(tmp, "check.py");
84459
84639
  try {
84460
84640
  writeFileSync39(tmpPy, content);
84461
- const r = spawnSync11("python3", ["-m", "py_compile", tmpPy], {
84641
+ const r = spawnSync12("python3", ["-m", "py_compile", tmpPy], {
84462
84642
  encoding: "utf-8"
84463
84643
  });
84464
84644
  if (r.status !== 0) {
@@ -84621,7 +84801,7 @@ function registerSkillCommand(program3) {
84621
84801
  \u2713 Wrote ${name} to ${currentDir}`));
84622
84802
  const applyBin = process.argv[1] ?? "switchroom";
84623
84803
  console.log(source_default.gray(`Running \`switchroom apply --non-interactive\`...`));
84624
- const r = spawnSync11(process.argv0, [applyBin, "apply", "--non-interactive"], { stdio: "inherit" });
84804
+ const r = spawnSync12(process.argv0, [applyBin, "apply", "--non-interactive"], { stdio: "inherit" });
84625
84805
  if (r.status !== 0) {
84626
84806
  console.error(source_default.yellow(`(warning: \`switchroom apply\` exited ${r.status} \u2014 skill is ` + `in the pool but symlinks may not be refreshed. Re-run manually.)`));
84627
84807
  }
@@ -84653,7 +84833,7 @@ import {
84653
84833
  } from "node:fs";
84654
84834
  import { dirname as dirname24, join as join80, relative as relative3, resolve as resolve48 } from "node:path";
84655
84835
  import { homedir as homedir46, tmpdir as tmpdir6 } from "node:os";
84656
- import { spawnSync as spawnSync12 } from "node:child_process";
84836
+ import { spawnSync as spawnSync13 } from "node:child_process";
84657
84837
  init_helpers();
84658
84838
  init_source();
84659
84839
  var PERSONAL_PREFIX = "personal-";
@@ -84842,7 +85022,7 @@ function behavioralValidate(files) {
84842
85022
  const errors2 = [];
84843
85023
  for (const [path8, content] of Object.entries(files)) {
84844
85024
  if (SH_SCRIPT_RE.test(path8)) {
84845
- const r = spawnSync12("bash", ["-n"], { input: content, encoding: "utf-8" });
85025
+ const r = spawnSync13("bash", ["-n"], { input: content, encoding: "utf-8" });
84846
85026
  if (r.status !== 0) {
84847
85027
  errors2.push(`${path8} fails \`bash -n\`: ${(r.stderr ?? "").trim()}`);
84848
85028
  }
@@ -84851,7 +85031,7 @@ function behavioralValidate(files) {
84851
85031
  const tmpPy = join80(tmp, "check.py");
84852
85032
  try {
84853
85033
  writeFileSync40(tmpPy, content);
84854
- const r = spawnSync12("python3", ["-m", "py_compile", tmpPy], {
85034
+ const r = spawnSync13("python3", ["-m", "py_compile", tmpPy], {
84855
85035
  encoding: "utf-8"
84856
85036
  });
84857
85037
  if (r.status !== 0) {
@@ -85544,7 +85724,7 @@ init_helpers();
85544
85724
  import { existsSync as existsSync84, mkdirSync as mkdirSync47, readdirSync as readdirSync33, readFileSync as readFileSync71, writeFileSync as writeFileSync41, statSync as statSync34, copyFileSync as copyFileSync12 } from "node:fs";
85545
85725
  import { homedir as homedir48 } from "node:os";
85546
85726
  import { join as join82 } from "node:path";
85547
- import { spawnSync as spawnSync14 } from "node:child_process";
85727
+ import { spawnSync as spawnSync15 } from "node:child_process";
85548
85728
  init_audit_reader();
85549
85729
  function resolveHostdImageTag(explicitTag, release) {
85550
85730
  if (explicitTag)
@@ -85676,7 +85856,7 @@ function backupExistingCompose() {
85676
85856
  return bak;
85677
85857
  }
85678
85858
  function runDocker(args) {
85679
- const r = spawnSync14("docker", args, { encoding: "utf8" });
85859
+ const r = spawnSync15("docker", args, { encoding: "utf8" });
85680
85860
  return {
85681
85861
  ok: r.status === 0,
85682
85862
  stdout: r.stdout ?? "",
@@ -85869,7 +86049,7 @@ init_helpers();
85869
86049
  import { existsSync as existsSync85, mkdirSync as mkdirSync48, writeFileSync as writeFileSync42, copyFileSync as copyFileSync13 } from "node:fs";
85870
86050
  import { homedir as homedir49 } from "node:os";
85871
86051
  import { join as join83 } from "node:path";
85872
- import { spawnSync as spawnSync15 } from "node:child_process";
86052
+ import { spawnSync as spawnSync16 } from "node:child_process";
85873
86053
  function resolveWebImageTag(explicitTag, release) {
85874
86054
  if (explicitTag)
85875
86055
  return explicitTag;
@@ -85968,7 +86148,7 @@ function backupExistingCompose2() {
85968
86148
  return bak;
85969
86149
  }
85970
86150
  function runDocker2(args) {
85971
- const r = spawnSync15("docker", args, { encoding: "utf8" });
86151
+ const r = spawnSync16("docker", args, { encoding: "utf8" });
85972
86152
  return {
85973
86153
  ok: r.status === 0,
85974
86154
  stdout: r.stdout ?? "",
@@ -86098,6 +86278,7 @@ var program3 = new Command().name("switchroom").description("Multi-agent orchest
86098
86278
  registerSetupCommand(program3);
86099
86279
  registerDoctorCommand(program3);
86100
86280
  registerUpdateCommand(program3);
86281
+ registerRolloutCommand(program3);
86101
86282
  registerRestartCommand(program3);
86102
86283
  registerVersionCommand(program3);
86103
86284
  registerVersionsCommand(program3);
@@ -13713,16 +13713,8 @@ var HttpDiffPollSchema = exports_external.object({
13713
13713
  diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
13714
13714
  state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
13715
13715
  });
13716
- var TelegramReactionsPollSchema = exports_external.object({
13717
- type: exports_external.literal("telegram-reactions"),
13718
- chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
13719
- emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68‍\uD83D\uDCBB)."),
13720
- lookback: exports_external.number().int().positive().max(200).default(40),
13721
- state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
13722
- });
13723
13716
  var PollSpecSchema = exports_external.discriminatedUnion("type", [
13724
- HttpDiffPollSchema,
13725
- TelegramReactionsPollSchema
13717
+ HttpDiffPollSchema
13726
13718
  ]);
13727
13719
  var TelegramMessageActionSchema = exports_external.object({
13728
13720
  type: exports_external.literal("telegram-message"),
@@ -13760,7 +13752,7 @@ var ScheduleEntrySchema = exports_external.object({
13760
13752
  ctx.addIssue({
13761
13753
  code: exports_external.ZodIssueCode.custom,
13762
13754
  path: ["poll"],
13763
- message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
13755
+ message: "kind: poll requires a `poll` spec (http-diff)."
13764
13756
  });
13765
13757
  }
13766
13758
  if (kind !== "poll" && entry.poll) {
@@ -11299,7 +11299,7 @@ var init_dist = __esm(() => {
11299
11299
  });
11300
11300
 
11301
11301
  // src/config/schema.ts
11302
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11302
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11303
11303
  var init_schema = __esm(() => {
11304
11304
  init_zod();
11305
11305
  CodeRepoEntrySchema = exports_external.object({
@@ -11321,16 +11321,8 @@ var init_schema = __esm(() => {
11321
11321
  diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
11322
11322
  state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
11323
11323
  });
11324
- TelegramReactionsPollSchema = exports_external.object({
11325
- type: exports_external.literal("telegram-reactions"),
11326
- chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
11327
- emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68‍\uD83D\uDCBB)."),
11328
- lookback: exports_external.number().int().positive().max(200).default(40),
11329
- state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
11330
- });
11331
11324
  PollSpecSchema = exports_external.discriminatedUnion("type", [
11332
- HttpDiffPollSchema,
11333
- TelegramReactionsPollSchema
11325
+ HttpDiffPollSchema
11334
11326
  ]);
11335
11327
  TelegramMessageActionSchema = exports_external.object({
11336
11328
  type: exports_external.literal("telegram-message"),
@@ -11368,7 +11360,7 @@ var init_schema = __esm(() => {
11368
11360
  ctx.addIssue({
11369
11361
  code: exports_external.ZodIssueCode.custom,
11370
11362
  path: ["poll"],
11371
- message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11363
+ message: "kind: poll requires a `poll` spec (http-diff)."
11372
11364
  });
11373
11365
  }
11374
11366
  if (kind !== "poll" && entry.poll) {
@@ -11299,7 +11299,7 @@ var init_zod = __esm(() => {
11299
11299
  });
11300
11300
 
11301
11301
  // src/config/schema.ts
11302
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11302
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11303
11303
  var init_schema = __esm(() => {
11304
11304
  init_zod();
11305
11305
  CodeRepoEntrySchema = exports_external.object({
@@ -11321,16 +11321,8 @@ var init_schema = __esm(() => {
11321
11321
  diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
11322
11322
  state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
11323
11323
  });
11324
- TelegramReactionsPollSchema = exports_external.object({
11325
- type: exports_external.literal("telegram-reactions"),
11326
- chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
11327
- emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68‍\uD83D\uDCBB)."),
11328
- lookback: exports_external.number().int().positive().max(200).default(40),
11329
- state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
11330
- });
11331
11324
  PollSpecSchema = exports_external.discriminatedUnion("type", [
11332
- HttpDiffPollSchema,
11333
- TelegramReactionsPollSchema
11325
+ HttpDiffPollSchema
11334
11326
  ]);
11335
11327
  TelegramMessageActionSchema = exports_external.object({
11336
11328
  type: exports_external.literal("telegram-message"),
@@ -11368,7 +11360,7 @@ var init_schema = __esm(() => {
11368
11360
  ctx.addIssue({
11369
11361
  code: exports_external.ZodIssueCode.custom,
11370
11362
  path: ["poll"],
11371
- message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11363
+ message: "kind: poll requires a `poll` spec (http-diff)."
11372
11364
  });
11373
11365
  }
11374
11366
  if (kind !== "poll" && entry.poll) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.16",
3
+ "version": "0.15.18",
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": {
@@ -23802,7 +23802,7 @@ var init_dist = __esm(() => {
23802
23802
  });
23803
23803
 
23804
23804
  // ../src/config/schema.ts
23805
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
23805
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
23806
23806
  var init_schema = __esm(() => {
23807
23807
  init_zod();
23808
23808
  CodeRepoEntrySchema = exports_external.object({
@@ -23824,16 +23824,8 @@ var init_schema = __esm(() => {
23824
23824
  diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
23825
23825
  state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
23826
23826
  });
23827
- TelegramReactionsPollSchema = exports_external.object({
23828
- type: exports_external.literal("telegram-reactions"),
23829
- chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
23830
- emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68\u200d\uD83D\uDCBB)."),
23831
- lookback: exports_external.number().int().positive().max(200).default(40),
23832
- state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
23833
- });
23834
23827
  PollSpecSchema = exports_external.discriminatedUnion("type", [
23835
- HttpDiffPollSchema,
23836
- TelegramReactionsPollSchema
23828
+ HttpDiffPollSchema
23837
23829
  ]);
23838
23830
  TelegramMessageActionSchema = exports_external.object({
23839
23831
  type: exports_external.literal("telegram-message"),
@@ -23871,7 +23863,7 @@ var init_schema = __esm(() => {
23871
23863
  ctx.addIssue({
23872
23864
  code: exports_external.ZodIssueCode.custom,
23873
23865
  path: ["poll"],
23874
- message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
23866
+ message: "kind: poll requires a `poll` spec (http-diff)."
23875
23867
  });
23876
23868
  }
23877
23869
  if (kind !== "poll" && entry.poll) {
@@ -54166,11 +54158,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54166
54158
  }
54167
54159
 
54168
54160
  // ../src/build-info.ts
54169
- var VERSION = "0.15.16";
54170
- var COMMIT_SHA = "a96f10cc";
54171
- var COMMIT_DATE = "2026-06-13T20:50:23Z";
54172
- var LATEST_PR = 2330;
54173
- var COMMITS_AHEAD_OF_TAG = 0;
54161
+ var VERSION = "0.15.18";
54162
+ var COMMIT_SHA = "d7c044b9";
54163
+ var COMMIT_DATE = "2026-06-14T08:55:07+10:00";
54164
+ var LATEST_PR = null;
54165
+ var COMMITS_AHEAD_OF_TAG = 3;
54174
54166
 
54175
54167
  // gateway/boot-version.ts
54176
54168
  function formatRelativeAgo(iso) {
@@ -57214,10 +57206,8 @@ if (inboundSpool != null) {
57214
57206
  }
57215
57207
  }
57216
57208
  var pendingPermissionBuffer = createPendingPermissionBuffer();
57217
- function buildPermissionActionRow(requestId, showAlways, showTimeBox = false) {
57218
- const kb = new import_grammy9.InlineKeyboard().text("\u274C Deny", `perm:deny:${requestId}`).text("\u2705 Allow once", `perm:allow:${requestId}`);
57219
- if (showTimeBox)
57220
- kb.text("\u23F1 30 min", `perm:tmb:${requestId}`);
57209
+ function buildPermissionActionRow(requestId, showAlways) {
57210
+ const kb = new import_grammy9.InlineKeyboard().text("\u274C Deny", `perm:deny:${requestId}`).text("\u2705 Allow", `perm:allow:${requestId}`);
57221
57211
  if (showAlways)
57222
57212
  kb.text("\uD83D\uDD01 Always\u2026", `perm:always:${requestId}`);
57223
57213
  return kb;
@@ -57473,10 +57463,8 @@ var ipcServer = createIpcServer({
57473
57463
  description,
57474
57464
  agentName: _client.agentName
57475
57465
  });
57476
- const scopeChoices = resolveScopedAllowChoices(toolName, inputPreview);
57477
- const showAlways = scopeChoices != null;
57478
- const showTimeBox = scopedApprovalTtlMs() > 0 && resolveTimeBox(toolName, inputPreview, scopeChoices) != null;
57479
- const keyboard = buildPermissionActionRow(requestId, showAlways, showTimeBox);
57466
+ const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null;
57467
+ const keyboard = buildPermissionActionRow(requestId, showAlways);
57480
57468
  const activeTurn = currentTurn;
57481
57469
  const targets = resolvePermissionCardTargets();
57482
57470
  for (const { chatId, threadId } of targets) {
@@ -65088,8 +65076,8 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
65088
65076
  const cbMessageId = ctx.callbackQuery?.message?.message_id;
65089
65077
  const metaForMessage = cbMessageId != null ? agentButtonMeta.get(`${cbChatId}:${cbMessageId}`) : undefined;
65090
65078
  const tapMeta = metaForMessage?.get(agentCb.raw);
65091
- const ackText = typeof tapMeta?.ack_text === "string" && tapMeta.ack_text.length > 0 ? tapMeta.ack_text : "\u2713 received";
65092
- await ctx.answerCallbackQuery({ text: ackText }).catch(() => {});
65079
+ const ackText2 = typeof tapMeta?.ack_text === "string" && tapMeta.ack_text.length > 0 ? tapMeta.ack_text : "\u2713 received";
65080
+ await ctx.answerCallbackQuery({ text: ackText2 }).catch(() => {});
65093
65081
  const buttonText = (() => {
65094
65082
  const msg2 = ctx.callbackQuery?.message;
65095
65083
  const kb = msg2 && "reply_markup" in msg2 ? msg2.reply_markup : undefined;
@@ -65158,7 +65146,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
65158
65146
  }
65159
65147
  return;
65160
65148
  }
65161
- const m = /^perm:(allow|deny|always|asn|asb|back|tmb):([a-km-z]{5})$/.exec(data);
65149
+ const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data);
65162
65150
  if (!m) {
65163
65151
  await ctx.answerCallbackQuery().catch(() => {});
65164
65152
  return;
@@ -65178,9 +65166,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
65178
65166
  }
65179
65167
  let keyboard;
65180
65168
  if (behavior === "back") {
65181
- const backChoices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
65182
- const backTimeBox = scopedApprovalTtlMs() > 0 && resolveTimeBox(details.tool_name, details.input_preview, backChoices) != null;
65183
- keyboard = buildPermissionActionRow(request_id, true, backTimeBox);
65169
+ keyboard = buildPermissionActionRow(request_id, true);
65184
65170
  } else {
65185
65171
  const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
65186
65172
  if (choices == null) {
@@ -65313,12 +65299,12 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
65313
65299
  }
65314
65300
  const ok = durable;
65315
65301
  const legacyNote = legacy && durable;
65316
- const ackText = ok ? legacyNote ? `\u2705 Saved. ${agentName3} can now ${grantPhrase} without asking (legacy path).` : `\u2705 Saved. ${agentName3} can now ${grantPhrase} without asking.` : editLockHint ? `\u26A0\uFE0F Allowed for now \u2014 config edits are locked. Enable hostd.config_edit_enabled.` : `\u26A0\uFE0F Allowed for now, but "always" did NOT save \u2014 it will ask again after restart. Check gateway log.`;
65302
+ const ackText2 = ok ? legacyNote ? `\u2705 Saved. ${agentName3} can now ${grantPhrase} without asking (legacy path).` : `\u2705 Saved. ${agentName3} can now ${grantPhrase} without asking.` : editLockHint ? `\u26A0\uFE0F Allowed for now \u2014 config edits are locked. Enable hostd.config_edit_enabled.` : `\u26A0\uFE0F Allowed for now, but "always" did NOT save \u2014 it will ask again after restart. Check gateway log.`;
65317
65303
  const sourceMsg = ctx.callbackQuery?.message;
65318
65304
  const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
65319
65305
  const editLabel = ok ? legacyNote ? `\u2705 <b>${escapeHtmlForTg(agentName3)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking (legacy path); restart agent for full effect` : `\u2705 <b>${escapeHtmlForTg(agentName3)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect` : editLockHint ? `\u26A0\uFE0F <b>Allowed for now \u2014 "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.` : `\u26A0\uFE0F <b>Allowed for now \u2014 "always" did NOT save.</b> It will ask again after restart. Check gateway log.`;
65320
65306
  await finalizeCallback(ctx, {
65321
- ackText: ackText.slice(0, 200),
65307
+ ackText: ackText2.slice(0, 200),
65322
65308
  newText: baseText2 ? `${baseText2}
65323
65309
 
65324
65310
  ${editLabel}` : editLabel,
@@ -65326,64 +65312,28 @@ ${editLabel}` : editLabel,
65326
65312
  });
65327
65313
  return;
65328
65314
  }
65329
- if (behavior === "tmb") {
65330
- const details = pendingPermissions.get(request_id);
65331
- if (!details) {
65332
- await ctx.answerCallbackQuery({ text: "Details no longer available." }).catch(() => {});
65333
- return;
65334
- }
65335
- const ttl = scopedApprovalTtlMs();
65336
- if (ttl <= 0) {
65337
- await ctx.answerCallbackQuery({ text: "Time-boxed approvals are disabled." }).catch(() => {});
65338
- return;
65339
- }
65340
- const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
65341
- const tb = resolveTimeBox(details.tool_name, details.input_preview, choices);
65342
- if (!tb) {
65343
- await ctx.answerCallbackQuery({ text: "This action can't be time-boxed." }).catch(() => {});
65344
- return;
65345
- }
65346
- const agentName3 = selfAgentName();
65347
- if (!agentName3) {
65348
- await ctx.answerCallbackQuery({ text: "Time-box needs SWITCHROOM_AGENT_NAME \u2014 gateway is misconfigured." }).catch(() => {});
65349
- return;
65350
- }
65351
- pendingPermissions.delete(request_id);
65352
- dispatchPermissionVerdict({ type: "permission", requestId: request_id, behavior: "allow" });
65353
- recordScopedGrant(scopedGrants, agentName3, tb.rule, Date.now(), ttl);
65354
- resumeReactionAfterVerdict();
65355
- postPermissionResumeMessage({
65356
- behavior: "allow",
65357
- action: naturalAction(details.tool_name, details.input_preview)
65358
- });
65359
- process.stderr.write(`telegram gateway: scoped-approval granted rule="${tb.rule}" agent=${agentName3} ttl_ms=${ttl} (request_id=${request_id})
65315
+ const pd = pendingPermissions.get(request_id);
65316
+ const resumeAction = pd ? naturalAction(pd.tool_name, pd.input_preview) : "";
65317
+ const scopedTtl = scopedApprovalTtlMs();
65318
+ const timeBox = behavior === "allow" && scopedTtl > 0 && pd ? resolveTimeBox(pd.tool_name, pd.input_preview, resolveScopedAllowChoices(pd.tool_name, pd.input_preview)) : null;
65319
+ const grantAgent = selfAgentName();
65320
+ pendingPermissions.delete(request_id);
65321
+ if (timeBox && grantAgent) {
65322
+ recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl);
65323
+ process.stderr.write(`telegram gateway: scoped-approval granted via Allow rule="${timeBox.rule}" agent=${grantAgent} ttl_ms=${scopedTtl} (request_id=${request_id})
65360
65324
  `);
65361
- const mins = Math.max(1, Math.round(ttl / 60000));
65362
- const sourceMsg = ctx.callbackQuery?.message;
65363
- const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
65364
- const editLabel = `\u23F1 <b>Allowed for ${mins} min \u2014 ${escapeHtmlForTg(tb.breadth)}</b> \xB7 re-asks after that, and now for anything else`;
65365
- await finalizeCallback(ctx, {
65366
- ackText: `\u23F1 Allowed for ${mins} min`.slice(0, 200),
65367
- newText: baseText2 ? `${baseText2}
65368
-
65369
- ${editLabel}` : editLabel,
65370
- parseMode: "HTML"
65371
- });
65372
- return;
65373
65325
  }
65374
- const resumeAction = (() => {
65375
- const d = pendingPermissions.get(request_id);
65376
- return d ? naturalAction(d.tool_name, d.input_preview) : "";
65377
- })();
65378
- pendingPermissions.delete(request_id);
65379
- const label = behavior === "allow" ? "\u2705 Allowed" : "\u274C Denied";
65326
+ const scopedMins = Math.max(1, Math.round(scopedTtl / 60000));
65327
+ const windowGranted = timeBox != null && grantAgent !== "";
65328
+ const ackText = behavior === "deny" ? "\u274C Denied" : windowGranted ? `\u2705 Allowed (${scopedMins} min)` : "\u2705 Allowed once";
65329
+ const htmlLabel = behavior === "deny" ? "\u274C <b>Denied</b>" : windowGranted ? `\u2705 <b>Allowed \u2014 won't ask again about ${escapeHtmlForTg(timeBox.breadth)} for ${scopedMins} min</b>` : "\u2705 <b>Allowed once</b>";
65380
65330
  const msg = ctx.callbackQuery?.message;
65381
65331
  const baseText = msg && "text" in msg && msg.text ? escapeHtmlForTg(msg.text) : "";
65382
65332
  await finalizeCallback(ctx, {
65383
- ackText: label,
65333
+ ackText: ackText.slice(0, 200),
65384
65334
  newText: baseText ? `${baseText}
65385
65335
 
65386
- ${label}` : label,
65336
+ ${htmlLabel}` : htmlLabel,
65387
65337
  parseMode: "HTML",
65388
65338
  synthInbound: () => {
65389
65339
  dispatchPermissionVerdict({
@@ -3660,8 +3660,9 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
3660
3660
  }
3661
3661
  }
3662
3662
 
3663
- // "⏱ 30 min" scoped-approval store (the middle tier between Allow-once and
3664
- // 🔁 Always). Operator-tapped, gateway-side ONLY (never pushed to the
3663
+ // Scoped-approval store: the 30-min window that backs the "✅ Allow" tap for
3664
+ // narrow non-destructive scopes (not a separate button — it IS what Allow
3665
+ // means for those). Operator-tapped, gateway-side ONLY (never pushed to the
3665
3666
  // bridge's untimed sessionAllowRules), fixed-window, fail-closed. Keyed by
3666
3667
  // agent name for per-agent isolation. All policy lives in
3667
3668
  // ../scoped-approval.ts (pure + unit-tested); this gateway only wires it.
@@ -5605,24 +5606,27 @@ const pendingPermissionBuffer = createPendingPermissionBuffer()
5605
5606
  * are resolved at call-time, after module init.)
5606
5607
  */
5607
5608
  /**
5608
- * The default permission-card action row: ❌ Deny · ✅ Allow once ·
5609
+ * The default permission-card action row: ❌ Deny · ✅ Allow ·
5609
5610
  * 🔁 Always… (the last only when a meaningful always-rule exists).
5610
5611
  * Tapping "🔁 Always…" swaps this row for the scope sub-menu; "← Back"
5611
5612
  * rebuilds this row. callback_data stays tiny (verb + 5-char id) so we
5612
5613
  * never approach Telegram's 64-byte ceiling.
5614
+ *
5615
+ * "✅ Allow" is no longer always literally "once": for a NARROW,
5616
+ * non-destructive scope it auto-grants a fixed 30-min window (so the same
5617
+ * action stops re-asking) — that is the default behavior of the allow tap,
5618
+ * not a separate button. Broad / MCP / destructive scopes stay truly once.
5619
+ * The post-tap card states which happened (honest-card contract). The
5620
+ * decision lives in the allow handler via resolveTimeBox; the label here is
5621
+ * deliberately neutral ("Allow", not "Allow once" or "Allow 30 min").
5613
5622
  */
5614
5623
  function buildPermissionActionRow(
5615
5624
  requestId: string,
5616
5625
  showAlways: boolean,
5617
- showTimeBox = false,
5618
5626
  ): InlineKeyboard {
5619
5627
  const kb = new InlineKeyboard()
5620
5628
  .text('❌ Deny', `perm:deny:${requestId}`)
5621
- .text('✅ Allow once', `perm:allow:${requestId}`)
5622
- // "⏱ 30 min" sits between once and always. Only shown for a narrow,
5623
- // non-destructive scope (resolveTimeBox decides); broad/MCP/destructive
5624
- // requests get once/always only.
5625
- if (showTimeBox) kb.text('⏱ 30 min', `perm:tmb:${requestId}`)
5629
+ .text('✅ Allow', `perm:allow:${requestId}`)
5626
5630
  if (showAlways) kb.text('🔁 Always…', `perm:always:${requestId}`)
5627
5631
  return kb
5628
5632
  }
@@ -6044,19 +6048,15 @@ const ipcServer: IpcServer = createIpcServer({
6044
6048
  description,
6045
6049
  agentName: _client.agentName,
6046
6050
  })
6047
- // Compact action row: ❌ Deny · ✅ Allow once · 🔁 Always… — the
6048
- // scope of an "always" grant stays hidden until the operator taps
6049
- // "🔁 Always…", which swaps the row for a scope choice (this file /
6050
- // any file ⚠️). The "🔁 Always…" button only appears when we can
6051
- // synthesize a meaningful rule for this tool; unknown tools get the
6052
- // two-button row only.
6053
- const scopeChoices = resolveScopedAllowChoices(toolName, inputPreview)
6054
- const showAlways = scopeChoices != null
6055
- // Offer "⏱ 30 min" only for a narrow, non-destructive scope, and only
6056
- // when the tier is enabled (TTL > 0).
6057
- const showTimeBox = scopedApprovalTtlMs() > 0 &&
6058
- resolveTimeBox(toolName, inputPreview, scopeChoices) != null
6059
- const keyboard = buildPermissionActionRow(requestId, showAlways, showTimeBox)
6051
+ // Compact action row: ❌ Deny · ✅ Allow · 🔁 Always… — the scope of an
6052
+ // "always" grant stays hidden until the operator taps "🔁 Always…",
6053
+ // which swaps the row for a scope choice (this file / any file ⚠️). The
6054
+ // "🔁 Always…" button only appears when we can synthesize a meaningful
6055
+ // rule for this tool; unknown tools get the two-button row only. "Allow"
6056
+ // itself auto-grants a 30-min window for narrow non-destructive scopes
6057
+ // (decided in the allow handler), so there is no separate time-box button.
6058
+ const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
6059
+ const keyboard = buildPermissionActionRow(requestId, showAlways)
6060
6060
  // Route the card to the SAME place the post-verdict resume message
6061
6061
  // lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
6062
6062
  // there's an active turn — so a supergroup agent's card appears IN the
@@ -19073,7 +19073,7 @@ bot.on('callback_query:data', async ctx => {
19073
19073
  }
19074
19074
 
19075
19075
  // Permission request buttons.
19076
- const m = /^perm:(allow|deny|always|asn|asb|back|tmb):([a-km-z]{5})$/.exec(data)
19076
+ const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data)
19077
19077
  if (!m) { await ctx.answerCallbackQuery().catch(() => {}); return }
19078
19078
  const access = loadAccess()
19079
19079
  const senderId = String(ctx.from.id)
@@ -19088,10 +19088,7 @@ bot.on('callback_query:data', async ctx => {
19088
19088
  if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
19089
19089
  let keyboard: InlineKeyboard
19090
19090
  if (behavior === 'back') {
19091
- const backChoices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
19092
- const backTimeBox = scopedApprovalTtlMs() > 0 &&
19093
- resolveTimeBox(details.tool_name, details.input_preview, backChoices) != null
19094
- keyboard = buildPermissionActionRow(request_id, true, backTimeBox)
19091
+ keyboard = buildPermissionActionRow(request_id, true)
19095
19092
  } else {
19096
19093
  const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
19097
19094
  if (choices == null) {
@@ -19315,69 +19312,47 @@ bot.on('callback_query:data', async ctx => {
19315
19312
  return
19316
19313
  }
19317
19314
 
19318
- // "⏱ 30 min" record a fixed-window scoped grant for the NARROW scope,
19319
- // then allow the in-flight call. Mirrors the asn/asb structure: dispatch
19320
- // the verdict immediately, then edit the card. CRITICAL: the verdict
19321
- // carries NO `rule` (unlike asn/asb), so the bridge does not cache it
19322
- // untimed the window lives only in scopedGrants, gateway-side.
19323
- if (behavior === 'tmb') {
19324
- const details = pendingPermissions.get(request_id)
19325
- if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
19326
- const ttl = scopedApprovalTtlMs()
19327
- if (ttl <= 0) { await ctx.answerCallbackQuery({ text: 'Time-boxed approvals are disabled.' }).catch(() => {}); return }
19328
- const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
19329
- const tb = resolveTimeBox(details.tool_name, details.input_preview, choices)
19330
- if (!tb) { await ctx.answerCallbackQuery({ text: 'This action can\'t be time-boxed.' }).catch(() => {}); return }
19331
- const agentName = selfAgentName()
19332
- if (!agentName) { await ctx.answerCallbackQuery({ text: 'Time-box needs SWITCHROOM_AGENT_NAME — gateway is misconfigured.' }).catch(() => {}); return }
19333
-
19334
- pendingPermissions.delete(request_id)
19335
- // (1) Allow the in-flight call NOW — no `rule` (keeps the window
19336
- // strictly gateway-side; the bridge must not cache it untimed).
19337
- dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior: 'allow' })
19338
- // (2) Record the fixed-window grant so matching calls auto-allow.
19339
- recordScopedGrant(scopedGrants, agentName, tb.rule, Date.now(), ttl)
19340
- resumeReactionAfterVerdict()
19341
- postPermissionResumeMessage({
19342
- behavior: 'allow',
19343
- action: naturalAction(details.tool_name, details.input_preview),
19344
- })
19315
+ // Forward permission decision to connected bridges. Capture the pending
19316
+ // details BEFORE deleting the entry the resume message names the resumed
19317
+ // work, and "Allow" on a narrow non-destructive scope auto-grants a 30-min
19318
+ // window (resolveTimeBox) so the same action stops re-asking. This IS the
19319
+ // default behavior of Allow (no separate button); broad / MCP / destructive
19320
+ // scopes (resolveTimeBox null) and the disabled tier (ttl<=0) stay truly
19321
+ // once. The verdict is still dispatched WITHOUT a `rule` (below), so the
19322
+ // bridge never caches it untimed the window lives only in scopedGrants.
19323
+ const pd = pendingPermissions.get(request_id)
19324
+ const resumeAction = pd ? naturalAction(pd.tool_name, pd.input_preview) : ''
19325
+ const scopedTtl = scopedApprovalTtlMs()
19326
+ const timeBox = (behavior === 'allow' && scopedTtl > 0 && pd)
19327
+ ? resolveTimeBox(pd.tool_name, pd.input_preview, resolveScopedAllowChoices(pd.tool_name, pd.input_preview))
19328
+ : null
19329
+ const grantAgent = selfAgentName()
19330
+ pendingPermissions.delete(request_id)
19331
+ if (timeBox && grantAgent) {
19332
+ recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
19345
19333
  process.stderr.write(
19346
- `telegram gateway: scoped-approval granted rule="${tb.rule}" agent=${agentName} ` +
19347
- `ttl_ms=${ttl} (request_id=${request_id})\n`,
19334
+ `telegram gateway: scoped-approval granted via Allow rule="${timeBox.rule}" ` +
19335
+ `agent=${grantAgent} ttl_ms=${scopedTtl} (request_id=${request_id})\n`,
19348
19336
  )
19349
-
19350
- const mins = Math.max(1, Math.round(ttl / 60_000))
19351
- // Honest card: state the real BREADTH (e.g. "any `git` command"), not
19352
- // just the rule, plus the window — consent covers both (access-model
19353
- // honest-card contract).
19354
- const sourceMsg = ctx.callbackQuery?.message
19355
- const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
19356
- ? escapeHtmlForTg(sourceMsg.text)
19357
- : ''
19358
- const editLabel = `⏱ <b>Allowed for ${mins} min — ${escapeHtmlForTg(tb.breadth)}</b> · re-asks after that, and now for anything else`
19359
- await finalizeCallback(ctx, {
19360
- ackText: `⏱ Allowed for ${mins} min`.slice(0, 200),
19361
- newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
19362
- parseMode: 'HTML',
19363
- })
19364
- return
19365
19337
  }
19366
-
19367
- // Forward permission decision to connected bridges. Capture the work
19368
- // phrase BEFORE deleting the pending entry — postPermissionResumeMessage
19369
- // (fired in synthInbound below) names the resumed work.
19370
- const resumeAction = (() => {
19371
- const d = pendingPermissions.get(request_id)
19372
- return d ? naturalAction(d.tool_name, d.input_preview) : ''
19373
- })()
19374
- pendingPermissions.delete(request_id)
19338
+ const scopedMins = Math.max(1, Math.round(scopedTtl / 60_000))
19375
19339
  // The card collapses to a plain verdict label. The distinct agent-voiced
19376
19340
  // "got it, continuing: …" message (posted on resume below) now carries
19377
19341
  // the "is it working or did my tap do nothing?" signal the old
19378
19342
  // `▶️ resuming…` card footnote used to — and names the work, which the
19379
19343
  // footnote never did. Keeps the card terse and the resume legible.
19380
- const label = behavior === 'allow' ? '✅ Allowed' : '❌ Denied'
19344
+ // Honest-card: only claim the window when one was actually recorded
19345
+ // (timeBox eligible AND we had an agent name to key it under) — never
19346
+ // promise "won't ask again for 30 min" if nothing was stored.
19347
+ const windowGranted = timeBox != null && grantAgent !== ''
19348
+ const ackText = behavior === 'deny'
19349
+ ? '❌ Denied'
19350
+ : (windowGranted ? `✅ Allowed (${scopedMins} min)` : '✅ Allowed once')
19351
+ const htmlLabel = behavior === 'deny'
19352
+ ? '❌ <b>Denied</b>'
19353
+ : (windowGranted
19354
+ ? `✅ <b>Allowed — won't ask again about ${escapeHtmlForTg(timeBox!.breadth)} for ${scopedMins} min</b>`
19355
+ : '✅ <b>Allowed once</b>')
19381
19356
  // HTML-escape the source text — same hazard as the scope-commit and
19382
19357
  // recent-denial paths above. The permission card body
19383
19358
  // (formatPermissionCardBody) appends claude-supplied `description`
@@ -19396,10 +19371,12 @@ bot.on('callback_query:data', async ctx => {
19396
19371
  // permission was already broadcast. Routing through finalizeCallback
19397
19372
  // strips the keyboard atomically with the status-line edit.
19398
19373
  await finalizeCallback(ctx, {
19399
- ackText: label,
19400
- newText: baseText ? `${baseText}\n\n${label}` : label,
19374
+ ackText: ackText.slice(0, 200),
19375
+ newText: baseText ? `${baseText}\n\n${htmlLabel}` : htmlLabel,
19401
19376
  parseMode: 'HTML',
19402
19377
  synthInbound: () => {
19378
+ // No `rule` → the bridge does NOT cache this (truly once on the bridge);
19379
+ // any 30-min stickiness lives only in scopedGrants (recorded above).
19403
19380
  dispatchPermissionVerdict({
19404
19381
  type: 'permission',
19405
19382
  requestId: request_id,
@@ -1,9 +1,13 @@
1
1
  /**
2
- * Scoped, time-boxed approval — the "⏱ 30 min" tier, the middle rung
3
- * between "✅ Allow once" (re-prompts on the very next call) and
4
- * "🔁 Always…" (a durable `tools.allow` write that lasts forever). After
5
- * the operator taps "⏱ 30 min" on a permission card, byte-identical
6
- * in-scope requests auto-allow for a fixed window without re-carding.
2
+ * Scoped, time-boxed approval — the default behavior of the "✅ Allow" tap
3
+ * for a NARROW, non-destructive scope. Tapping Allow on such a request
4
+ * auto-grants a fixed window so byte-identical in-scope requests auto-allow
5
+ * without re-carding (killing tap-fatigue on re-edits / re-runs). Broad /
6
+ * MCP / destructive scopes get no window Allow stays truly once for them.
7
+ * "🔁 Always…" remains the separate durable (`tools.allow`, forever) tier.
8
+ * There is deliberately no separate time-box button: the window IS what
9
+ * "Allow" means for a narrow safe scope, disclosed honestly on the post-tap
10
+ * card ("won't ask again about <breadth> for 30 min" vs "allowed once").
7
11
  *
8
12
  * Design contract (reference/access-model.md — "you hold the leash"):
9
13
  *
@@ -21,14 +25,13 @@
21
25
  * - **Conservative scope (this tier, v1).** Only the *narrow* scope is
22
26
  * ever time-boxed: an exact file path (`Edit(/x.ts)`) or a Bash
23
27
  * command-family (`Bash(git:*)`). Broad scopes ("any file", resource-
24
- * blind MCP, "any command") are NOT offered the button they stay
25
- * once / always. This covers the real fatigue (re-editing the same
28
+ * blind MCP, "any command") get NO window — Allow stays truly once for them. This covers the real fatigue (re-editing the same
26
29
  * file, re-running a safe command) without fanning one tap across an
27
30
  * unbounded action set.
28
31
  * - **Fail-closed on irreversible.** A Bash family grant (`Bash(git:*)`)
29
32
  * must never auto-allow a destructive member of that family
30
33
  * (`git push --force`, `git reset --hard`). `isDestructiveBashCommand`
31
- * is re-checked at BOTH grant time (don't offer ) and match time
34
+ * is re-checked at BOTH grant time (no window granted) and match time
32
35
  * (a cached family grant fails closed → re-cards) so per-call consent
33
36
  * for irreversible actions is preserved.
34
37
  *
@@ -45,8 +48,8 @@ export const SCOPED_APPROVAL_DEFAULT_TTL_MS = 30 * 60 * 1000;
45
48
 
46
49
  /**
47
50
  * Resolve the configured window from the environment. `0` (or negative)
48
- * disables the tierthe gateway hides the button and never
49
- * short-circuits. A blank/garbage value falls back to the 30-min default.
51
+ * disables the windowAllow becomes truly once for every scope and the
52
+ * gateway never short-circuits. A blank/garbage value falls back to the 30-min default.
50
53
  * Kill-switch: `SWITCHROOM_SCOPED_APPROVAL_TTL_MS=0`.
51
54
  */
52
55
  export function scopedApprovalTtlMs(
@@ -84,7 +87,7 @@ const BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
84
87
  * Conservative time-box eligibility. Given the already-resolved scope
85
88
  * choices for a permission request, return the NARROW rule to time-box
86
89
  * plus an honest breadth phrase — or `null` when this request must not
87
- * get a button (broad-only tools, MCP, Skill, a destructive Bash
90
+ * get a window (broad-only tools, MCP, Skill, a destructive Bash
88
91
  * command, or any tool with no narrow sub-scope).
89
92
  *
90
93
  * - File tools with an exact path → time-boxable (bounded to the one
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tests for the "⏱ 30 min" scoped-approval tier (scoped-approval.ts)
2
+ * Tests for scoped-approval.ts — the 30-min window backing the "Allow" tap
3
3
  * the middle rung between "Allow once" and "🔁 Always".
4
4
  *
5
5
  * These pin the access-model invariants the adversarial review flagged as
@@ -233,7 +233,7 @@ describe('isDestructiveBashCommand — fail-closed denylist', () => {
233
233
  recordScopedGrant(store, 'clerk', 'Bash(git:*)', T0, TTL)
234
234
  // first token is the harmless `git`, but the backtick hides `rm -rf`
235
235
  expect(lookupScopedGrant(store, 'clerk', 'Bash', bashInput('git status `rm -rf /important`'), T0 + 1)).toBeNull()
236
- // and the request never gets offered the ⏱ button at grant time either
236
+ // and the request never gets a window at grant time either
237
237
  expect(timeBoxRule('Bash', bashInput('git status `rm -rf x`'))).toBeNull()
238
238
  })
239
239