switchroom 0.14.12 → 0.14.13

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.
@@ -50004,10 +50004,32 @@ function defaultReadEvents(stateDir) {
50004
50004
  }
50005
50005
  // permission-title.ts
50006
50006
  import { basename as basename5 } from "node:path";
50007
- var COMMAND_TITLE_MAX = 40;
50008
- var PATH_TITLE_MAX = 40;
50007
+
50008
+ // permission-rule.ts
50009
+ var FILE_TOOLS = new Set([
50010
+ "Edit",
50011
+ "Write",
50012
+ "MultiEdit",
50013
+ "NotebookEdit",
50014
+ "Read"
50015
+ ]);
50016
+ var BROAD_ONLY_TOOLS = new Set([
50017
+ "Glob",
50018
+ "Grep",
50019
+ "WebFetch",
50020
+ "WebSearch",
50021
+ "Task",
50022
+ "Agent",
50023
+ "TodoWrite",
50024
+ "ExitPlanMode"
50025
+ ]);
50026
+ function prettyMcpServer(server) {
50027
+ return server.split(/[-_]/).filter((w) => w.length > 0).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
50028
+ }
50029
+
50030
+ // permission-title.ts
50031
+ var COMMAND_TITLE_MAX = 48;
50009
50032
  var DESCRIPTION_LINE_MAX = 240;
50010
- var INPUT_VALUE_MAX = 60;
50011
50033
  var MCP_TOOL_DESCRIPTIONS = {
50012
50034
  "mcp__agent-config__config_get": "Read its own merged config",
50013
50035
  "mcp__agent-config__cron_list": "List its own scheduled tasks",
@@ -50027,103 +50049,153 @@ var MCP_TOOL_DESCRIPTIONS = {
50027
50049
  mcp__hostd__update_apply: "Apply a fleet-wide update (pull + recreate)",
50028
50050
  mcp__hindsight__recall: "Recall relevant memories",
50029
50051
  mcp__hindsight__retain: "Retain a memory",
50030
- mcp__hindsight__reflect: "Reflect across its memory bank"
50052
+ mcp__hindsight__reflect: "Reflect across its memory bank",
50053
+ mcp__perplexity__search: "Search the web",
50054
+ mcp__perplexity__ask: "Ask the web"
50031
50055
  };
50032
- function summarizeToolForTitle(toolName, inputPreview) {
50033
- if (toolName.startsWith("mcp__")) {
50034
- const curated = MCP_TOOL_DESCRIPTIONS[toolName];
50035
- const base = curated ? curated : (() => {
50036
- const parts = toolName.split("__");
50037
- if (parts.length >= 3) {
50038
- const server = parts[1];
50039
- const verb = parts.slice(2).join("__").replace(/_/g, " ");
50040
- return `${server}: ${verb}`;
50041
- }
50042
- return toolName;
50043
- })();
50044
- const argHint = firstScalarArgHint(parseInput(inputPreview));
50045
- return argHint ? `${base} (${argHint})` : base;
50056
+ var INTERNAL_MCP_SERVERS = new Set([
50057
+ "agent-config",
50058
+ "hostd",
50059
+ "hindsight",
50060
+ "switchroom-telegram"
50061
+ ]);
50062
+ function formatPermissionCardBody(opts) {
50063
+ const action = naturalAction(opts.toolName, opts.inputPreview);
50064
+ const lines = [];
50065
+ if (opts.agentName && opts.agentName.length > 0) {
50066
+ lines.push(`\uD83D\uDD10 <b>${escapeTgHtml(capFirst(opts.agentName))}</b> wants to ${escapeTgHtml(action)}`);
50067
+ } else {
50068
+ lines.push(`\uD83D\uDD10 ${escapeTgHtml(capFirst(action))}`);
50046
50069
  }
50070
+ const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
50071
+ const truncatedWhy = rawWhy.length > DESCRIPTION_LINE_MAX ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "\u2026" : rawWhy;
50072
+ lines.push(truncatedWhy.length > 0 ? `why: <i>${escapeTgHtml(truncatedWhy)}</i>` : `why: <i>not provided</i>`);
50073
+ return lines.join(`
50074
+ `);
50075
+ }
50076
+ function naturalAction(toolName, inputPreview) {
50047
50077
  const input = parseInput(inputPreview);
50048
- if (!input)
50049
- return toolName;
50078
+ if (toolName.startsWith("mcp__"))
50079
+ return naturalMcpAction(toolName, input);
50050
50080
  switch (toolName) {
50051
- case "Skill": {
50052
- const skill = readString(input, "skill") ?? readString(input, "skill_name") ?? readString(input, "skillName") ?? readString(input, "name") ?? skillBasenameFromPath(input);
50053
- if (skill)
50054
- return `${toolName} (${skill})`;
50055
- const command = readString(input, "command");
50056
- if (command)
50057
- return `${toolName}: ${truncate6(command, COMMAND_TITLE_MAX)}`;
50058
- const argHint = firstScalarArgHint(input);
50059
- return argHint ? `${toolName} (${argHint})` : toolName;
50060
- }
50061
- case "Bash": {
50062
- const command = readString(input, "command");
50063
- return command ? `${toolName}: ${truncate6(command, COMMAND_TITLE_MAX)}` : toolName;
50064
- }
50065
- case "Read":
50066
50081
  case "Edit":
50067
- case "Write":
50068
50082
  case "MultiEdit":
50069
50083
  case "NotebookEdit": {
50070
- const filePath = readString(input, "file_path") ?? readString(input, "notebook_path");
50071
- return filePath ? `${toolName}: ${truncate6(basename5(filePath), PATH_TITLE_MAX)}` : toolName;
50084
+ const f = fileBase(input);
50085
+ return f ? `edit: ${f}` : "edit files";
50086
+ }
50087
+ case "Write": {
50088
+ const f = fileBase(input);
50089
+ return f ? `write: ${f}` : "write files";
50090
+ }
50091
+ case "Read": {
50092
+ const f = fileBase(input);
50093
+ return f ? `read: ${f}` : "read files";
50094
+ }
50095
+ case "Bash": {
50096
+ const c = input ? readString(input, "command") : null;
50097
+ return c ? `run: ${truncate6(c, COMMAND_TITLE_MAX)}` : "run shell commands";
50098
+ }
50099
+ case "Skill": {
50100
+ const s = input ? resolveSkillName(input) : null;
50101
+ return s ? `use the ${s} skill` : "use a skill";
50072
50102
  }
50073
50103
  case "Glob":
50074
50104
  case "Grep": {
50075
- const pattern = readString(input, "pattern");
50076
- return pattern ? `${toolName}: ${truncate6(pattern, COMMAND_TITLE_MAX)}` : toolName;
50105
+ const p = input ? readString(input, "pattern") : null;
50106
+ return p ? `search files for: ${truncate6(p, COMMAND_TITLE_MAX)}` : "search files";
50077
50107
  }
50078
- case "WebFetch":
50079
50108
  case "WebSearch": {
50080
- const query2 = readString(input, "url") ?? readString(input, "query");
50081
- return query2 ? `${toolName}: ${truncate6(query2, COMMAND_TITLE_MAX)}` : toolName;
50109
+ const q = input ? readString(input, "query") : null;
50110
+ return q ? `search the web for: ${truncate6(q, COMMAND_TITLE_MAX)}` : "search the web";
50082
50111
  }
50112
+ case "WebFetch": {
50113
+ const u = input ? readString(input, "url") : null;
50114
+ return u ? `fetch a web page: ${truncate6(u, COMMAND_TITLE_MAX)}` : "fetch a web page";
50115
+ }
50116
+ case "Task":
50117
+ case "Agent":
50118
+ return "dispatch a sub-agent";
50119
+ case "TodoWrite":
50120
+ return "update its task list";
50121
+ case "ExitPlanMode":
50122
+ return "exit plan mode";
50083
50123
  default:
50084
- return toolName;
50085
- }
50086
- }
50087
- function formatPermissionCardBody(opts) {
50088
- const summary = summarizeToolForTitle(opts.toolName, opts.inputPreview);
50089
- const lines = [];
50090
- const agentBit = opts.agentName && opts.agentName.length > 0 ? `<b>${escapeTgHtml(opts.agentName)}</b> \u00b7 ` : "";
50091
- lines.push(`\uD83D\uDD10 ${agentBit}${escapeTgHtml(summary)}`);
50092
- const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
50093
- const truncatedWhy = rawWhy.length > DESCRIPTION_LINE_MAX ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "\u2026" : rawWhy;
50094
- if (truncatedWhy.length > 0) {
50095
- lines.push(`why: <i>${escapeTgHtml(truncatedWhy)}</i>`);
50096
- } else {
50097
- lines.push(`why: <i>not provided</i>`);
50124
+ return `use ${toolName}`;
50125
+ }
50126
+ }
50127
+ function naturalMcpAction(toolName, input) {
50128
+ const parts = toolName.split("__");
50129
+ const server = parts.length >= 2 ? parts[1] : "";
50130
+ const curated = MCP_TOOL_DESCRIPTIONS[toolName];
50131
+ if (curated) {
50132
+ const phrase = lowerFirst(curated);
50133
+ return INTERNAL_MCP_SERVERS.has(server) ? phrase : `${phrase} (${prettyMcpServer(server)})`;
50134
+ }
50135
+ if (parts.length >= 3) {
50136
+ const verb = parts.slice(2).join("__").replace(/_/g, " ");
50137
+ return INTERNAL_MCP_SERVERS.has(server) ? verb : `${verb} (${prettyMcpServer(server)})`;
50138
+ }
50139
+ return `use ${toolName}`;
50140
+ }
50141
+ function describeGrant(toolName, inputPreview, option) {
50142
+ const rule = option.rule;
50143
+ if (rule.endsWith("__*") && rule.startsWith("mcp__")) {
50144
+ const server = rule.split("__")[1] ?? "";
50145
+ return `use any ${prettyMcpServer(server)} tool`;
50146
+ }
50147
+ const scoped = /^([A-Za-z]+)\((.+)\)$/.exec(rule);
50148
+ if (scoped) {
50149
+ const t = scoped[1];
50150
+ const arg = scoped[2];
50151
+ if (t === "Skill")
50152
+ return `use the ${arg} skill`;
50153
+ if (t === "Bash") {
50154
+ const m = /^([^:]+):\*$/.exec(arg);
50155
+ return m ? `run ${m[1]} commands` : "run that command";
50156
+ }
50157
+ if (t === "Edit" || t === "MultiEdit" || t === "NotebookEdit")
50158
+ return `edit ${basename5(arg)}`;
50159
+ if (t === "Write")
50160
+ return `write ${basename5(arg)}`;
50161
+ if (t === "Read")
50162
+ return `read ${basename5(arg)}`;
50163
+ return naturalAction(toolName, inputPreview);
50164
+ }
50165
+ switch (rule) {
50166
+ case "Edit":
50167
+ case "MultiEdit":
50168
+ case "NotebookEdit":
50169
+ return "edit any file";
50170
+ case "Write":
50171
+ return "write any file";
50172
+ case "Read":
50173
+ return "read any file";
50174
+ case "Bash":
50175
+ return "run any command";
50176
+ case "Skill":
50177
+ return "use any skill";
50178
+ default:
50179
+ return naturalAction(toolName, inputPreview);
50098
50180
  }
50099
- return lines.join(`
50100
- `);
50101
50181
  }
50102
- function escapeTgHtml(text) {
50103
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
50182
+ function resolveSkillName(input) {
50183
+ return readString(input, "skill") ?? readString(input, "skill_name") ?? readString(input, "skillName") ?? readString(input, "name") ?? skillBasenameFromPath(input);
50104
50184
  }
50105
- function firstScalarArgHint(input) {
50185
+ function fileBase(input) {
50106
50186
  if (!input)
50107
50187
  return null;
50108
- const SKIP = new Set([
50109
- "chat_id",
50110
- "chatId",
50111
- "message_thread_id",
50112
- "messageThreadId",
50113
- "request_id",
50114
- "requestId"
50115
- ]);
50116
- for (const [key, value] of Object.entries(input)) {
50117
- if (SKIP.has(key))
50118
- continue;
50119
- if (typeof value === "string" && value.length > 0) {
50120
- return `${key}: ${truncate6(value, INPUT_VALUE_MAX)}`;
50121
- }
50122
- if (typeof value === "number" || typeof value === "boolean") {
50123
- return `${key}: ${String(value)}`;
50124
- }
50125
- }
50126
- return null;
50188
+ const p = readString(input, "file_path") ?? readString(input, "notebook_path");
50189
+ return p ? basename5(p) : null;
50190
+ }
50191
+ function lowerFirst(text) {
50192
+ return text.length > 0 ? text.charAt(0).toLowerCase() + text.slice(1) : text;
50193
+ }
50194
+ function capFirst(text) {
50195
+ return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : text;
50196
+ }
50197
+ function escapeTgHtml(text) {
50198
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
50127
50199
  }
50128
50200
  function parseInput(raw) {
50129
50201
  if (!raw || typeof raw !== "string")
@@ -50149,8 +50221,8 @@ function skillBasenameFromPath(input) {
50149
50221
  return null;
50150
50222
  const trimmed = path.replace(/\/SKILL\.md$/i, "").replace(/\/$/, "");
50151
50223
  const lastSlash = trimmed.lastIndexOf("/");
50152
- const basename6 = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
50153
- return basename6.length > 0 ? basename6 : null;
50224
+ const base = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
50225
+ return base.length > 0 ? base : null;
50154
50226
  }
50155
50227
  function truncate6(text, max) {
50156
50228
  const collapsed = text.replace(/\s+/g, " ").trim();
@@ -50161,47 +50233,96 @@ function truncate6(text, max) {
50161
50233
 
50162
50234
  // permission-rule.ts
50163
50235
  import { basename as basename6 } from "node:path";
50164
- function resolveAlwaysAllowRule(toolName, inputPreview) {
50236
+ var FILE_TOOLS2 = new Set([
50237
+ "Edit",
50238
+ "Write",
50239
+ "MultiEdit",
50240
+ "NotebookEdit",
50241
+ "Read"
50242
+ ]);
50243
+ var BROAD_ONLY_TOOLS2 = new Set([
50244
+ "Glob",
50245
+ "Grep",
50246
+ "WebFetch",
50247
+ "WebSearch",
50248
+ "Task",
50249
+ "Agent",
50250
+ "TodoWrite",
50251
+ "ExitPlanMode"
50252
+ ]);
50253
+ function resolveScopedAllowChoices(toolName, inputPreview) {
50165
50254
  if (!toolName)
50166
50255
  return null;
50167
50256
  const input = parseInput2(inputPreview);
50168
- switch (toolName) {
50169
- case "Skill": {
50170
- if (!input)
50171
- return null;
50172
- const skill = readString2(input, "skill") ?? readString2(input, "skill_name") ?? readString2(input, "skillName") ?? readString2(input, "name") ?? skillBasenameFromPath2(input);
50173
- if (!skill)
50174
- return null;
50175
- if (!/^[A-Za-z0-9._\-+]+$/.test(skill))
50176
- return null;
50257
+ if (toolName === "Skill") {
50258
+ const skill = input ? resolveSkillName2(input) : null;
50259
+ if (!skill)
50260
+ return null;
50261
+ if (!/^[A-Za-z0-9._\-+]+$/.test(skill))
50262
+ return null;
50263
+ return {
50264
+ specific: { rule: `Skill(${skill})`, buttonLabel: "This skill", broad: false },
50265
+ broad: { rule: "Skill", buttonLabel: "Any skill", broad: true }
50266
+ };
50267
+ }
50268
+ if (FILE_TOOLS2.has(toolName)) {
50269
+ const path = filePathFrom(input);
50270
+ const broad = { rule: toolName, buttonLabel: "Any file", broad: true };
50271
+ if (path) {
50177
50272
  return {
50178
- rule: `Skill(${skill})`,
50179
- label: `Skill(${skill})`
50273
+ specific: { rule: `${toolName}(${path})`, buttonLabel: "This file", broad: false },
50274
+ broad
50180
50275
  };
50181
50276
  }
50182
- case "Bash":
50183
- case "Read":
50184
- case "Write":
50185
- case "Edit":
50186
- case "MultiEdit":
50187
- case "NotebookEdit":
50188
- case "Glob":
50189
- case "Grep":
50190
- case "WebFetch":
50191
- case "WebSearch":
50192
- case "Task":
50193
- case "Agent":
50194
- case "TodoWrite":
50195
- case "ExitPlanMode": {
50196
- return { rule: toolName, label: toolName };
50197
- }
50198
- default: {
50199
- if (/^mcp__[A-Za-z0-9_\-]+(__[A-Za-z0-9_\-]+)?$/.test(toolName)) {
50200
- return { rule: toolName, label: toolName };
50201
- }
50202
- return null;
50277
+ return { broad };
50278
+ }
50279
+ if (toolName === "Bash") {
50280
+ const broad = { rule: "Bash", buttonLabel: "Any command", broad: true };
50281
+ const cmd = input ? readString2(input, "command") : null;
50282
+ const tok = cmd ? bashFirstToken(cmd) : null;
50283
+ if (tok) {
50284
+ return {
50285
+ specific: { rule: `Bash(${tok}:*)`, buttonLabel: `${tok} commands`, broad: false },
50286
+ broad
50287
+ };
50203
50288
  }
50289
+ return { broad };
50290
+ }
50291
+ if (BROAD_ONLY_TOOLS2.has(toolName)) {
50292
+ return { broad: { rule: toolName, buttonLabel: "Always allow", broad: true } };
50204
50293
  }
50294
+ const mcp = /^mcp__([A-Za-z0-9_-]+)__([A-Za-z0-9_-]+)$/.exec(toolName);
50295
+ if (mcp) {
50296
+ const server = mcp[1];
50297
+ return {
50298
+ specific: { rule: toolName, buttonLabel: "This action", broad: false },
50299
+ broad: { rule: `mcp__${server}__*`, buttonLabel: `All ${prettyMcpServer2(server)}`, broad: true }
50300
+ };
50301
+ }
50302
+ if (/^mcp__[A-Za-z0-9_-]+$/.test(toolName)) {
50303
+ return { broad: { rule: toolName, buttonLabel: "Always allow", broad: true } };
50304
+ }
50305
+ return null;
50306
+ }
50307
+ function prettyMcpServer2(server) {
50308
+ return server.split(/[-_]/).filter((w) => w.length > 0).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
50309
+ }
50310
+ function resolveSkillName2(input) {
50311
+ return readString2(input, "skill") ?? readString2(input, "skill_name") ?? readString2(input, "skillName") ?? readString2(input, "name") ?? skillBasenameFromPath2(input);
50312
+ }
50313
+ function filePathFrom(input) {
50314
+ if (!input)
50315
+ return null;
50316
+ return readString2(input, "file_path") ?? readString2(input, "notebook_path");
50317
+ }
50318
+ function bashFirstToken(command) {
50319
+ const m = /^\s*([^\s|&;<>()`$]+)/.exec(command);
50320
+ if (!m)
50321
+ return null;
50322
+ const tok = m[1];
50323
+ if (tok.includes(".."))
50324
+ return null;
50325
+ return /^[A-Za-z0-9._\-\/]+$/.test(tok) ? tok : null;
50205
50326
  }
50206
50327
  function parseInput2(raw) {
50207
50328
  if (!raw || typeof raw !== "string")
@@ -50792,11 +50913,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
50792
50913
  }
50793
50914
 
50794
50915
  // ../src/build-info.ts
50795
- var VERSION = "0.14.12";
50796
- var COMMIT_SHA = "a6cc0835";
50797
- var COMMIT_DATE = "2026-05-29T20:52:05+10:00";
50798
- var LATEST_PR = null;
50799
- var COMMITS_AHEAD_OF_TAG = 2;
50916
+ var VERSION = "0.14.13";
50917
+ var COMMIT_SHA = "240594e9";
50918
+ var COMMIT_DATE = "2026-05-29T12:19:57Z";
50919
+ var LATEST_PR = 1996;
50920
+ var COMMITS_AHEAD_OF_TAG = 0;
50800
50921
 
50801
50922
  // gateway/boot-version.ts
50802
50923
  function formatRelativeAgo(iso) {
@@ -52795,6 +52916,12 @@ if (inboundSpool != null) {
52795
52916
  }
52796
52917
  }
52797
52918
  var pendingPermissionBuffer = createPendingPermissionBuffer();
52919
+ function buildPermissionActionRow(requestId, showAlways) {
52920
+ const kb = new import_grammy9.InlineKeyboard().text("\u274C Deny", `perm:deny:${requestId}`).text("\u2705 Allow once", `perm:allow:${requestId}`);
52921
+ if (showAlways)
52922
+ kb.text("\uD83D\uDD01 Always\u2026", `perm:always:${requestId}`);
52923
+ return kb;
52924
+ }
52798
52925
  function dispatchPermissionVerdict(ev) {
52799
52926
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
52800
52927
  const delivered = ipcServer.sendToAgent(selfAgent, ev);
@@ -53013,11 +53140,8 @@ var ipcServer = createIpcServer({
53013
53140
  description,
53014
53141
  agentName: _client.agentName
53015
53142
  });
53016
- const alwaysRule = resolveAlwaysAllowRule(toolName, inputPreview);
53017
- const keyboard = new import_grammy9.InlineKeyboard().text("See more", `perm:more:${requestId}`).text("\u2705 Allow", `perm:allow:${requestId}`).text("\u274C Deny", `perm:deny:${requestId}`);
53018
- if (alwaysRule != null) {
53019
- keyboard.row().text(`\uD83D\uDD01 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`);
53020
- }
53143
+ const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null;
53144
+ const keyboard = buildPermissionActionRow(requestId, showAlways);
53021
53145
  const activeTurn = currentTurn;
53022
53146
  const permTopic = resolveAgentOutboundTopic({
53023
53147
  kind: "permission",
@@ -59715,7 +59839,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
59715
59839
  }
59716
59840
  return;
59717
59841
  }
59718
- const m = /^perm:(allow|deny|more|always):([a-km-z]{5})$/.exec(data);
59842
+ const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data);
59719
59843
  if (!m) {
59720
59844
  await ctx.answerCallbackQuery().catch(() => {});
59721
59845
  return;
@@ -59727,45 +59851,43 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
59727
59851
  return;
59728
59852
  }
59729
59853
  const [, behavior, request_id] = m;
59730
- if (behavior === "more") {
59854
+ if (behavior === "always" || behavior === "back") {
59731
59855
  const details = pendingPermissions.get(request_id);
59732
59856
  if (!details) {
59733
59857
  await ctx.answerCallbackQuery({ text: "Details no longer available." }).catch(() => {});
59734
59858
  return;
59735
59859
  }
59736
- const { tool_name, description, input_preview } = details;
59737
- let prettyInput;
59738
- try {
59739
- prettyInput = JSON.stringify(JSON.parse(input_preview), null, 2);
59740
- } catch {
59741
- prettyInput = input_preview;
59742
- }
59743
- const expanded = `\uD83D\uDD10 Permission: ${tool_name}
59744
-
59745
- tool_name: ${tool_name}
59746
- description: ${description}
59747
- input_preview:
59748
- ${prettyInput}`;
59749
- const expandedRule = resolveAlwaysAllowRule(tool_name, input_preview);
59750
- const expandedKeyboard = new import_grammy9.InlineKeyboard().text("\u2705 Allow", `perm:allow:${request_id}`).text("\u274C Deny", `perm:deny:${request_id}`);
59751
- if (expandedRule != null) {
59752
- expandedKeyboard.row().text(`\uD83D\uDD01 Always allow ${expandedRule.label}`, `perm:always:${request_id}`);
59860
+ let keyboard;
59861
+ if (behavior === "back") {
59862
+ keyboard = buildPermissionActionRow(request_id, true);
59863
+ } else {
59864
+ const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
59865
+ if (choices == null) {
59866
+ await ctx.answerCallbackQuery({ text: "No always-allow rule for this tool." }).catch(() => {});
59867
+ return;
59868
+ }
59869
+ keyboard = new import_grammy9.InlineKeyboard().text("\u2190 Back", `perm:back:${request_id}`);
59870
+ if (choices.specific)
59871
+ keyboard.text(choices.specific.buttonLabel, `perm:asn:${request_id}`);
59872
+ keyboard.text(`${choices.broad.buttonLabel} \u26A0\uFE0F`, `perm:asb:${request_id}`);
59753
59873
  }
59754
- await ctx.editMessageText(expanded, { reply_markup: expandedKeyboard }).catch(() => {});
59874
+ await ctx.editMessageReplyMarkup({ reply_markup: keyboard }).catch(() => {});
59755
59875
  await ctx.answerCallbackQuery().catch(() => {});
59756
59876
  return;
59757
59877
  }
59758
- if (behavior === "always") {
59878
+ if (behavior === "asn" || behavior === "asb") {
59759
59879
  const details = pendingPermissions.get(request_id);
59760
59880
  if (!details) {
59761
59881
  await ctx.answerCallbackQuery({ text: "Details no longer available." }).catch(() => {});
59762
59882
  return;
59763
59883
  }
59764
- const rule = resolveAlwaysAllowRule(details.tool_name, details.input_preview);
59765
- if (rule == null) {
59884
+ const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
59885
+ if (choices == null) {
59766
59886
  await ctx.answerCallbackQuery({ text: "Cannot synthesize an always-allow rule for this tool." }).catch(() => {});
59767
59887
  return;
59768
59888
  }
59889
+ const chosen = behavior === "asn" ? choices.specific ?? choices.broad : choices.broad;
59890
+ const grantPhrase = describeGrant(details.tool_name, details.input_preview, chosen);
59769
59891
  const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
59770
59892
  if (!agentName3) {
59771
59893
  await ctx.answerCallbackQuery({ text: "Always-allow needs SWITCHROOM_AGENT_NAME \u2014 gateway is misconfigured." }).catch(() => {});
@@ -59776,7 +59898,7 @@ ${prettyInput}`;
59776
59898
  type: "permission",
59777
59899
  requestId: request_id,
59778
59900
  behavior: "allow",
59779
- rule: rule.rule
59901
+ rule: chosen.rule
59780
59902
  });
59781
59903
  let durable = false;
59782
59904
  let legacy = false;
@@ -59787,26 +59909,26 @@ ${prettyInput}`;
59787
59909
  try {
59788
59910
  const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findConfigFile2();
59789
59911
  const raw = readFileSync36(cfgPath, "utf8");
59790
- return synthesizeAllowRuleDiff({ agentName: agentName3, rule: rule.rule, configText: raw });
59912
+ return synthesizeAllowRuleDiff({ agentName: agentName3, rule: chosen.rule, configText: raw });
59791
59913
  } catch (err) {
59792
59914
  process.stderr.write(`telegram gateway: always-allow diff synth failed: ${err.message}
59793
59915
  `);
59794
59916
  return null;
59795
59917
  }
59796
59918
  })();
59797
- const correlationKey = `${agentName3}::${rule.rule}`;
59919
+ const correlationKey = `${agentName3}::${chosen.rule}`;
59798
59920
  try {
59799
59921
  if (unifiedDiff == null) {
59800
59922
  legacy = true;
59801
59923
  } else {
59802
- pendingAlwaysAllowCorrelations.set(correlationKey, { agentName: agentName3, rule: rule.rule, unifiedDiff, createdAt: Date.now() });
59924
+ pendingAlwaysAllowCorrelations.set(correlationKey, { agentName: agentName3, rule: chosen.rule, unifiedDiff, createdAt: Date.now() });
59803
59925
  const req = {
59804
59926
  v: 1,
59805
59927
  op: "config_propose_edit",
59806
59928
  request_id: hostdRequestId("gw-always-allow"),
59807
59929
  args: {
59808
59930
  unified_diff: unifiedDiff,
59809
- reason: `Operator 'always allow' for ${rule.label}`,
59931
+ reason: `Operator 'always allow': ${agentName3} can ${grantPhrase}`,
59810
59932
  target_path: "/state/config/switchroom.yaml"
59811
59933
  }
59812
59934
  };
@@ -59816,7 +59938,7 @@ ${prettyInput}`;
59816
59938
  legacy = true;
59817
59939
  } else if (resp.result === "completed") {
59818
59940
  durable = true;
59819
- process.stderr.write(`telegram gateway: always-allow durable via hostd rule="${rule.rule}" agent=${agentName3} (request_id=${request_id})
59941
+ process.stderr.write(`telegram gateway: always-allow durable via hostd rule="${chosen.rule}" agent=${agentName3} (request_id=${request_id})
59820
59942
  `);
59821
59943
  } else {
59822
59944
  failReason = resp.error ?? `hostd ${resp.result}`;
@@ -59828,19 +59950,19 @@ ${prettyInput}`;
59828
59950
  }
59829
59951
  if (legacy) {
59830
59952
  try {
59831
- switchroomExec(["agent", "grant", agentName3, rule.rule, "--no-restart"]);
59953
+ switchroomExec(["agent", "grant", agentName3, chosen.rule, "--no-restart"]);
59832
59954
  try {
59833
59955
  const cfg = loadConfig2();
59834
59956
  const rawAgent = cfg.agents?.[agentName3];
59835
59957
  if (rawAgent) {
59836
59958
  const resolved = resolveAgentConfig2(cfg.defaults, cfg.profiles, rawAgent);
59837
59959
  const allowList = resolved.tools?.allow ?? [];
59838
- if (isRulePersisted(allowList, rule.rule)) {
59960
+ if (isRulePersisted(allowList, chosen.rule)) {
59839
59961
  durable = true;
59840
- process.stderr.write(`telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName3} via legacy grant (request_id=${request_id})
59962
+ process.stderr.write(`telegram gateway: always-allow added rule="${chosen.rule}" agent=${agentName3} via legacy grant (request_id=${request_id})
59841
59963
  `);
59842
59964
  } else {
59843
- failReason = `rule "${rule.rule}" not found in resolved tools.allow after write \u2014 config location may have drifted`;
59965
+ failReason = `rule "${chosen.rule}" not found in resolved tools.allow after write \u2014 config location may have drifted`;
59844
59966
  process.stderr.write(`telegram gateway: always-allow VERIFY FAILED: ${failReason} (request_id=${request_id})
59845
59967
  `);
59846
59968
  }
@@ -59865,10 +59987,10 @@ ${prettyInput}`;
59865
59987
  }
59866
59988
  const ok = durable;
59867
59989
  const legacyNote = legacy && durable;
59868
- const ackText = ok ? legacyNote ? `\uD83D\uDD01 Always allow ${rule.label} for ${agentName3} (legacy path)` : `\uD83D\uDD01 Always allow ${rule.label} for ${agentName3}` : 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.`;
59990
+ 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.`;
59869
59991
  const sourceMsg = ctx.callbackQuery?.message;
59870
59992
  const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
59871
- const editLabel = ok ? legacyNote ? `\uD83D\uDD01 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName3)} \u2014 saved (legacy path); restart agent for full effect` : `\uD83D\uDD01 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName3)} \u2014 saved; 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.`;
59993
+ 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.`;
59872
59994
  await finalizeCallback(ctx, {
59873
59995
  ackText: ackText.slice(0, 200),
59874
59996
  newText: baseText2 ? `${baseText2}