switchroom 0.15.17 → 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.
@@ -49571,9 +49571,9 @@ __export(exports_server, {
49571
49571
  dispatchTool: () => dispatchTool,
49572
49572
  TOOLS: () => TOOLS
49573
49573
  });
49574
- import { spawnSync as spawnSync13 } from "node:child_process";
49574
+ import { spawnSync as spawnSync14 } from "node:child_process";
49575
49575
  function execCli(args, stdin) {
49576
- const r = spawnSync13(CLI_BIN, args, {
49576
+ const r = spawnSync14(CLI_BIN, args, {
49577
49577
  encoding: "utf-8",
49578
49578
  env: process.env,
49579
49579
  timeout: 15000,
@@ -50459,8 +50459,8 @@ var {
50459
50459
  } = import__.default;
50460
50460
 
50461
50461
  // src/build-info.ts
50462
- var VERSION = "0.15.17";
50463
- var COMMIT_SHA = "a5129bd3";
50462
+ var VERSION = "0.15.18";
50463
+ var COMMIT_SHA = "d7c044b9";
50464
50464
 
50465
50465
  // src/cli/agent.ts
50466
50466
  init_source();
@@ -76809,6 +76809,194 @@ function registerUpdateCommand(program3) {
76809
76809
  });
76810
76810
  }
76811
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
+
76812
77000
  // src/cli/restart.ts
76813
77001
  init_source();
76814
77002
  init_helpers();
@@ -78264,7 +78452,7 @@ init_helpers();
78264
78452
  init_loader();
78265
78453
  import { existsSync as existsSync64 } from "node:fs";
78266
78454
  import { resolve as resolve39, sep as sep3 } from "node:path";
78267
- import { spawnSync as spawnSync10 } from "node:child_process";
78455
+ import { spawnSync as spawnSync11 } from "node:child_process";
78268
78456
 
78269
78457
  // src/agents/workspace.ts
78270
78458
  import { readFile as readFile2, stat } from "node:fs/promises";
@@ -78977,7 +79165,7 @@ function registerWorkspaceCommand(program3) {
78977
79165
  process.exit(1);
78978
79166
  }
78979
79167
  const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? "vi";
78980
- const child = spawnSync10(editor, [target], { stdio: "inherit" });
79168
+ const child = spawnSync11(editor, [target], { stdio: "inherit" });
78981
79169
  if (child.status !== 0 && child.status !== null) {
78982
79170
  process.exit(child.status);
78983
79171
  }
@@ -79044,7 +79232,7 @@ function registerWorkspaceCommand(program3) {
79044
79232
  `);
79045
79233
  return;
79046
79234
  }
79047
- const statusResult = spawnSync10("git", ["status", "--short"], {
79235
+ const statusResult = spawnSync11("git", ["status", "--short"], {
79048
79236
  cwd: dir,
79049
79237
  encoding: "utf-8"
79050
79238
  });
@@ -79059,7 +79247,7 @@ function registerWorkspaceCommand(program3) {
79059
79247
  return;
79060
79248
  }
79061
79249
  const message = opts.message || `checkpoint: ${new Date().toISOString()}`;
79062
- const addResult = spawnSync10("git", ["add", "-A"], {
79250
+ const addResult = spawnSync11("git", ["add", "-A"], {
79063
79251
  cwd: dir,
79064
79252
  encoding: "utf-8"
79065
79253
  });
@@ -79068,7 +79256,7 @@ function registerWorkspaceCommand(program3) {
79068
79256
  `);
79069
79257
  process.exit(1);
79070
79258
  }
79071
- const commitResult = spawnSync10("git", ["commit", "-m", message], {
79259
+ const commitResult = spawnSync11("git", ["commit", "-m", message], {
79072
79260
  cwd: dir,
79073
79261
  encoding: "utf-8"
79074
79262
  });
@@ -79077,7 +79265,7 @@ function registerWorkspaceCommand(program3) {
79077
79265
  `);
79078
79266
  process.exit(1);
79079
79267
  }
79080
- const shaResult = spawnSync10("git", ["rev-parse", "--short", "HEAD"], {
79268
+ const shaResult = spawnSync11("git", ["rev-parse", "--short", "HEAD"], {
79081
79269
  cwd: dir,
79082
79270
  encoding: "utf-8"
79083
79271
  });
@@ -79098,7 +79286,7 @@ function registerWorkspaceCommand(program3) {
79098
79286
  `);
79099
79287
  return;
79100
79288
  }
79101
- const child = spawnSync10("git", ["status", "--short"], {
79289
+ const child = spawnSync11("git", ["status", "--short"], {
79102
79290
  cwd: dir,
79103
79291
  stdio: "inherit"
79104
79292
  });
@@ -84093,7 +84281,7 @@ import {
84093
84281
  } from "node:fs";
84094
84282
  import { tmpdir as tmpdir5, homedir as homedir45 } from "node:os";
84095
84283
  import { dirname as dirname23, join as join79, relative as relative2, resolve as resolve47 } from "node:path";
84096
- import { spawnSync as spawnSync11 } from "node:child_process";
84284
+ import { spawnSync as spawnSync12 } from "node:child_process";
84097
84285
 
84098
84286
  // src/cli/skill-common.ts
84099
84287
  var import_yaml22 = __toESM(require_dist(), 1);
@@ -84346,7 +84534,7 @@ function loadFromDir(dir) {
84346
84534
  function loadFromTarball(tarPath) {
84347
84535
  const isGz = tarPath.endsWith(".gz") || tarPath.endsWith(".tgz");
84348
84536
  const listFlags = isGz ? ["-tzf"] : ["-tf"];
84349
- const list2 = spawnSync11("tar", [...listFlags, tarPath], {
84537
+ const list2 = spawnSync12("tar", [...listFlags, tarPath], {
84350
84538
  encoding: "utf-8",
84351
84539
  stdio: ["ignore", "pipe", "pipe"]
84352
84540
  });
@@ -84363,7 +84551,7 @@ function loadFromTarball(tarPath) {
84363
84551
  const staging = mkdtempSync5(join79(tmpdir5(), "skill-apply-extract-"));
84364
84552
  try {
84365
84553
  const flags = isGz ? ["-xzf"] : ["-xf"];
84366
- const r = spawnSync11("tar", [
84554
+ const r = spawnSync12("tar", [
84367
84555
  ...flags,
84368
84556
  tarPath,
84369
84557
  "-C",
@@ -84438,7 +84626,7 @@ function validatePayload(name, files) {
84438
84626
  if (errors2.length === 0) {
84439
84627
  for (const [path8, content] of Object.entries(files)) {
84440
84628
  if (SH_SCRIPT_RE2.test(path8)) {
84441
- const r = spawnSync11("bash", ["-n"], {
84629
+ const r = spawnSync12("bash", ["-n"], {
84442
84630
  input: content,
84443
84631
  encoding: "utf-8"
84444
84632
  });
@@ -84450,7 +84638,7 @@ function validatePayload(name, files) {
84450
84638
  const tmpPy = join79(tmp, "check.py");
84451
84639
  try {
84452
84640
  writeFileSync39(tmpPy, content);
84453
- const r = spawnSync11("python3", ["-m", "py_compile", tmpPy], {
84641
+ const r = spawnSync12("python3", ["-m", "py_compile", tmpPy], {
84454
84642
  encoding: "utf-8"
84455
84643
  });
84456
84644
  if (r.status !== 0) {
@@ -84613,7 +84801,7 @@ function registerSkillCommand(program3) {
84613
84801
  \u2713 Wrote ${name} to ${currentDir}`));
84614
84802
  const applyBin = process.argv[1] ?? "switchroom";
84615
84803
  console.log(source_default.gray(`Running \`switchroom apply --non-interactive\`...`));
84616
- const r = spawnSync11(process.argv0, [applyBin, "apply", "--non-interactive"], { stdio: "inherit" });
84804
+ const r = spawnSync12(process.argv0, [applyBin, "apply", "--non-interactive"], { stdio: "inherit" });
84617
84805
  if (r.status !== 0) {
84618
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.)`));
84619
84807
  }
@@ -84645,7 +84833,7 @@ import {
84645
84833
  } from "node:fs";
84646
84834
  import { dirname as dirname24, join as join80, relative as relative3, resolve as resolve48 } from "node:path";
84647
84835
  import { homedir as homedir46, tmpdir as tmpdir6 } from "node:os";
84648
- import { spawnSync as spawnSync12 } from "node:child_process";
84836
+ import { spawnSync as spawnSync13 } from "node:child_process";
84649
84837
  init_helpers();
84650
84838
  init_source();
84651
84839
  var PERSONAL_PREFIX = "personal-";
@@ -84834,7 +85022,7 @@ function behavioralValidate(files) {
84834
85022
  const errors2 = [];
84835
85023
  for (const [path8, content] of Object.entries(files)) {
84836
85024
  if (SH_SCRIPT_RE.test(path8)) {
84837
- const r = spawnSync12("bash", ["-n"], { input: content, encoding: "utf-8" });
85025
+ const r = spawnSync13("bash", ["-n"], { input: content, encoding: "utf-8" });
84838
85026
  if (r.status !== 0) {
84839
85027
  errors2.push(`${path8} fails \`bash -n\`: ${(r.stderr ?? "").trim()}`);
84840
85028
  }
@@ -84843,7 +85031,7 @@ function behavioralValidate(files) {
84843
85031
  const tmpPy = join80(tmp, "check.py");
84844
85032
  try {
84845
85033
  writeFileSync40(tmpPy, content);
84846
- const r = spawnSync12("python3", ["-m", "py_compile", tmpPy], {
85034
+ const r = spawnSync13("python3", ["-m", "py_compile", tmpPy], {
84847
85035
  encoding: "utf-8"
84848
85036
  });
84849
85037
  if (r.status !== 0) {
@@ -85536,7 +85724,7 @@ init_helpers();
85536
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";
85537
85725
  import { homedir as homedir48 } from "node:os";
85538
85726
  import { join as join82 } from "node:path";
85539
- import { spawnSync as spawnSync14 } from "node:child_process";
85727
+ import { spawnSync as spawnSync15 } from "node:child_process";
85540
85728
  init_audit_reader();
85541
85729
  function resolveHostdImageTag(explicitTag, release) {
85542
85730
  if (explicitTag)
@@ -85668,7 +85856,7 @@ function backupExistingCompose() {
85668
85856
  return bak;
85669
85857
  }
85670
85858
  function runDocker(args) {
85671
- const r = spawnSync14("docker", args, { encoding: "utf8" });
85859
+ const r = spawnSync15("docker", args, { encoding: "utf8" });
85672
85860
  return {
85673
85861
  ok: r.status === 0,
85674
85862
  stdout: r.stdout ?? "",
@@ -85861,7 +86049,7 @@ init_helpers();
85861
86049
  import { existsSync as existsSync85, mkdirSync as mkdirSync48, writeFileSync as writeFileSync42, copyFileSync as copyFileSync13 } from "node:fs";
85862
86050
  import { homedir as homedir49 } from "node:os";
85863
86051
  import { join as join83 } from "node:path";
85864
- import { spawnSync as spawnSync15 } from "node:child_process";
86052
+ import { spawnSync as spawnSync16 } from "node:child_process";
85865
86053
  function resolveWebImageTag(explicitTag, release) {
85866
86054
  if (explicitTag)
85867
86055
  return explicitTag;
@@ -85960,7 +86148,7 @@ function backupExistingCompose2() {
85960
86148
  return bak;
85961
86149
  }
85962
86150
  function runDocker2(args) {
85963
- const r = spawnSync15("docker", args, { encoding: "utf8" });
86151
+ const r = spawnSync16("docker", args, { encoding: "utf8" });
85964
86152
  return {
85965
86153
  ok: r.status === 0,
85966
86154
  stdout: r.stdout ?? "",
@@ -86090,6 +86278,7 @@ var program3 = new Command().name("switchroom").description("Multi-agent orchest
86090
86278
  registerSetupCommand(program3);
86091
86279
  registerDoctorCommand(program3);
86092
86280
  registerUpdateCommand(program3);
86281
+ registerRolloutCommand(program3);
86093
86282
  registerRestartCommand(program3);
86094
86283
  registerVersionCommand(program3);
86095
86284
  registerVersionsCommand(program3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.17",
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": {
@@ -54158,11 +54158,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54158
54158
  }
54159
54159
 
54160
54160
  // ../src/build-info.ts
54161
- var VERSION = "0.15.17";
54162
- var COMMIT_SHA = "a5129bd3";
54163
- var COMMIT_DATE = "2026-06-13T22:07:37Z";
54164
- var LATEST_PR = 2332;
54165
- 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;
54166
54166
 
54167
54167
  // gateway/boot-version.ts
54168
54168
  function formatRelativeAgo(iso) {
@@ -57206,10 +57206,8 @@ if (inboundSpool != null) {
57206
57206
  }
57207
57207
  }
57208
57208
  var pendingPermissionBuffer = createPendingPermissionBuffer();
57209
- function buildPermissionActionRow(requestId, showAlways, showTimeBox = false) {
57210
- const kb = new import_grammy9.InlineKeyboard().text("\u274C Deny", `perm:deny:${requestId}`).text("\u2705 Allow once", `perm:allow:${requestId}`);
57211
- if (showTimeBox)
57212
- 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}`);
57213
57211
  if (showAlways)
57214
57212
  kb.text("\uD83D\uDD01 Always\u2026", `perm:always:${requestId}`);
57215
57213
  return kb;
@@ -57465,10 +57463,8 @@ var ipcServer = createIpcServer({
57465
57463
  description,
57466
57464
  agentName: _client.agentName
57467
57465
  });
57468
- const scopeChoices = resolveScopedAllowChoices(toolName, inputPreview);
57469
- const showAlways = scopeChoices != null;
57470
- const showTimeBox = scopedApprovalTtlMs() > 0 && resolveTimeBox(toolName, inputPreview, scopeChoices) != null;
57471
- const keyboard = buildPermissionActionRow(requestId, showAlways, showTimeBox);
57466
+ const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null;
57467
+ const keyboard = buildPermissionActionRow(requestId, showAlways);
57472
57468
  const activeTurn = currentTurn;
57473
57469
  const targets = resolvePermissionCardTargets();
57474
57470
  for (const { chatId, threadId } of targets) {
@@ -65080,8 +65076,8 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
65080
65076
  const cbMessageId = ctx.callbackQuery?.message?.message_id;
65081
65077
  const metaForMessage = cbMessageId != null ? agentButtonMeta.get(`${cbChatId}:${cbMessageId}`) : undefined;
65082
65078
  const tapMeta = metaForMessage?.get(agentCb.raw);
65083
- const ackText = typeof tapMeta?.ack_text === "string" && tapMeta.ack_text.length > 0 ? tapMeta.ack_text : "\u2713 received";
65084
- 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(() => {});
65085
65081
  const buttonText = (() => {
65086
65082
  const msg2 = ctx.callbackQuery?.message;
65087
65083
  const kb = msg2 && "reply_markup" in msg2 ? msg2.reply_markup : undefined;
@@ -65150,7 +65146,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
65150
65146
  }
65151
65147
  return;
65152
65148
  }
65153
- 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);
65154
65150
  if (!m) {
65155
65151
  await ctx.answerCallbackQuery().catch(() => {});
65156
65152
  return;
@@ -65170,9 +65166,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
65170
65166
  }
65171
65167
  let keyboard;
65172
65168
  if (behavior === "back") {
65173
- const backChoices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
65174
- const backTimeBox = scopedApprovalTtlMs() > 0 && resolveTimeBox(details.tool_name, details.input_preview, backChoices) != null;
65175
- keyboard = buildPermissionActionRow(request_id, true, backTimeBox);
65169
+ keyboard = buildPermissionActionRow(request_id, true);
65176
65170
  } else {
65177
65171
  const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
65178
65172
  if (choices == null) {
@@ -65305,12 +65299,12 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
65305
65299
  }
65306
65300
  const ok = durable;
65307
65301
  const legacyNote = legacy && durable;
65308
- 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.`;
65309
65303
  const sourceMsg = ctx.callbackQuery?.message;
65310
65304
  const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
65311
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.`;
65312
65306
  await finalizeCallback(ctx, {
65313
- ackText: ackText.slice(0, 200),
65307
+ ackText: ackText2.slice(0, 200),
65314
65308
  newText: baseText2 ? `${baseText2}
65315
65309
 
65316
65310
  ${editLabel}` : editLabel,
@@ -65318,64 +65312,28 @@ ${editLabel}` : editLabel,
65318
65312
  });
65319
65313
  return;
65320
65314
  }
65321
- if (behavior === "tmb") {
65322
- const details = pendingPermissions.get(request_id);
65323
- if (!details) {
65324
- await ctx.answerCallbackQuery({ text: "Details no longer available." }).catch(() => {});
65325
- return;
65326
- }
65327
- const ttl = scopedApprovalTtlMs();
65328
- if (ttl <= 0) {
65329
- await ctx.answerCallbackQuery({ text: "Time-boxed approvals are disabled." }).catch(() => {});
65330
- return;
65331
- }
65332
- const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
65333
- const tb = resolveTimeBox(details.tool_name, details.input_preview, choices);
65334
- if (!tb) {
65335
- await ctx.answerCallbackQuery({ text: "This action can't be time-boxed." }).catch(() => {});
65336
- return;
65337
- }
65338
- const agentName3 = selfAgentName();
65339
- if (!agentName3) {
65340
- await ctx.answerCallbackQuery({ text: "Time-box needs SWITCHROOM_AGENT_NAME \u2014 gateway is misconfigured." }).catch(() => {});
65341
- return;
65342
- }
65343
- pendingPermissions.delete(request_id);
65344
- dispatchPermissionVerdict({ type: "permission", requestId: request_id, behavior: "allow" });
65345
- recordScopedGrant(scopedGrants, agentName3, tb.rule, Date.now(), ttl);
65346
- resumeReactionAfterVerdict();
65347
- postPermissionResumeMessage({
65348
- behavior: "allow",
65349
- action: naturalAction(details.tool_name, details.input_preview)
65350
- });
65351
- 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})
65352
65324
  `);
65353
- const mins = Math.max(1, Math.round(ttl / 60000));
65354
- const sourceMsg = ctx.callbackQuery?.message;
65355
- const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
65356
- const editLabel = `\u23F1 <b>Allowed for ${mins} min \u2014 ${escapeHtmlForTg(tb.breadth)}</b> \xB7 re-asks after that, and now for anything else`;
65357
- await finalizeCallback(ctx, {
65358
- ackText: `\u23F1 Allowed for ${mins} min`.slice(0, 200),
65359
- newText: baseText2 ? `${baseText2}
65360
-
65361
- ${editLabel}` : editLabel,
65362
- parseMode: "HTML"
65363
- });
65364
- return;
65365
65325
  }
65366
- const resumeAction = (() => {
65367
- const d = pendingPermissions.get(request_id);
65368
- return d ? naturalAction(d.tool_name, d.input_preview) : "";
65369
- })();
65370
- pendingPermissions.delete(request_id);
65371
- 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>";
65372
65330
  const msg = ctx.callbackQuery?.message;
65373
65331
  const baseText = msg && "text" in msg && msg.text ? escapeHtmlForTg(msg.text) : "";
65374
65332
  await finalizeCallback(ctx, {
65375
- ackText: label,
65333
+ ackText: ackText.slice(0, 200),
65376
65334
  newText: baseText ? `${baseText}
65377
65335
 
65378
- ${label}` : label,
65336
+ ${htmlLabel}` : htmlLabel,
65379
65337
  parseMode: "HTML",
65380
65338
  synthInbound: () => {
65381
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