switchroom 0.14.12 → 0.14.14
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.
- package/dist/cli/switchroom.js +13 -11
- package/dist/host-control/main.js +80 -6
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +17 -3
- package/telegram-plugin/dist/bridge/bridge.js +61 -8
- package/telegram-plugin/dist/gateway/gateway.js +283 -161
- package/telegram-plugin/dist/server.js +64 -9
- package/telegram-plugin/gateway/gateway.ts +78 -66
- package/telegram-plugin/gateway/ipc-protocol.ts +4 -2
- package/telegram-plugin/permission-rule.ts +200 -122
- package/telegram-plugin/permission-title.ts +209 -197
- package/telegram-plugin/tests/always-allow-grant.test.ts +86 -54
- package/telegram-plugin/tests/always-allow-persist.test.ts +35 -34
- package/telegram-plugin/tests/permission-rule.test.ts +185 -127
- package/telegram-plugin/tests/permission-title.test.ts +109 -195
|
@@ -1,46 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Human-readable text for the Telegram permission approval card.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* The operator is (often) non-technical. A card must read as a plain
|
|
5
|
+
* sentence — "Gymbro wants to edit: supplement-log.md" — never a raw
|
|
6
|
+
* tool identifier (`mcp__perplexity__search`, `Edit:`). Two surfaces:
|
|
5
7
|
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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".
|
|
8
|
+
* `formatPermissionCardBody` — the card itself: a one-line natural
|
|
9
|
+
* title plus the agent's stated reason. No tool ids, no scope chrome
|
|
10
|
+
* (scope only appears once the operator taps "🔁 Always…").
|
|
12
11
|
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
12
|
+
* `describeGrant` — the confirmation after a grant lands: "Gymbro can
|
|
13
|
+
* now edit any file without asking" — phrased from the *scope the
|
|
14
|
+
* operator chose*, so the breadth of an always-allow is legible after
|
|
15
|
+
* the fact, not just before.
|
|
20
16
|
*
|
|
21
|
-
* See #186 (title)
|
|
17
|
+
* See #186 (title), #1790 (reason line), and the scoped-card work.
|
|
22
18
|
*/
|
|
23
19
|
|
|
24
20
|
import { basename } from "node:path";
|
|
21
|
+
import { prettyMcpServer, type ScopeOption } from "./permission-rule.js";
|
|
25
22
|
|
|
26
|
-
const COMMAND_TITLE_MAX =
|
|
27
|
-
const PATH_TITLE_MAX = 40;
|
|
23
|
+
const COMMAND_TITLE_MAX = 48;
|
|
28
24
|
const DESCRIPTION_LINE_MAX = 240;
|
|
29
|
-
const INPUT_VALUE_MAX = 60;
|
|
30
25
|
|
|
31
26
|
/**
|
|
32
|
-
* Human-
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* Note: post-#1215 these tools are pre-allowed in scaffolded
|
|
40
|
-
* settings.permissions.allow, so the card should fire rarely.
|
|
41
|
-
* This map is for the fallback path — agents the operator
|
|
42
|
-
* narrowed the allowlist on, or tools added in future PRs that
|
|
43
|
-
* haven't shipped the allowlist bump yet.
|
|
27
|
+
* Human verb-phrases for switchroom-managed MCP tools. The raw
|
|
28
|
+
* `mcp__<server>__<tool>` name is operator-hostile. Phrases are written
|
|
29
|
+
* to slot in after "wants to" / "can now" — e.g. "read its own merged
|
|
30
|
+
* config". Internal-server tools (agent-config / hostd / hindsight /
|
|
31
|
+
* telegram) read fine alone; external integrations get a "(Server)" tag
|
|
32
|
+
* appended so the operator knows which third party is involved.
|
|
44
33
|
*/
|
|
45
34
|
const MCP_TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
46
35
|
// agent-config — every agent's self-service surface (#1163, #1215)
|
|
@@ -65,160 +54,225 @@ const MCP_TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
|
65
54
|
"mcp__hindsight__recall": "Recall relevant memories",
|
|
66
55
|
"mcp__hindsight__retain": "Retain a memory",
|
|
67
56
|
"mcp__hindsight__reflect": "Reflect across its memory bank",
|
|
57
|
+
// external integrations — common verbs (get a "(Server)" tag)
|
|
58
|
+
"mcp__perplexity__search": "Search the web",
|
|
59
|
+
"mcp__perplexity__ask": "Ask the web",
|
|
68
60
|
};
|
|
69
61
|
|
|
62
|
+
const INTERNAL_MCP_SERVERS = new Set([
|
|
63
|
+
"agent-config",
|
|
64
|
+
"hostd",
|
|
65
|
+
"hindsight",
|
|
66
|
+
"switchroom-telegram",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the multi-line card body for an approval prompt.
|
|
71
|
+
*
|
|
72
|
+
* 🔐 <b>Gymbro</b> wants to edit: supplement-log.md
|
|
73
|
+
* why: <i>logging today's lifts</i>
|
|
74
|
+
*
|
|
75
|
+
* Output is HTML-escaped for `parse_mode: 'HTML'`. The agent name is
|
|
76
|
+
* capitalized for the sentence; dropped (with "wants to") when null —
|
|
77
|
+
* the bridge client can be anonymous during early-boot edge cases.
|
|
78
|
+
*/
|
|
79
|
+
export function formatPermissionCardBody(opts: {
|
|
80
|
+
toolName: string;
|
|
81
|
+
inputPreview: string | undefined;
|
|
82
|
+
description: string | undefined;
|
|
83
|
+
agentName: string | null;
|
|
84
|
+
}): string {
|
|
85
|
+
const action = naturalAction(opts.toolName, opts.inputPreview);
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
|
|
88
|
+
if (opts.agentName && opts.agentName.length > 0) {
|
|
89
|
+
lines.push(
|
|
90
|
+
`🔐 <b>${escapeTgHtml(capFirst(opts.agentName))}</b> wants to ${escapeTgHtml(action)}`,
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
lines.push(`🔐 ${escapeTgHtml(capFirst(action))}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
|
|
97
|
+
const truncatedWhy =
|
|
98
|
+
rawWhy.length > DESCRIPTION_LINE_MAX
|
|
99
|
+
? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "…"
|
|
100
|
+
: rawWhy;
|
|
101
|
+
lines.push(
|
|
102
|
+
truncatedWhy.length > 0
|
|
103
|
+
? `why: <i>${escapeTgHtml(truncatedWhy)}</i>`
|
|
104
|
+
: `why: <i>not provided</i>`,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
70
110
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* conservative: better to keep the bare name than render gibberish from
|
|
74
|
-
* a malformed input_preview.
|
|
111
|
+
* The natural-language action for a tool call — the part that reads
|
|
112
|
+
* after "wants to". No tool identifiers, no scope.
|
|
75
113
|
*/
|
|
76
|
-
export function
|
|
114
|
+
export function naturalAction(
|
|
77
115
|
toolName: string,
|
|
78
116
|
inputPreview: string | undefined,
|
|
79
117
|
): string {
|
|
80
|
-
// MCP tools: `mcp__<server>__<verb>`. Prefer a curated human
|
|
81
|
-
// description (so the card reads "Read its own merged config"
|
|
82
|
-
// instead of "mcp__agent-config__config_get"). Fall through to a
|
|
83
|
-
// generic `<server>: <verb-with-spaces>` shape for unknown MCP
|
|
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)
|
|
89
|
-
if (toolName.startsWith("mcp__")) {
|
|
90
|
-
const curated = MCP_TOOL_DESCRIPTIONS[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;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
118
|
const input = parseInput(inputPreview);
|
|
107
|
-
|
|
119
|
+
|
|
120
|
+
if (toolName.startsWith("mcp__")) return naturalMcpAction(toolName, input);
|
|
108
121
|
|
|
109
122
|
switch (toolName) {
|
|
110
|
-
case "Skill": {
|
|
111
|
-
// Claude Code's Skill tool input shape has shifted across versions
|
|
112
|
-
// and skill flavours. Read defensively from every known field
|
|
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.
|
|
122
|
-
const skill =
|
|
123
|
-
readString(input, "skill") ??
|
|
124
|
-
readString(input, "skill_name") ??
|
|
125
|
-
readString(input, "skillName") ??
|
|
126
|
-
readString(input, "name") ??
|
|
127
|
-
skillBasenameFromPath(input);
|
|
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;
|
|
133
|
-
}
|
|
134
|
-
case "Bash": {
|
|
135
|
-
const command = readString(input, "command");
|
|
136
|
-
return command ? `${toolName}: ${truncate(command, COMMAND_TITLE_MAX)}` : toolName;
|
|
137
|
-
}
|
|
138
|
-
case "Read":
|
|
139
123
|
case "Edit":
|
|
140
|
-
case "Write":
|
|
141
124
|
case "MultiEdit":
|
|
142
125
|
case "NotebookEdit": {
|
|
143
|
-
const
|
|
144
|
-
return
|
|
126
|
+
const f = fileBase(input);
|
|
127
|
+
return f ? `edit: ${f}` : "edit files";
|
|
128
|
+
}
|
|
129
|
+
case "Write": {
|
|
130
|
+
const f = fileBase(input);
|
|
131
|
+
return f ? `write: ${f}` : "write files";
|
|
132
|
+
}
|
|
133
|
+
case "Read": {
|
|
134
|
+
const f = fileBase(input);
|
|
135
|
+
return f ? `read: ${f}` : "read files";
|
|
136
|
+
}
|
|
137
|
+
case "Bash": {
|
|
138
|
+
const c = input ? readString(input, "command") : null;
|
|
139
|
+
return c ? `run: ${truncate(c, COMMAND_TITLE_MAX)}` : "run shell commands";
|
|
140
|
+
}
|
|
141
|
+
case "Skill": {
|
|
142
|
+
const s = input ? resolveSkillName(input) : null;
|
|
143
|
+
return s ? `use the ${s} skill` : "use a skill";
|
|
145
144
|
}
|
|
146
145
|
case "Glob":
|
|
147
146
|
case "Grep": {
|
|
148
|
-
const
|
|
149
|
-
return
|
|
147
|
+
const p = input ? readString(input, "pattern") : null;
|
|
148
|
+
return p ? `search files for: ${truncate(p, COMMAND_TITLE_MAX)}` : "search files";
|
|
150
149
|
}
|
|
151
|
-
case "WebFetch":
|
|
152
150
|
case "WebSearch": {
|
|
153
|
-
const
|
|
154
|
-
return
|
|
151
|
+
const q = input ? readString(input, "query") : null;
|
|
152
|
+
return q ? `search the web for: ${truncate(q, COMMAND_TITLE_MAX)}` : "search the web";
|
|
153
|
+
}
|
|
154
|
+
case "WebFetch": {
|
|
155
|
+
const u = input ? readString(input, "url") : null;
|
|
156
|
+
return u ? `fetch a web page: ${truncate(u, COMMAND_TITLE_MAX)}` : "fetch a web page";
|
|
155
157
|
}
|
|
158
|
+
case "Task":
|
|
159
|
+
case "Agent":
|
|
160
|
+
return "dispatch a sub-agent";
|
|
161
|
+
case "TodoWrite":
|
|
162
|
+
return "update its task list";
|
|
163
|
+
case "ExitPlanMode":
|
|
164
|
+
return "exit plan mode";
|
|
156
165
|
default:
|
|
157
|
-
return toolName
|
|
166
|
+
return `use ${toolName}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function naturalMcpAction(
|
|
171
|
+
toolName: string,
|
|
172
|
+
input: Record<string, unknown> | null,
|
|
173
|
+
): string {
|
|
174
|
+
void input;
|
|
175
|
+
const parts = toolName.split("__");
|
|
176
|
+
const server = parts.length >= 2 ? parts[1]! : "";
|
|
177
|
+
const curated = MCP_TOOL_DESCRIPTIONS[toolName];
|
|
178
|
+
if (curated) {
|
|
179
|
+
const phrase = lowerFirst(curated);
|
|
180
|
+
return INTERNAL_MCP_SERVERS.has(server)
|
|
181
|
+
? phrase
|
|
182
|
+
: `${phrase} (${prettyMcpServer(server)})`;
|
|
158
183
|
}
|
|
184
|
+
if (parts.length >= 3) {
|
|
185
|
+
const verb = parts.slice(2).join("__").replace(/_/g, " ");
|
|
186
|
+
return INTERNAL_MCP_SERVERS.has(server)
|
|
187
|
+
? verb
|
|
188
|
+
: `${verb} (${prettyMcpServer(server)})`;
|
|
189
|
+
}
|
|
190
|
+
return `use ${toolName}`;
|
|
159
191
|
}
|
|
160
192
|
|
|
161
193
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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):
|
|
194
|
+
* Confirmation phrase describing a grant that just landed, derived from
|
|
195
|
+
* the *scope option the operator chose* — so an always-allow's breadth
|
|
196
|
+
* is legible after the fact. Slots in after "<Agent> can now …":
|
|
172
197
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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.
|
|
198
|
+
* "edit any file" / "edit supplement-log.md" / "run npm commands" /
|
|
199
|
+
* "use the mail skill" / "use any Perplexity tool"
|
|
182
200
|
*/
|
|
183
|
-
export function
|
|
184
|
-
toolName: string
|
|
185
|
-
inputPreview: string | undefined
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const summary = summarizeToolForTitle(opts.toolName, opts.inputPreview);
|
|
190
|
-
const lines: string[] = [];
|
|
201
|
+
export function describeGrant(
|
|
202
|
+
toolName: string,
|
|
203
|
+
inputPreview: string | undefined,
|
|
204
|
+
option: ScopeOption,
|
|
205
|
+
): string {
|
|
206
|
+
const rule = option.rule;
|
|
191
207
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
208
|
+
// MCP wildcard → "use any <Server> tool".
|
|
209
|
+
if (rule.endsWith("__*") && rule.startsWith("mcp__")) {
|
|
210
|
+
const server = rule.split("__")[1] ?? "";
|
|
211
|
+
return `use any ${prettyMcpServer(server)} tool`;
|
|
212
|
+
}
|
|
196
213
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
214
|
+
const scoped = /^([A-Za-z]+)\((.+)\)$/.exec(rule);
|
|
215
|
+
if (scoped) {
|
|
216
|
+
const t = scoped[1]!;
|
|
217
|
+
const arg = scoped[2]!;
|
|
218
|
+
if (t === "Skill") return `use the ${arg} skill`;
|
|
219
|
+
if (t === "Bash") {
|
|
220
|
+
const m = /^([^:]+):\*$/.exec(arg);
|
|
221
|
+
return m ? `run ${m[1]} commands` : "run that command";
|
|
222
|
+
}
|
|
223
|
+
if (t === "Edit" || t === "MultiEdit" || t === "NotebookEdit")
|
|
224
|
+
return `edit ${basename(arg)}`;
|
|
225
|
+
if (t === "Write") return `write ${basename(arg)}`;
|
|
226
|
+
if (t === "Read") return `read ${basename(arg)}`;
|
|
227
|
+
return naturalAction(toolName, inputPreview);
|
|
211
228
|
}
|
|
212
229
|
|
|
213
|
-
|
|
230
|
+
// Bare tool name — the broad, whole-category grants.
|
|
231
|
+
switch (rule) {
|
|
232
|
+
case "Edit":
|
|
233
|
+
case "MultiEdit":
|
|
234
|
+
case "NotebookEdit":
|
|
235
|
+
return "edit any file";
|
|
236
|
+
case "Write":
|
|
237
|
+
return "write any file";
|
|
238
|
+
case "Read":
|
|
239
|
+
return "read any file";
|
|
240
|
+
case "Bash":
|
|
241
|
+
return "run any command";
|
|
242
|
+
case "Skill":
|
|
243
|
+
return "use any skill";
|
|
244
|
+
default:
|
|
245
|
+
// Exact MCP tool or a broad-only built-in — fall back to the
|
|
246
|
+
// request's natural action.
|
|
247
|
+
return naturalAction(toolName, inputPreview);
|
|
248
|
+
}
|
|
214
249
|
}
|
|
215
250
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
251
|
+
function resolveSkillName(input: Record<string, unknown>): string | null {
|
|
252
|
+
return (
|
|
253
|
+
readString(input, "skill") ??
|
|
254
|
+
readString(input, "skill_name") ??
|
|
255
|
+
readString(input, "skillName") ??
|
|
256
|
+
readString(input, "name") ??
|
|
257
|
+
skillBasenameFromPath(input)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function fileBase(input: Record<string, unknown> | null): string | null {
|
|
262
|
+
if (!input) return null;
|
|
263
|
+
const p = readString(input, "file_path") ?? readString(input, "notebook_path");
|
|
264
|
+
return p ? basename(p) : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function lowerFirst(text: string): string {
|
|
268
|
+
return text.length > 0 ? text.charAt(0).toLowerCase() + text.slice(1) : text;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function capFirst(text: string): string {
|
|
272
|
+
return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : text;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Minimal HTML escape for Telegram `parse_mode=HTML`. */
|
|
222
276
|
function escapeTgHtml(text: string): string {
|
|
223
277
|
return text
|
|
224
278
|
.replace(/&/g, "&")
|
|
@@ -226,40 +280,6 @@ function escapeTgHtml(text: string): string {
|
|
|
226
280
|
.replace(/>/g, ">");
|
|
227
281
|
}
|
|
228
282
|
|
|
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
|
-
|
|
263
283
|
function parseInput(raw: string | undefined): Record<string, unknown> | null {
|
|
264
284
|
if (!raw || typeof raw !== "string") return null;
|
|
265
285
|
const trimmed = raw.trim();
|
|
@@ -280,21 +300,13 @@ function readString(input: Record<string, unknown>, key: string): string | null
|
|
|
280
300
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
281
301
|
}
|
|
282
302
|
|
|
283
|
-
/**
|
|
284
|
-
* Some Skill tool variants pass the skill as a directory path (e.g.
|
|
285
|
-
* `skills/mail/SKILL.md` or `~/.switchroom/skills/mail`). Lift the
|
|
286
|
-
* skill name out of the path so the popup still says `Skill (mail)`
|
|
287
|
-
* instead of dumping the full path or bare `Skill`.
|
|
288
|
-
*/
|
|
289
303
|
function skillBasenameFromPath(input: Record<string, unknown>): string | null {
|
|
290
304
|
const path = readString(input, "path") ?? readString(input, "skill_path");
|
|
291
305
|
if (!path) return null;
|
|
292
|
-
// Strip a trailing /SKILL.md or filename so we land on the directory
|
|
293
|
-
// basename — that's the canonical skill name in switchroom's layout.
|
|
294
306
|
const trimmed = path.replace(/\/SKILL\.md$/i, "").replace(/\/$/, "");
|
|
295
307
|
const lastSlash = trimmed.lastIndexOf("/");
|
|
296
|
-
const
|
|
297
|
-
return
|
|
308
|
+
const base = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
|
|
309
|
+
return base.length > 0 ? base : null;
|
|
298
310
|
}
|
|
299
311
|
|
|
300
312
|
function truncate(text: string, max: number): string {
|