switchroom 0.13.36 → 0.13.38

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.
@@ -23247,6 +23247,9 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
23247
23247
  if (existsSync12(`${hostHomeForChecks}/.switchroom/skills`)) {
23248
23248
  lines.push(` - ${homePrefix}/.switchroom/skills:${homePrefix}/.switchroom/skills:ro`);
23249
23249
  }
23250
+ if (existsSync12(`${hostHomeForChecks}/.switchroom/mcp-launchers`)) {
23251
+ lines.push(` - ${homePrefix}/.switchroom/mcp-launchers:${homePrefix}/.switchroom/mcp-launchers:ro`);
23252
+ }
23250
23253
  if (existsSync12(`${hostHomeForChecks}/.switchroom/credentials/${a.name}`)) {
23251
23254
  lines.push(` - ${homePrefix}/.switchroom/credentials/${a.name}:${homePrefix}/.switchroom/credentials:ro`);
23252
23255
  }
@@ -29681,6 +29684,7 @@ __export(exports_doctor, {
29681
29684
  deriveEd25519PublicKeyBytes: () => deriveEd25519PublicKeyBytes,
29682
29685
  classifyReadError: () => classifyReadError,
29683
29686
  checkVaultBrokerSocketPairs: () => checkVaultBrokerSocketPairs,
29687
+ checkUserDeclaredMcps: () => checkUserDeclaredMcps,
29684
29688
  checkTelegram: () => checkTelegram,
29685
29689
  checkStartShStale: () => checkStartShStale,
29686
29690
  checkSkillsPrerequisites: () => checkSkillsPrerequisites,
@@ -29978,6 +29982,33 @@ function checkConfig(config, configPath) {
29978
29982
  });
29979
29983
  return results;
29980
29984
  }
29985
+ function checkUserDeclaredMcps(name, agentConfig, config, renderedMcpServers) {
29986
+ const resolved = resolveAgentConfig(config.defaults, config.profiles, agentConfig);
29987
+ const declaredMcp = resolved.mcp_servers ?? {};
29988
+ const declaredKeys = Object.entries(declaredMcp).filter(([, v]) => v !== false).map(([k]) => k);
29989
+ const renderedKeys = Object.keys(renderedMcpServers);
29990
+ const missing = declaredKeys.filter((k) => !renderedKeys.includes(k));
29991
+ if (declaredKeys.length === 0) {
29992
+ return {
29993
+ name: `${name}: user-declared MCPs`,
29994
+ status: "skip",
29995
+ detail: "no user-declared mcp_servers in switchroom.yaml"
29996
+ };
29997
+ }
29998
+ if (missing.length === 0) {
29999
+ return {
30000
+ name: `${name}: user-declared MCPs`,
30001
+ status: "ok",
30002
+ detail: `${declaredKeys.length} declared, all in .mcp.json (${declaredKeys.join(", ")})`
30003
+ };
30004
+ }
30005
+ return {
30006
+ name: `${name}: user-declared MCPs`,
30007
+ status: "warn",
30008
+ detail: `${missing.length}/${declaredKeys.length} declared but missing from .mcp.json: ${missing.join(", ")}`,
30009
+ fix: `Run \`switchroom agent reconcile ${name} --restart\`. If the entry still doesn't appear, check switchroom.yaml shape (defaults.mcp_servers.<key> or agents.${name}.mcp_servers.<key>).`
30010
+ };
30011
+ }
29981
30012
  function checkLegacyState() {
29982
30013
  const results = [];
29983
30014
  const h = process.env.HOME ?? "/root";
@@ -30739,6 +30770,7 @@ function checkAgents(config, configPath) {
30739
30770
  detail: memoryEnabled ? "switchroom-telegram + hindsight" : "switchroom-telegram"
30740
30771
  });
30741
30772
  }
30773
+ results.push(checkUserDeclaredMcps(name, agentConfig, config, mcp.mcpServers ?? {}));
30742
30774
  } catch (err) {
30743
30775
  results.push({
30744
30776
  name: `${name}: .mcp.json`,
@@ -31354,6 +31386,7 @@ var init_doctor = __esm(() => {
31354
31386
  init_doctor_status();
31355
31387
  init_vault();
31356
31388
  init_loader();
31389
+ init_merge();
31357
31390
  init_paths();
31358
31391
  init_helpers();
31359
31392
  init_lifecycle();
@@ -47711,8 +47744,8 @@ var {
47711
47744
  } = import__.default;
47712
47745
 
47713
47746
  // src/build-info.ts
47714
- var VERSION = "0.13.36";
47715
- var COMMIT_SHA = "73e8bb05";
47747
+ var VERSION = "0.13.38";
47748
+ var COMMIT_SHA = "faca4736";
47716
47749
 
47717
47750
  // src/cli/agent.ts
47718
47751
  init_source();
@@ -49460,6 +49493,14 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
49460
49493
  mcpServers[gdrive.key] = gdrive.value;
49461
49494
  }
49462
49495
  }
49496
+ if (agentConfig.mcp_servers) {
49497
+ const filtered = filterMcpServers(agentConfig.mcp_servers);
49498
+ if (filtered) {
49499
+ for (const [key, value] of Object.entries(filtered)) {
49500
+ mcpServers[key] = value;
49501
+ }
49502
+ }
49503
+ }
49463
49504
  writeIfChanged(mcpJsonPath, () => JSON.stringify({ mcpServers }, null, 2) + `
49464
49505
  `, created, skipped, 384);
49465
49506
  mcpServerKeysToTrust = Object.keys(mcpServers);
@@ -50504,6 +50545,14 @@ ${body}
50504
50545
  mcpServers[gdrive.key] = gdrive.value;
50505
50546
  }
50506
50547
  }
50548
+ if (agentConfig.mcp_servers) {
50549
+ const filtered = filterMcpServers(agentConfig.mcp_servers);
50550
+ if (filtered) {
50551
+ for (const [key, value] of Object.entries(filtered)) {
50552
+ mcpServers[key] = value;
50553
+ }
50554
+ }
50555
+ }
50507
50556
  const mcpJson = { mcpServers };
50508
50557
  const after = JSON.stringify(mcpJson, null, 2) + `
50509
50558
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.36",
3
+ "version": "0.13.38",
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": {
@@ -431,7 +431,7 @@ const TOOL_SCHEMAS = [
431
431
  chat_id: { type: 'string', description: 'Chat to render the approval card in (use the chat_id of the user message that triggered the workflow).' },
432
432
  key: { type: 'string', description: 'Vault key the agent wants access to (matches the key shown in the VAULT-BROKER-DENIED error, e.g. `fatsecret/credentials`).' },
433
433
  scope: { type: 'string', enum: ['read', 'write'], description: 'Access scope: "read" (default) for `vault:<key>` references; "write" if the agent needs to put new values.' },
434
- reason: { type: 'string', description: 'Short human-readable rationale rendered on the card (e.g. "to look up today\'s food log entries"). Helps the operator decide.' },
434
+ reason: { type: 'string', description: 'REQUIRED in practice — short human-readable rationale rendered on the card (e.g. "to look up today\'s food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation — they will usually Deny. Always supply a one-line rationale.' },
435
435
  duration: { type: 'string', description: 'Requested grant TTL, like "30d" or "12h". Default 30d, capped at 90d. Beyond 90d the operator should use the host CLI explicitly.' },
436
436
  message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message if not specified.' },
437
437
  },
@@ -24797,7 +24797,7 @@ var TOOL_SCHEMAS = [
24797
24797
  chat_id: { type: "string", description: "Chat to render the approval card in (use the chat_id of the user message that triggered the workflow)." },
24798
24798
  key: { type: "string", description: "Vault key the agent wants access to (matches the key shown in the VAULT-BROKER-DENIED error, e.g. `fatsecret/credentials`)." },
24799
24799
  scope: { type: "string", enum: ["read", "write"], description: 'Access scope: "read" (default) for `vault:<key>` references; "write" if the agent needs to put new values.' },
24800
- reason: { type: "string", description: `Short human-readable rationale rendered on the card (e.g. "to look up today's food log entries"). Helps the operator decide.` },
24800
+ reason: { type: "string", description: `REQUIRED in practice \u2014 short human-readable rationale rendered on the card (e.g. "to look up today's food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation \u2014 they will usually Deny. Always supply a one-line rationale.` },
24801
24801
  duration: { type: "string", description: 'Requested grant TTL, like "30d" or "12h". Default 30d, capped at 90d. Beyond 90d the operator should use the host CLI explicitly.' },
24802
24802
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
24803
24803
  },
@@ -48305,6 +48305,8 @@ function defaultReadEvents(stateDir) {
48305
48305
  import { basename as basename5 } from "node:path";
48306
48306
  var COMMAND_TITLE_MAX = 40;
48307
48307
  var PATH_TITLE_MAX = 40;
48308
+ var DESCRIPTION_LINE_MAX = 240;
48309
+ var INPUT_VALUE_MAX = 60;
48308
48310
  var MCP_TOOL_DESCRIPTIONS = {
48309
48311
  "mcp__agent-config__config_get": "Read its own merged config",
48310
48312
  "mcp__agent-config__cron_list": "List its own scheduled tasks",
@@ -48329,15 +48331,17 @@ var MCP_TOOL_DESCRIPTIONS = {
48329
48331
  function summarizeToolForTitle(toolName, inputPreview) {
48330
48332
  if (toolName.startsWith("mcp__")) {
48331
48333
  const curated = MCP_TOOL_DESCRIPTIONS[toolName];
48332
- if (curated)
48333
- return curated;
48334
- const parts = toolName.split("__");
48335
- if (parts.length >= 3) {
48336
- const server = parts[1];
48337
- const verb = parts.slice(2).join("__").replace(/_/g, " ");
48338
- return `${server}: ${verb}`;
48339
- }
48340
- return toolName;
48334
+ const base = curated ? curated : (() => {
48335
+ const parts = toolName.split("__");
48336
+ if (parts.length >= 3) {
48337
+ const server = parts[1];
48338
+ const verb = parts.slice(2).join("__").replace(/_/g, " ");
48339
+ return `${server}: ${verb}`;
48340
+ }
48341
+ return toolName;
48342
+ })();
48343
+ const argHint = firstScalarArgHint(parseInput(inputPreview));
48344
+ return argHint ? `${base} (${argHint})` : base;
48341
48345
  }
48342
48346
  const input = parseInput(inputPreview);
48343
48347
  if (!input)
@@ -48345,7 +48349,13 @@ function summarizeToolForTitle(toolName, inputPreview) {
48345
48349
  switch (toolName) {
48346
48350
  case "Skill": {
48347
48351
  const skill = readString(input, "skill") ?? readString(input, "skill_name") ?? readString(input, "skillName") ?? readString(input, "name") ?? skillBasenameFromPath(input);
48348
- return skill ? `${toolName} (${skill})` : toolName;
48352
+ if (skill)
48353
+ return `${toolName} (${skill})`;
48354
+ const command = readString(input, "command");
48355
+ if (command)
48356
+ return `${toolName}: ${truncate5(command, COMMAND_TITLE_MAX)}`;
48357
+ const argHint = firstScalarArgHint(input);
48358
+ return argHint ? `${toolName} (${argHint})` : toolName;
48349
48359
  }
48350
48360
  case "Bash": {
48351
48361
  const command = readString(input, "command");
@@ -48373,6 +48383,47 @@ function summarizeToolForTitle(toolName, inputPreview) {
48373
48383
  return toolName;
48374
48384
  }
48375
48385
  }
48386
+ function formatPermissionCardBody(opts) {
48387
+ const summary = summarizeToolForTitle(opts.toolName, opts.inputPreview);
48388
+ const lines = [];
48389
+ const agentBit = opts.agentName && opts.agentName.length > 0 ? `<b>${escapeTgHtml(opts.agentName)}</b> \u00b7 ` : "";
48390
+ lines.push(`\uD83D\uDD10 ${agentBit}${escapeTgHtml(summary)}`);
48391
+ const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
48392
+ const truncatedWhy = rawWhy.length > DESCRIPTION_LINE_MAX ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "\u2026" : rawWhy;
48393
+ if (truncatedWhy.length > 0) {
48394
+ lines.push(`why: <i>${escapeTgHtml(truncatedWhy)}</i>`);
48395
+ } else {
48396
+ lines.push(`why: <i>not provided</i>`);
48397
+ }
48398
+ return lines.join(`
48399
+ `);
48400
+ }
48401
+ function escapeTgHtml(text) {
48402
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
48403
+ }
48404
+ function firstScalarArgHint(input) {
48405
+ if (!input)
48406
+ return null;
48407
+ const SKIP = new Set([
48408
+ "chat_id",
48409
+ "chatId",
48410
+ "message_thread_id",
48411
+ "messageThreadId",
48412
+ "request_id",
48413
+ "requestId"
48414
+ ]);
48415
+ for (const [key, value] of Object.entries(input)) {
48416
+ if (SKIP.has(key))
48417
+ continue;
48418
+ if (typeof value === "string" && value.length > 0) {
48419
+ return `${key}: ${truncate5(value, INPUT_VALUE_MAX)}`;
48420
+ }
48421
+ if (typeof value === "number" || typeof value === "boolean") {
48422
+ return `${key}: ${String(value)}`;
48423
+ }
48424
+ }
48425
+ return null;
48426
+ }
48376
48427
  function parseInput(raw) {
48377
48428
  if (!raw || typeof raw !== "string")
48378
48429
  return null;
@@ -48671,10 +48722,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48671
48722
  }
48672
48723
 
48673
48724
  // ../src/build-info.ts
48674
- var VERSION = "0.13.36";
48675
- var COMMIT_SHA = "73e8bb05";
48676
- var COMMIT_DATE = "2026-05-25T03:53:49Z";
48677
- var LATEST_PR = 1785;
48725
+ var VERSION = "0.13.38";
48726
+ var COMMIT_SHA = "faca4736";
48727
+ var COMMIT_DATE = "2026-05-25T06:37:08Z";
48728
+ var LATEST_PR = 1795;
48678
48729
  var COMMITS_AHEAD_OF_TAG = 0;
48679
48730
 
48680
48731
  // gateway/boot-version.ts
@@ -50838,14 +50889,22 @@ ${reminder}
50838
50889
  const { requestId, toolName, description, inputPreview } = msg;
50839
50890
  pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() });
50840
50891
  const access = loadAccess();
50841
- const text = `\uD83D\uDD10 Permission: ${summarizeToolForTitle(toolName, inputPreview)}`;
50892
+ const text = formatPermissionCardBody({
50893
+ toolName,
50894
+ inputPreview,
50895
+ description,
50896
+ agentName: _client.agentName
50897
+ });
50842
50898
  const alwaysRule = resolveAlwaysAllowRule(toolName, inputPreview);
50843
50899
  const keyboard = new import_grammy9.InlineKeyboard().text("See more", `perm:more:${requestId}`).text("\u2705 Allow", `perm:allow:${requestId}`).text("\u274C Deny", `perm:deny:${requestId}`);
50844
50900
  if (alwaysRule != null) {
50845
50901
  keyboard.row().text(`\uD83D\uDD01 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`);
50846
50902
  }
50847
50903
  for (const chat_id of access.allowFrom) {
50848
- bot.api.sendMessage(chat_id, text, { reply_markup: keyboard }).catch((e) => {
50904
+ bot.api.sendMessage(chat_id, text, {
50905
+ parse_mode: "HTML",
50906
+ reply_markup: keyboard
50907
+ }).catch((e) => {
50849
50908
  process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}
50850
50909
  `);
50851
50910
  });
@@ -52130,6 +52189,8 @@ function renderVaultRequestAccessCard(req) {
52130
52189
  lines.push(`scope: <code>${scopeLabel}</code> \xB7 duration: <code>${durationLabel}</code>`);
52131
52190
  if (req.reason && req.reason.length > 0) {
52132
52191
  lines.push(`why: <i>${escapeHtmlForTg(req.reason)}</i>`);
52192
+ } else {
52193
+ lines.push(`why: <i>not provided</i>`);
52133
52194
  }
52134
52195
  lines.push("");
52135
52196
  lines.push(`<i>Tap Approve to mint a scoped grant token (same flow as <code>switchroom vault grant</code>). Tap Deny to refuse \u2014 the agent will receive a denial result.</i>`);
@@ -24492,7 +24492,7 @@ var init_bridge = __esm(async () => {
24492
24492
  chat_id: { type: "string", description: "Chat to render the approval card in (use the chat_id of the user message that triggered the workflow)." },
24493
24493
  key: { type: "string", description: "Vault key the agent wants access to (matches the key shown in the VAULT-BROKER-DENIED error, e.g. `fatsecret/credentials`)." },
24494
24494
  scope: { type: "string", enum: ["read", "write"], description: 'Access scope: "read" (default) for `vault:<key>` references; "write" if the agent needs to put new values.' },
24495
- reason: { type: "string", description: `Short human-readable rationale rendered on the card (e.g. "to look up today's food log entries"). Helps the operator decide.` },
24495
+ reason: { type: "string", description: `REQUIRED in practice \u2014 short human-readable rationale rendered on the card (e.g. "to look up today's food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation \u2014 they will usually Deny. Always supply a one-line rationale.` },
24496
24496
  duration: { type: "string", description: 'Requested grant TTL, like "30d" or "12h". Default 30d, capped at 90d. Beyond 90d the operator should use the host CLI explicitly.' },
24497
24497
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
24498
24498
  },
@@ -356,7 +356,7 @@ import { maybeRenderUpdateAnnouncement } from './update-announce.js'
356
356
  import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
357
357
  import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
358
358
  import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
359
- import { summarizeToolForTitle } from '../permission-title.js'
359
+ import { summarizeToolForTitle, formatPermissionCardBody } from '../permission-title.js'
360
360
  import { resolveAlwaysAllowRule } from '../permission-rule.js'
361
361
  import {
362
362
  readClaudeJsonOverage,
@@ -3863,10 +3863,18 @@ const ipcServer: IpcServer = createIpcServer({
3863
3863
  const { requestId, toolName, description, inputPreview } = msg
3864
3864
  pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
3865
3865
  const access = loadAccess()
3866
- // Lift the most-identifying field into the title so the user can
3867
- // approve at a glance e.g. `Skill (mail)` instead of bare `Skill`.
3868
- // See #186.
3869
- const text = `🔐 Permission: ${summarizeToolForTitle(toolName, inputPreview)}`
3866
+ // #1790 multi-line collapsed body so the operator can see what
3867
+ // is being requested and why without tapping "See more". Mirrors
3868
+ // the `vault_request_access` card layout (the gold standard).
3869
+ // The detail (expanded `tool_name` / pretty `input_preview`)
3870
+ // still surfaces on the See-more tap; this is the
3871
+ // collapsed-view fix only. Sent with parse_mode=HTML below.
3872
+ const text = formatPermissionCardBody({
3873
+ toolName,
3874
+ inputPreview,
3875
+ description,
3876
+ agentName: _client.agentName,
3877
+ })
3870
3878
  // Build the keyboard. The "🔁 Always" button only appears when we
3871
3879
  // can synthesize a meaningful allow-rule for this tool — for an
3872
3880
  // unknown tool we'd write a useless rule (or worse, a rule that
@@ -3887,8 +3895,13 @@ const ipcServer: IpcServer = createIpcServer({
3887
3895
  .text(`🔁 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`)
3888
3896
  }
3889
3897
  for (const chat_id of access.allowFrom) {
3890
- // allow-raw-bot-api: permission-request keyboard fan-out; reply_markup-only opts, no thread_id
3891
- void bot.api.sendMessage(chat_id, text, { reply_markup: keyboard }).catch(e => {
3898
+ // parse_mode=HTML pairs with formatPermissionCardBody (#1790)
3899
+ // so the <b>/<i> tags render as formatting.
3900
+ // allow-raw-bot-api: permission-request keyboard fan-out; reply_markup + parse_mode only, no thread_id
3901
+ void bot.api.sendMessage(chat_id, text, {
3902
+ parse_mode: 'HTML',
3903
+ reply_markup: keyboard,
3904
+ }).catch(e => {
3892
3905
  process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
3893
3906
  })
3894
3907
  }
@@ -5998,8 +6011,17 @@ function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
5998
6011
  lines.push(`🔐 <b>${escapeHtmlForTg(req.agent)}</b> wants vault access`)
5999
6012
  lines.push(`key: <code>${escapeHtmlForTg(req.key)}</code>`)
6000
6013
  lines.push(`scope: <code>${scopeLabel}</code> · duration: <code>${durationLabel}</code>`)
6014
+ // #1790 — always render the why-line, even when the agent omitted
6015
+ // `reason`. Rendering "not provided" makes a missing rationale
6016
+ // visibly an agent-side failure (the tool description nudges the
6017
+ // model to supply one — see executeVaultRequestAccess); skipping
6018
+ // the line silently used to make the omission look like a card-
6019
+ // template choice, which the operator couldn't tell apart from a
6020
+ // legitimate "no reason needed" case.
6001
6021
  if (req.reason && req.reason.length > 0) {
6002
6022
  lines.push(`why: <i>${escapeHtmlForTg(req.reason)}</i>`)
6023
+ } else {
6024
+ lines.push(`why: <i>not provided</i>`)
6003
6025
  }
6004
6026
  lines.push('')
6005
6027
  lines.push(`<i>Tap Approve to mint a scoped grant token (same flow as <code>switchroom vault grant</code>). Tap Deny to refuse — the agent will receive a denial result.</i>`)
@@ -1,21 +1,32 @@
1
1
  /**
2
- * Build a human-readable title for the inline-keyboard permission
3
- * approval message. Pre-fix the title was always `🔐 Permission:
4
- * ${toolName}` — for a `Skill` or `Bash` call the user couldn't tell
5
- * which skill / command was being approved without tapping "See more".
2
+ * Build the inline-keyboard permission approval message title + body.
6
3
  *
7
- * The detail surfaces (the expanded view at server.ts/gateway.ts) still
8
- * render the full description + input_preview block; this helper just
9
- * lifts the most identifying field into the title so the user can
10
- * approve at a glance.
4
+ * Two related concerns:
11
5
  *
12
- * See #186.
6
+ * `summarizeToolForTitle` (one line, no escaping) is the bare summary
7
+ * used in the always-allow rule label and as the body-builder's
8
+ * internal building block. Pre-#186 the title was always `🔐
9
+ * Permission: ${toolName}` — for a `Skill` or `Bash` call the user
10
+ * couldn't tell which skill / command was being approved without
11
+ * tapping "See more".
12
+ *
13
+ * `formatPermissionCardBody` (multi-line, HTML-escaped for
14
+ * parse_mode=HTML) is the body of the card itself. Pre-#1790 the
15
+ * collapsed card was a single line — operators had to tap "See more"
16
+ * to see the agent's stated reason or input preview. This mirrors
17
+ * the vault `vault_request_access` card's three-line layout (the
18
+ * gold standard) so every approval surface answers "what" + "why"
19
+ * without an expand tap.
20
+ *
21
+ * See #186 (title) and #1790 (body).
13
22
  */
14
23
 
15
24
  import { basename } from "node:path";
16
25
 
17
26
  const COMMAND_TITLE_MAX = 40;
18
27
  const PATH_TITLE_MAX = 40;
28
+ const DESCRIPTION_LINE_MAX = 240;
29
+ const INPUT_VALUE_MAX = 60;
19
30
 
20
31
  /**
21
32
  * Human-friendly descriptions for switchroom-managed MCP tools. The
@@ -70,17 +81,26 @@ export function summarizeToolForTitle(
70
81
  // description (so the card reads "Read its own merged config"
71
82
  // instead of "mcp__agent-config__config_get"). Fall through to a
72
83
  // generic `<server>: <verb-with-spaces>` shape for unknown MCP
73
- // tools and finally to the raw name when even that fails.
84
+ // tools and finally to the raw name when even that fails. When
85
+ // we have an input preview, append the first arg-value pair so
86
+ // the operator sees what's being requested without expanding —
87
+ // e.g. `Read its own merged config (key: coolify/api-token)`
88
+ // rather than just `Read its own merged config`. (#1790)
74
89
  if (toolName.startsWith("mcp__")) {
75
90
  const curated = MCP_TOOL_DESCRIPTIONS[toolName];
76
- if (curated) return curated;
77
- const parts = toolName.split("__");
78
- if (parts.length >= 3) {
79
- const server = parts[1]!;
80
- const verb = parts.slice(2).join("__").replace(/_/g, " ");
81
- return `${server}: ${verb}`;
82
- }
83
- return toolName;
91
+ const base = curated
92
+ ? curated
93
+ : (() => {
94
+ const parts = toolName.split("__");
95
+ if (parts.length >= 3) {
96
+ const server = parts[1]!;
97
+ const verb = parts.slice(2).join("__").replace(/_/g, " ");
98
+ return `${server}: ${verb}`;
99
+ }
100
+ return toolName;
101
+ })();
102
+ const argHint = firstScalarArgHint(parseInput(inputPreview));
103
+ return argHint ? `${base} (${argHint})` : base;
84
104
  }
85
105
 
86
106
  const input = parseInput(inputPreview);
@@ -90,17 +110,26 @@ export function summarizeToolForTitle(
90
110
  case "Skill": {
91
111
  // Claude Code's Skill tool input shape has shifted across versions
92
112
  // and skill flavours. Read defensively from every known field
93
- // before falling back to the bare tool name the user reported
94
- // a popup that rendered as `🔐 Permission: Skill` (no brackets)
95
- // because we'd only checked `skill`. The skill name is the most
96
- // identifying field of the prompt; never drop it silently.
113
+ // before falling back. The skill name is the most identifying
114
+ // field of the prompt; never drop it silently.
115
+ //
116
+ // (#1790) Final fallback added: when no skill-name key matches,
117
+ // try `command` (some Skill variants pass the invocation under
118
+ // that key), then the first scalar arg-value pair. Pre-fix the
119
+ // default returned a bare `Skill` with zero context — operators
120
+ // saw "🔐 Permission: Skill" with no way to tell what was being
121
+ // asked without tapping See more.
97
122
  const skill =
98
123
  readString(input, "skill") ??
99
124
  readString(input, "skill_name") ??
100
125
  readString(input, "skillName") ??
101
126
  readString(input, "name") ??
102
127
  skillBasenameFromPath(input);
103
- return skill ? `${toolName} (${skill})` : toolName;
128
+ if (skill) return `${toolName} (${skill})`;
129
+ const command = readString(input, "command");
130
+ if (command) return `${toolName}: ${truncate(command, COMMAND_TITLE_MAX)}`;
131
+ const argHint = firstScalarArgHint(input);
132
+ return argHint ? `${toolName} (${argHint})` : toolName;
104
133
  }
105
134
  case "Bash": {
106
135
  const command = readString(input, "command");
@@ -129,6 +158,108 @@ export function summarizeToolForTitle(
129
158
  }
130
159
  }
131
160
 
161
+ /**
162
+ * Build the multi-line collapsed body of an approval card (#1790).
163
+ *
164
+ * Pre-fix the card was a single line — `🔐 Permission: <title>` —
165
+ * and the agent's stated `description` plus the input preview only
166
+ * surfaced when the operator tapped "See more". For skill / generic
167
+ * tool prompts the title alone (e.g. `Skill (mail)`) is rarely
168
+ * enough to approve at a glance; the operator needs to see *why*
169
+ * before they tap Allow / Deny.
170
+ *
171
+ * Layout mirrors the `vault_request_access` card (the gold standard):
172
+ *
173
+ * 🔐 <agent> · <tool summary>
174
+ * why: <description-or-"not provided">
175
+ *
176
+ * The agent line is dropped when `agentName` is null (the
177
+ * gateway's bridge client may be anonymous during early-boot edge
178
+ * cases — better to render the title than a misleading blank).
179
+ *
180
+ * Output is HTML-escaped and intended for `parse_mode: 'HTML'`
181
+ * via Telegram's Bot API.
182
+ */
183
+ export function formatPermissionCardBody(opts: {
184
+ toolName: string;
185
+ inputPreview: string | undefined;
186
+ description: string | undefined;
187
+ agentName: string | null;
188
+ }): string {
189
+ const summary = summarizeToolForTitle(opts.toolName, opts.inputPreview);
190
+ const lines: string[] = [];
191
+
192
+ const agentBit = opts.agentName && opts.agentName.length > 0
193
+ ? `<b>${escapeTgHtml(opts.agentName)}</b> · `
194
+ : "";
195
+ lines.push(`🔐 ${agentBit}${escapeTgHtml(summary)}`);
196
+
197
+ // The agent's stated reason. Always render the line — when the
198
+ // agent omitted a `description`, render an explicit
199
+ // `why: <i>not provided</i>` rather than skip silently, so the
200
+ // missing-rationale is visible as an agent-side failure (matches
201
+ // the vault card's #1790 treatment of an omitted `reason`).
202
+ const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
203
+ const truncatedWhy =
204
+ rawWhy.length > DESCRIPTION_LINE_MAX
205
+ ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "…"
206
+ : rawWhy;
207
+ if (truncatedWhy.length > 0) {
208
+ lines.push(`why: <i>${escapeTgHtml(truncatedWhy)}</i>`);
209
+ } else {
210
+ lines.push(`why: <i>not provided</i>`);
211
+ }
212
+
213
+ return lines.join("\n");
214
+ }
215
+
216
+ /**
217
+ * Minimal HTML escape for Telegram `parse_mode=HTML`. Mirrors
218
+ * `escapeHtmlForTg` in gateway.ts; duplicated here to keep
219
+ * permission-title.ts free of a gateway import (the file is
220
+ * referenced by both server.ts and gateway.ts).
221
+ */
222
+ function escapeTgHtml(text: string): string {
223
+ return text
224
+ .replace(/&/g, "&amp;")
225
+ .replace(/</g, "&lt;")
226
+ .replace(/>/g, "&gt;");
227
+ }
228
+
229
+ /**
230
+ * Return a `key: value` hint for the first scalar (string / number /
231
+ * boolean) arg in the input preview. Used as a last-ditch context
232
+ * line on uncurated MCP tools and Skill calls whose canonical
233
+ * skill-name fields are all missing.
234
+ *
235
+ * Skips obviously-routing keys (`chat_id`, `message_thread_id`,
236
+ * `request_id`) that aren't useful to a human operator deciding
237
+ * whether to approve. Returns `null` when nothing scalar remains.
238
+ */
239
+ function firstScalarArgHint(
240
+ input: Record<string, unknown> | null,
241
+ ): string | null {
242
+ if (!input) return null;
243
+ const SKIP = new Set([
244
+ "chat_id",
245
+ "chatId",
246
+ "message_thread_id",
247
+ "messageThreadId",
248
+ "request_id",
249
+ "requestId",
250
+ ]);
251
+ for (const [key, value] of Object.entries(input)) {
252
+ if (SKIP.has(key)) continue;
253
+ if (typeof value === "string" && value.length > 0) {
254
+ return `${key}: ${truncate(value, INPUT_VALUE_MAX)}`;
255
+ }
256
+ if (typeof value === "number" || typeof value === "boolean") {
257
+ return `${key}: ${String(value)}`;
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
132
263
  function parseInput(raw: string | undefined): Record<string, unknown> | null {
133
264
  if (!raw || typeof raw !== "string") return null;
134
265
  const trimmed = raw.trim();
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from 'vitest'
2
- import { summarizeToolForTitle } from '../permission-title.js'
2
+ import { summarizeToolForTitle, formatPermissionCardBody } from '../permission-title.js'
3
3
 
4
4
  describe('summarizeToolForTitle (#186)', () => {
5
5
  test('Skill: surfaces the skill name in brackets', () => {
@@ -49,12 +49,37 @@ describe('summarizeToolForTitle (#186)', () => {
49
49
  expect(summarizeToolForTitle('Skill', undefined)).toBe('Skill')
50
50
  })
51
51
 
52
- test('falls back to bare toolName when expected key is missing', () => {
52
+ test('falls back to bare toolName for non-Skill tools when expected key is missing', () => {
53
53
  const input = JSON.stringify({ unrelated: 'x' })
54
- expect(summarizeToolForTitle('Skill', input)).toBe('Skill')
54
+ // Bash has no first-arg fallback (its only identifying field is command).
55
55
  expect(summarizeToolForTitle('Bash', input)).toBe('Bash')
56
56
  })
57
57
 
58
+ // #1790 — the prior contract was "fall back to bare toolName when no
59
+ // skill-name key matched"; that produced operator-hostile cards like
60
+ // `🔐 Permission: Skill` with zero context. The Skill summarizer now
61
+ // tries `command`, then a first-scalar-arg hint, before giving up.
62
+ test('Skill: when no skill-name key matches, falls back to command field (#1790)', () => {
63
+ const input = JSON.stringify({ command: 'gen calendar event' })
64
+ expect(summarizeToolForTitle('Skill', input)).toBe('Skill: gen calendar event')
65
+ })
66
+
67
+ test('Skill: when no skill-name and no command, surfaces the first scalar arg (#1790)', () => {
68
+ const input = JSON.stringify({ unrelated: 'x' })
69
+ expect(summarizeToolForTitle('Skill', input)).toBe('Skill (unrelated: x)')
70
+ })
71
+
72
+ test('Skill: skips routing-only keys when surfacing first scalar arg (#1790)', () => {
73
+ // chat_id / message_thread_id / request_id never help an operator
74
+ // decide; the helper skips them and finds the next useful field.
75
+ const input = JSON.stringify({
76
+ chat_id: '12345',
77
+ message_thread_id: '42',
78
+ topic: 'morning summary',
79
+ })
80
+ expect(summarizeToolForTitle('Skill', input)).toBe('Skill (topic: morning summary)')
81
+ })
82
+
58
83
  test('Bash: collapses internal whitespace before truncating', () => {
59
84
  const input = JSON.stringify({
60
85
  command: 'echo \t hello\nworld',
@@ -134,4 +159,123 @@ describe('summarizeToolForTitle (#186)', () => {
134
159
  test('MCP malformed: bare mcp__ prefix without __<server>__<verb> shape is left alone', () => {
135
160
  expect(summarizeToolForTitle('mcp__bad', undefined)).toBe('mcp__bad')
136
161
  })
162
+
163
+ // #1790 — append a `(key: value)` hint when an MCP tool's preview
164
+ // carries a scalar arg. Gives operators context on curated and
165
+ // uncurated MCP tools alike without an expand tap.
166
+ test('MCP curated tool appends first-arg hint when input_preview present (#1790)', () => {
167
+ const input = JSON.stringify({ key: 'coolify/api-token' })
168
+ expect(summarizeToolForTitle('mcp__agent-config__config_get', input)).toBe(
169
+ 'Read its own merged config (key: coolify/api-token)',
170
+ )
171
+ })
172
+
173
+ test('MCP uncurated tool appends first-arg hint (#1790)', () => {
174
+ const input = JSON.stringify({ folder_id: 'abc123' })
175
+ expect(summarizeToolForTitle('mcp__google-workspace__list_files', input)).toBe(
176
+ 'google-workspace: list files (folder_id: abc123)',
177
+ )
178
+ })
179
+
180
+ test('MCP arg hint skips routing-only keys (#1790)', () => {
181
+ const input = JSON.stringify({ chat_id: '12345', query: 'budget Q3' })
182
+ expect(summarizeToolForTitle('mcp__hindsight__recall', input)).toBe(
183
+ 'Recall relevant memories (query: budget Q3)',
184
+ )
185
+ })
186
+ })
187
+
188
+ // ──────────────────────────────────────────────────────────────────────
189
+ // #1790 — formatPermissionCardBody: multi-line collapsed-view body
190
+ // for approval cards. Mirrors the vault_request_access card layout.
191
+ // ──────────────────────────────────────────────────────────────────────
192
+
193
+ describe('formatPermissionCardBody (#1790)', () => {
194
+ test('renders agent · summary, then a why-line, when both are present', () => {
195
+ const body = formatPermissionCardBody({
196
+ toolName: 'Skill',
197
+ inputPreview: JSON.stringify({ skill: 'mail' }),
198
+ description: 'Compose the morning brief',
199
+ agentName: 'clerk',
200
+ })
201
+ expect(body).toBe(
202
+ [
203
+ '🔐 <b>clerk</b> · Skill (mail)',
204
+ 'why: <i>Compose the morning brief</i>',
205
+ ].join('\n'),
206
+ )
207
+ })
208
+
209
+ test('renders "why: <i>not provided</i>" when description is missing', () => {
210
+ const body = formatPermissionCardBody({
211
+ toolName: 'Bash',
212
+ inputPreview: JSON.stringify({ command: 'ls /tmp' }),
213
+ description: undefined,
214
+ agentName: 'gymbro',
215
+ })
216
+ expect(body).toBe(
217
+ ['🔐 <b>gymbro</b> · Bash: ls /tmp', 'why: <i>not provided</i>'].join('\n'),
218
+ )
219
+ })
220
+
221
+ test('renders "not provided" when description is whitespace-only', () => {
222
+ const body = formatPermissionCardBody({
223
+ toolName: 'Bash',
224
+ inputPreview: JSON.stringify({ command: 'ls /tmp' }),
225
+ description: ' \n ',
226
+ agentName: 'gymbro',
227
+ })
228
+ expect(body).toContain('why: <i>not provided</i>')
229
+ })
230
+
231
+ test('drops the agent prefix when agentName is null (early-boot edge)', () => {
232
+ const body = formatPermissionCardBody({
233
+ toolName: 'Skill',
234
+ inputPreview: JSON.stringify({ skill: 'mail' }),
235
+ description: 'do the thing',
236
+ agentName: null,
237
+ })
238
+ expect(body).toBe(['🔐 Skill (mail)', 'why: <i>do the thing</i>'].join('\n'))
239
+ })
240
+
241
+ test('HTML-escapes < > & in agentName / summary / description', () => {
242
+ const body = formatPermissionCardBody({
243
+ toolName: 'Bash',
244
+ inputPreview: JSON.stringify({ command: 'echo "a < b && c > d"' }),
245
+ description: 'compare a < b & c > d',
246
+ agentName: 'agent<test>',
247
+ })
248
+ expect(body).toContain('&lt;test&gt;')
249
+ expect(body).toContain('&amp;')
250
+ expect(body).not.toContain('<test>')
251
+ // The literal "<i>not provided</i>" and "<b>...</b>" wrapping tags
252
+ // around legitimate fields must survive untouched — only the
253
+ // user-supplied content is escaped.
254
+ expect(body).toContain('<b>')
255
+ expect(body).toContain('<i>')
256
+ })
257
+
258
+ test('truncates a very long description with an ellipsis', () => {
259
+ const longWhy = 'x'.repeat(500)
260
+ const body = formatPermissionCardBody({
261
+ toolName: 'Skill',
262
+ inputPreview: JSON.stringify({ skill: 'mail' }),
263
+ description: longWhy,
264
+ agentName: 'clerk',
265
+ })
266
+ // 240-char ceiling + trailing ellipsis
267
+ expect(body).toContain('xxxx…</i>')
268
+ // First line still intact
269
+ expect(body.split('\n')[0]).toBe('🔐 <b>clerk</b> · Skill (mail)')
270
+ })
271
+
272
+ test('collapses internal whitespace in description so the layout stays one-line', () => {
273
+ const body = formatPermissionCardBody({
274
+ toolName: 'Skill',
275
+ inputPreview: JSON.stringify({ skill: 'mail' }),
276
+ description: 'first\n\nsecond\t\t paragraph',
277
+ agentName: 'clerk',
278
+ })
279
+ expect(body).toContain('why: <i>first second paragraph</i>')
280
+ })
137
281
  })