switchroom 0.14.11 → 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.
@@ -23607,7 +23607,7 @@ var init_dist = __esm(() => {
23607
23607
  });
23608
23608
 
23609
23609
  // ../src/config/schema.ts
23610
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
23610
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
23611
23611
  var init_schema = __esm(() => {
23612
23612
  init_zod();
23613
23613
  CodeRepoEntrySchema = exports_external.object({
@@ -24057,6 +24057,9 @@ var init_schema = __esm(() => {
24057
24057
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip \u2014 the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` \u2014 " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C \u00a75.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
24058
24058
  auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
24059
24059
  });
24060
+ WebServiceConfigSchema = exports_external.object({
24061
+ managed: exports_external.boolean().default(false).describe("Whether `switchroom update` refreshes the web-service container " + "(dashboard + GitHub-webhook receiver) via `switchroom webd " + "install`. Default: false \u2014 existing installs run the web server " + "as the legacy `switchroom-web.service` systemd unit and must not " + "be surprised by a container takeover of host loopback 127.0.0.1:" + "8080 mid-update. Set true ONLY after cutting over to the " + "container (stop+disable the systemd unit, `switchroom webd " + "install`). The container runs in its own compose project " + "(`switchroom-web`), separate from the agent fleet, with " + "network_mode: host so it keeps owning loopback:8080 for the " + "cloudflared tunnel + tailscale serve consumers.")
24062
+ });
24060
24063
  HostdConfigSchema = exports_external.object({
24061
24064
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
24062
24065
  config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
@@ -24090,6 +24093,7 @@ var init_schema = __esm(() => {
24090
24093
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
24091
24094
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
24092
24095
  hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a \u2014 disabled by default)."),
24096
+ web_service: WebServiceConfigSchema.default({}).describe("Web-service container (dashboard + GitHub-webhook receiver) config. " + "Defaults to managed=false so existing systemd-mode installs are " + "untouched. Set managed: true after cutting over to the " + "`switchroom-web` container \u2014 then `switchroom update` keeps it " + "refreshed. See `switchroom webd install`."),
24093
24097
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
24094
24098
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
24095
24099
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -50000,10 +50004,32 @@ function defaultReadEvents(stateDir) {
50000
50004
  }
50001
50005
  // permission-title.ts
50002
50006
  import { basename as basename5 } from "node:path";
50003
- var COMMAND_TITLE_MAX = 40;
50004
- 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;
50005
50032
  var DESCRIPTION_LINE_MAX = 240;
50006
- var INPUT_VALUE_MAX = 60;
50007
50033
  var MCP_TOOL_DESCRIPTIONS = {
50008
50034
  "mcp__agent-config__config_get": "Read its own merged config",
50009
50035
  "mcp__agent-config__cron_list": "List its own scheduled tasks",
@@ -50023,103 +50049,153 @@ var MCP_TOOL_DESCRIPTIONS = {
50023
50049
  mcp__hostd__update_apply: "Apply a fleet-wide update (pull + recreate)",
50024
50050
  mcp__hindsight__recall: "Recall relevant memories",
50025
50051
  mcp__hindsight__retain: "Retain a memory",
50026
- 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"
50027
50055
  };
50028
- function summarizeToolForTitle(toolName, inputPreview) {
50029
- if (toolName.startsWith("mcp__")) {
50030
- const curated = MCP_TOOL_DESCRIPTIONS[toolName];
50031
- const base = curated ? curated : (() => {
50032
- const parts = toolName.split("__");
50033
- if (parts.length >= 3) {
50034
- const server = parts[1];
50035
- const verb = parts.slice(2).join("__").replace(/_/g, " ");
50036
- return `${server}: ${verb}`;
50037
- }
50038
- return toolName;
50039
- })();
50040
- const argHint = firstScalarArgHint(parseInput(inputPreview));
50041
- 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))}`);
50042
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) {
50043
50077
  const input = parseInput(inputPreview);
50044
- if (!input)
50045
- return toolName;
50078
+ if (toolName.startsWith("mcp__"))
50079
+ return naturalMcpAction(toolName, input);
50046
50080
  switch (toolName) {
50047
- case "Skill": {
50048
- const skill = readString(input, "skill") ?? readString(input, "skill_name") ?? readString(input, "skillName") ?? readString(input, "name") ?? skillBasenameFromPath(input);
50049
- if (skill)
50050
- return `${toolName} (${skill})`;
50051
- const command = readString(input, "command");
50052
- if (command)
50053
- return `${toolName}: ${truncate6(command, COMMAND_TITLE_MAX)}`;
50054
- const argHint = firstScalarArgHint(input);
50055
- return argHint ? `${toolName} (${argHint})` : toolName;
50056
- }
50057
- case "Bash": {
50058
- const command = readString(input, "command");
50059
- return command ? `${toolName}: ${truncate6(command, COMMAND_TITLE_MAX)}` : toolName;
50060
- }
50061
- case "Read":
50062
50081
  case "Edit":
50063
- case "Write":
50064
50082
  case "MultiEdit":
50065
50083
  case "NotebookEdit": {
50066
- const filePath = readString(input, "file_path") ?? readString(input, "notebook_path");
50067
- 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";
50068
50102
  }
50069
50103
  case "Glob":
50070
50104
  case "Grep": {
50071
- const pattern = readString(input, "pattern");
50072
- 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";
50073
50107
  }
50074
- case "WebFetch":
50075
50108
  case "WebSearch": {
50076
- const query2 = readString(input, "url") ?? readString(input, "query");
50077
- 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";
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";
50078
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";
50079
50123
  default:
50080
- return toolName;
50081
- }
50082
- }
50083
- function formatPermissionCardBody(opts) {
50084
- const summary = summarizeToolForTitle(opts.toolName, opts.inputPreview);
50085
- const lines = [];
50086
- const agentBit = opts.agentName && opts.agentName.length > 0 ? `<b>${escapeTgHtml(opts.agentName)}</b> \u00b7 ` : "";
50087
- lines.push(`\uD83D\uDD10 ${agentBit}${escapeTgHtml(summary)}`);
50088
- const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
50089
- const truncatedWhy = rawWhy.length > DESCRIPTION_LINE_MAX ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "\u2026" : rawWhy;
50090
- if (truncatedWhy.length > 0) {
50091
- lines.push(`why: <i>${escapeTgHtml(truncatedWhy)}</i>`);
50092
- } else {
50093
- 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);
50094
50180
  }
50095
- return lines.join(`
50096
- `);
50097
50181
  }
50098
- function escapeTgHtml(text) {
50099
- 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);
50100
50184
  }
50101
- function firstScalarArgHint(input) {
50185
+ function fileBase(input) {
50102
50186
  if (!input)
50103
50187
  return null;
50104
- const SKIP = new Set([
50105
- "chat_id",
50106
- "chatId",
50107
- "message_thread_id",
50108
- "messageThreadId",
50109
- "request_id",
50110
- "requestId"
50111
- ]);
50112
- for (const [key, value] of Object.entries(input)) {
50113
- if (SKIP.has(key))
50114
- continue;
50115
- if (typeof value === "string" && value.length > 0) {
50116
- return `${key}: ${truncate6(value, INPUT_VALUE_MAX)}`;
50117
- }
50118
- if (typeof value === "number" || typeof value === "boolean") {
50119
- return `${key}: ${String(value)}`;
50120
- }
50121
- }
50122
- 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;");
50123
50199
  }
50124
50200
  function parseInput(raw) {
50125
50201
  if (!raw || typeof raw !== "string")
@@ -50145,8 +50221,8 @@ function skillBasenameFromPath(input) {
50145
50221
  return null;
50146
50222
  const trimmed = path.replace(/\/SKILL\.md$/i, "").replace(/\/$/, "");
50147
50223
  const lastSlash = trimmed.lastIndexOf("/");
50148
- const basename6 = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
50149
- return basename6.length > 0 ? basename6 : null;
50224
+ const base = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
50225
+ return base.length > 0 ? base : null;
50150
50226
  }
50151
50227
  function truncate6(text, max) {
50152
50228
  const collapsed = text.replace(/\s+/g, " ").trim();
@@ -50157,47 +50233,96 @@ function truncate6(text, max) {
50157
50233
 
50158
50234
  // permission-rule.ts
50159
50235
  import { basename as basename6 } from "node:path";
50160
- 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) {
50161
50254
  if (!toolName)
50162
50255
  return null;
50163
50256
  const input = parseInput2(inputPreview);
50164
- switch (toolName) {
50165
- case "Skill": {
50166
- if (!input)
50167
- return null;
50168
- const skill = readString2(input, "skill") ?? readString2(input, "skill_name") ?? readString2(input, "skillName") ?? readString2(input, "name") ?? skillBasenameFromPath2(input);
50169
- if (!skill)
50170
- return null;
50171
- if (!/^[A-Za-z0-9._\-+]+$/.test(skill))
50172
- 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) {
50173
50272
  return {
50174
- rule: `Skill(${skill})`,
50175
- label: `Skill(${skill})`
50273
+ specific: { rule: `${toolName}(${path})`, buttonLabel: "This file", broad: false },
50274
+ broad
50176
50275
  };
50177
50276
  }
50178
- case "Bash":
50179
- case "Read":
50180
- case "Write":
50181
- case "Edit":
50182
- case "MultiEdit":
50183
- case "NotebookEdit":
50184
- case "Glob":
50185
- case "Grep":
50186
- case "WebFetch":
50187
- case "WebSearch":
50188
- case "Task":
50189
- case "Agent":
50190
- case "TodoWrite":
50191
- case "ExitPlanMode": {
50192
- return { rule: toolName, label: toolName };
50193
- }
50194
- default: {
50195
- if (/^mcp__[A-Za-z0-9_\-]+(__[A-Za-z0-9_\-]+)?$/.test(toolName)) {
50196
- return { rule: toolName, label: toolName };
50197
- }
50198
- 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
+ };
50199
50288
  }
50289
+ return { broad };
50290
+ }
50291
+ if (BROAD_ONLY_TOOLS2.has(toolName)) {
50292
+ return { broad: { rule: toolName, buttonLabel: "Always allow", broad: true } };
50200
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;
50201
50326
  }
50202
50327
  function parseInput2(raw) {
50203
50328
  if (!raw || typeof raw !== "string")
@@ -50788,10 +50913,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
50788
50913
  }
50789
50914
 
50790
50915
  // ../src/build-info.ts
50791
- var VERSION = "0.14.11";
50792
- var COMMIT_SHA = "89d93911";
50793
- var COMMIT_DATE = "2026-05-29T09:38:43Z";
50794
- var LATEST_PR = 1991;
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;
50795
50920
  var COMMITS_AHEAD_OF_TAG = 0;
50796
50921
 
50797
50922
  // gateway/boot-version.ts
@@ -52791,6 +52916,12 @@ if (inboundSpool != null) {
52791
52916
  }
52792
52917
  }
52793
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
+ }
52794
52925
  function dispatchPermissionVerdict(ev) {
52795
52926
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
52796
52927
  const delivered = ipcServer.sendToAgent(selfAgent, ev);
@@ -53009,11 +53140,8 @@ var ipcServer = createIpcServer({
53009
53140
  description,
53010
53141
  agentName: _client.agentName
53011
53142
  });
53012
- const alwaysRule = resolveAlwaysAllowRule(toolName, inputPreview);
53013
- const keyboard = new import_grammy9.InlineKeyboard().text("See more", `perm:more:${requestId}`).text("\u2705 Allow", `perm:allow:${requestId}`).text("\u274C Deny", `perm:deny:${requestId}`);
53014
- if (alwaysRule != null) {
53015
- keyboard.row().text(`\uD83D\uDD01 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`);
53016
- }
53143
+ const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null;
53144
+ const keyboard = buildPermissionActionRow(requestId, showAlways);
53017
53145
  const activeTurn = currentTurn;
53018
53146
  const permTopic = resolveAgentOutboundTopic({
53019
53147
  kind: "permission",
@@ -59711,7 +59839,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
59711
59839
  }
59712
59840
  return;
59713
59841
  }
59714
- 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);
59715
59843
  if (!m) {
59716
59844
  await ctx.answerCallbackQuery().catch(() => {});
59717
59845
  return;
@@ -59723,45 +59851,43 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
59723
59851
  return;
59724
59852
  }
59725
59853
  const [, behavior, request_id] = m;
59726
- if (behavior === "more") {
59854
+ if (behavior === "always" || behavior === "back") {
59727
59855
  const details = pendingPermissions.get(request_id);
59728
59856
  if (!details) {
59729
59857
  await ctx.answerCallbackQuery({ text: "Details no longer available." }).catch(() => {});
59730
59858
  return;
59731
59859
  }
59732
- const { tool_name, description, input_preview } = details;
59733
- let prettyInput;
59734
- try {
59735
- prettyInput = JSON.stringify(JSON.parse(input_preview), null, 2);
59736
- } catch {
59737
- prettyInput = input_preview;
59738
- }
59739
- const expanded = `\uD83D\uDD10 Permission: ${tool_name}
59740
-
59741
- tool_name: ${tool_name}
59742
- description: ${description}
59743
- input_preview:
59744
- ${prettyInput}`;
59745
- const expandedRule = resolveAlwaysAllowRule(tool_name, input_preview);
59746
- const expandedKeyboard = new import_grammy9.InlineKeyboard().text("\u2705 Allow", `perm:allow:${request_id}`).text("\u274C Deny", `perm:deny:${request_id}`);
59747
- if (expandedRule != null) {
59748
- 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}`);
59749
59873
  }
59750
- await ctx.editMessageText(expanded, { reply_markup: expandedKeyboard }).catch(() => {});
59874
+ await ctx.editMessageReplyMarkup({ reply_markup: keyboard }).catch(() => {});
59751
59875
  await ctx.answerCallbackQuery().catch(() => {});
59752
59876
  return;
59753
59877
  }
59754
- if (behavior === "always") {
59878
+ if (behavior === "asn" || behavior === "asb") {
59755
59879
  const details = pendingPermissions.get(request_id);
59756
59880
  if (!details) {
59757
59881
  await ctx.answerCallbackQuery({ text: "Details no longer available." }).catch(() => {});
59758
59882
  return;
59759
59883
  }
59760
- const rule = resolveAlwaysAllowRule(details.tool_name, details.input_preview);
59761
- if (rule == null) {
59884
+ const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview);
59885
+ if (choices == null) {
59762
59886
  await ctx.answerCallbackQuery({ text: "Cannot synthesize an always-allow rule for this tool." }).catch(() => {});
59763
59887
  return;
59764
59888
  }
59889
+ const chosen = behavior === "asn" ? choices.specific ?? choices.broad : choices.broad;
59890
+ const grantPhrase = describeGrant(details.tool_name, details.input_preview, chosen);
59765
59891
  const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
59766
59892
  if (!agentName3) {
59767
59893
  await ctx.answerCallbackQuery({ text: "Always-allow needs SWITCHROOM_AGENT_NAME \u2014 gateway is misconfigured." }).catch(() => {});
@@ -59772,7 +59898,7 @@ ${prettyInput}`;
59772
59898
  type: "permission",
59773
59899
  requestId: request_id,
59774
59900
  behavior: "allow",
59775
- rule: rule.rule
59901
+ rule: chosen.rule
59776
59902
  });
59777
59903
  let durable = false;
59778
59904
  let legacy = false;
@@ -59783,26 +59909,26 @@ ${prettyInput}`;
59783
59909
  try {
59784
59910
  const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findConfigFile2();
59785
59911
  const raw = readFileSync36(cfgPath, "utf8");
59786
- return synthesizeAllowRuleDiff({ agentName: agentName3, rule: rule.rule, configText: raw });
59912
+ return synthesizeAllowRuleDiff({ agentName: agentName3, rule: chosen.rule, configText: raw });
59787
59913
  } catch (err) {
59788
59914
  process.stderr.write(`telegram gateway: always-allow diff synth failed: ${err.message}
59789
59915
  `);
59790
59916
  return null;
59791
59917
  }
59792
59918
  })();
59793
- const correlationKey = `${agentName3}::${rule.rule}`;
59919
+ const correlationKey = `${agentName3}::${chosen.rule}`;
59794
59920
  try {
59795
59921
  if (unifiedDiff == null) {
59796
59922
  legacy = true;
59797
59923
  } else {
59798
- 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() });
59799
59925
  const req = {
59800
59926
  v: 1,
59801
59927
  op: "config_propose_edit",
59802
59928
  request_id: hostdRequestId("gw-always-allow"),
59803
59929
  args: {
59804
59930
  unified_diff: unifiedDiff,
59805
- reason: `Operator 'always allow' for ${rule.label}`,
59931
+ reason: `Operator 'always allow': ${agentName3} can ${grantPhrase}`,
59806
59932
  target_path: "/state/config/switchroom.yaml"
59807
59933
  }
59808
59934
  };
@@ -59812,7 +59938,7 @@ ${prettyInput}`;
59812
59938
  legacy = true;
59813
59939
  } else if (resp.result === "completed") {
59814
59940
  durable = true;
59815
- 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})
59816
59942
  `);
59817
59943
  } else {
59818
59944
  failReason = resp.error ?? `hostd ${resp.result}`;
@@ -59824,19 +59950,19 @@ ${prettyInput}`;
59824
59950
  }
59825
59951
  if (legacy) {
59826
59952
  try {
59827
- switchroomExec(["agent", "grant", agentName3, rule.rule, "--no-restart"]);
59953
+ switchroomExec(["agent", "grant", agentName3, chosen.rule, "--no-restart"]);
59828
59954
  try {
59829
59955
  const cfg = loadConfig2();
59830
59956
  const rawAgent = cfg.agents?.[agentName3];
59831
59957
  if (rawAgent) {
59832
59958
  const resolved = resolveAgentConfig2(cfg.defaults, cfg.profiles, rawAgent);
59833
59959
  const allowList = resolved.tools?.allow ?? [];
59834
- if (isRulePersisted(allowList, rule.rule)) {
59960
+ if (isRulePersisted(allowList, chosen.rule)) {
59835
59961
  durable = true;
59836
- 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})
59837
59963
  `);
59838
59964
  } else {
59839
- 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`;
59840
59966
  process.stderr.write(`telegram gateway: always-allow VERIFY FAILED: ${failReason} (request_id=${request_id})
59841
59967
  `);
59842
59968
  }
@@ -59861,10 +59987,10 @@ ${prettyInput}`;
59861
59987
  }
59862
59988
  const ok = durable;
59863
59989
  const legacyNote = legacy && durable;
59864
- 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.`;
59865
59991
  const sourceMsg = ctx.callbackQuery?.message;
59866
59992
  const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
59867
- 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.`;
59868
59994
  await finalizeCallback(ctx, {
59869
59995
  ackText: ackText.slice(0, 200),
59870
59996
  newText: baseText2 ? `${baseText2}