dsh-capability-panel 1.0.0
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/LICENSE +21 -0
- package/README.i18n.yaml +8 -0
- package/README.ja.md +110 -0
- package/README.ko.md +110 -0
- package/README.md +116 -0
- package/README.zh.md +116 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +11121 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +188 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1458 -0
- package/lib/index.js.map +1 -0
- package/package.json +101 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
//#region src/stats.ts
|
|
7
|
+
/**
|
|
8
|
+
* Usage statistics: did the agent still reach for a capability AFTER the user
|
|
9
|
+
* turned it off? That answer drives the panel's optimization direction, so it
|
|
10
|
+
* must be measured, not guessed.
|
|
11
|
+
*
|
|
12
|
+
* Detection is pure (this module has no I/O): the host feeds every
|
|
13
|
+
* `tools/result` observation through {@link classifyBlockedCall} and appends
|
|
14
|
+
* the returned record to a JSONL log. Two blocked shapes exist:
|
|
15
|
+
*
|
|
16
|
+
* - skill: the `skill` loader tool is still registered (only the catalog entry
|
|
17
|
+
* is shadowed), so a blocked attempt fails INSIDE the tool with
|
|
18
|
+
* "not available for model invocation". The skill name rides the arguments.
|
|
19
|
+
* - mcp: a restricted tool is invisible, so dispatch fails BEFORE the body
|
|
20
|
+
* with code UNKNOWN_TOOL. The tool name is the call name itself.
|
|
21
|
+
*
|
|
22
|
+
* Toggle operations are logged too (kind 'disable'/'enable') so later analysis
|
|
23
|
+
* can correlate "turned off at T" with "attempted at T+n".
|
|
24
|
+
*/
|
|
25
|
+
/** Stable prefix of the guard's denial text, marking a hard call to a preset-layer tool the user turned off. */
|
|
26
|
+
const GUARD_DENIAL_PREFIX = "capability-panel: tool disabled";
|
|
27
|
+
/**
|
|
28
|
+
* Classify one settled call against the session's CURRENT disabled sets.
|
|
29
|
+
* Returns null for everything that is not a blocked attempt — including
|
|
30
|
+
* unknown-tool failures for tools nobody disabled (genuine model typos).
|
|
31
|
+
*/
|
|
32
|
+
function classifyBlockedCall(call, disabledSkills, disabledToolNames) {
|
|
33
|
+
if (call.error === void 0) return null;
|
|
34
|
+
const sessionId = typeof call.agent?.id === "string" ? call.agent.id : null;
|
|
35
|
+
if (call.name === "skill") {
|
|
36
|
+
if (!(typeof call.error.message === "string" ? call.error.message : "").includes("not available for model invocation")) return null;
|
|
37
|
+
const args = call.arguments;
|
|
38
|
+
const skillName = args !== null && typeof args === "object" && typeof args.name === "string" ? args.name : null;
|
|
39
|
+
if (skillName === null || !disabledSkills.has(skillName)) return null;
|
|
40
|
+
return {
|
|
41
|
+
kind: "blocked-skill",
|
|
42
|
+
name: skillName,
|
|
43
|
+
sessionId
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (typeof call.name === "string" && disabledToolNames.has(call.name)) {
|
|
47
|
+
const message = typeof call.error.message === "string" ? call.error.message : "";
|
|
48
|
+
if (call.error.info?.code !== "UNKNOWN_TOOL" && !message.startsWith(GUARD_DENIAL_PREFIX)) return null;
|
|
49
|
+
return {
|
|
50
|
+
kind: "blocked-tool",
|
|
51
|
+
name: call.name,
|
|
52
|
+
sessionId
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/** Aggregate a JSONL log into a name → blocked-attempt count map. */
|
|
58
|
+
function aggregateBlocked(lines) {
|
|
59
|
+
const counts = {};
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
if (trimmed === "") continue;
|
|
63
|
+
let record;
|
|
64
|
+
try {
|
|
65
|
+
record = JSON.parse(trimmed);
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (record.kind !== "blocked-skill" && record.kind !== "blocked-tool" && record.kind !== "blocked-mcp") continue;
|
|
70
|
+
if (typeof record.name !== "string") continue;
|
|
71
|
+
counts[record.name] = (counts[record.name] ?? 0) + 1;
|
|
72
|
+
}
|
|
73
|
+
return counts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/host/errors.ts
|
|
78
|
+
var HttpError = class extends Error {
|
|
79
|
+
constructor(status, message) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.status = status;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
function errorMessage(error) {
|
|
85
|
+
return error instanceof Error ? error.message : String(error);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/host/reserved.ts
|
|
90
|
+
/**
|
|
91
|
+
* `run_code` is the Code Mode transport the harness itself depends on. It must
|
|
92
|
+
* stay reachable in every session, so it can never be switched off: the
|
|
93
|
+
* settings route rejects the attempt with 409, the panels render its switch
|
|
94
|
+
* disabled, and enforcement ignores a stored entry naming it. One constant
|
|
95
|
+
* keeps the four of those from drifting apart.
|
|
96
|
+
*/
|
|
97
|
+
const RESERVED_TOOL = "run_code";
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/host/capabilities.ts
|
|
101
|
+
function renderDisabledNote(state) {
|
|
102
|
+
const lines = [];
|
|
103
|
+
if (state.skills.size > 0) lines.push(`- Skills: ${[...state.skills.keys()].join(", ")}`);
|
|
104
|
+
if (state.mcpServers.size > 0) lines.push(`- MCP servers: ${[...state.mcpServers.keys()].join(", ")}`);
|
|
105
|
+
if (state.mcpTools.size > 0) lines.push(`- MCP tools: ${[...state.mcpTools.keys()].join(", ")}`);
|
|
106
|
+
if (state.systemTools.size > 0) lines.push(`- System tools: ${[...state.systemTools.keys()].join(", ")}`);
|
|
107
|
+
if (lines.length === 0) return "";
|
|
108
|
+
return [
|
|
109
|
+
"The user has turned off the following capabilities for this session:",
|
|
110
|
+
...lines,
|
|
111
|
+
"Do not attempt to call them. If the user's request depends on one, say it is disabled and can be re-enabled from the Capability Panel."
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
function createCapabilityController(ctx, appendStats, blockedCounts) {
|
|
115
|
+
const states = /* @__PURE__ */ new Map();
|
|
116
|
+
const stateFor = (sessionId) => {
|
|
117
|
+
let state = states.get(sessionId);
|
|
118
|
+
if (state === void 0) {
|
|
119
|
+
state = {
|
|
120
|
+
skills: /* @__PURE__ */ new Map(),
|
|
121
|
+
mcpServers: /* @__PURE__ */ new Map(),
|
|
122
|
+
mcpTools: /* @__PURE__ */ new Map(),
|
|
123
|
+
systemTools: /* @__PURE__ */ new Map(),
|
|
124
|
+
userToggled: /* @__PURE__ */ new Set()
|
|
125
|
+
};
|
|
126
|
+
states.set(sessionId, state);
|
|
127
|
+
}
|
|
128
|
+
return state;
|
|
129
|
+
};
|
|
130
|
+
const disabledToolNames = (state) => {
|
|
131
|
+
const names = new Set([...state.mcpTools.keys(), ...state.systemTools.keys()]);
|
|
132
|
+
for (const schema of state.mcpServers.size > 0 ? ctx.get("tools")?.schemas() ?? [] : []) {
|
|
133
|
+
if (typeof schema.name !== "string" || !schema.name.startsWith("mcp__")) continue;
|
|
134
|
+
const server = schema.name.slice(5, schema.name.indexOf("__", 5));
|
|
135
|
+
if (state.mcpServers.has(server)) names.add(schema.name);
|
|
136
|
+
}
|
|
137
|
+
return names;
|
|
138
|
+
};
|
|
139
|
+
ctx.on("system-prompt/assemble", async (_assembly, context, next) => {
|
|
140
|
+
const assembled = await next();
|
|
141
|
+
const sessionId = typeof context.agent?.id === "string" ? context.agent.id : null;
|
|
142
|
+
const state = sessionId === null ? void 0 : states.get(sessionId);
|
|
143
|
+
if (state === void 0 || state.systemTools.size === 0 || assembled.tools === void 0) return assembled;
|
|
144
|
+
return {
|
|
145
|
+
...assembled,
|
|
146
|
+
tools: assembled.tools.filter((tool) => !state.systemTools.has(String(tool.name)))
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
let guardRegistered = false;
|
|
150
|
+
const ensureGuard = () => {
|
|
151
|
+
if (guardRegistered) return;
|
|
152
|
+
const guardDispose = ctx.get("tools")?.guard?.((execution) => {
|
|
153
|
+
const sessionId = typeof execution.agent?.id === "string" ? execution.agent.id : null;
|
|
154
|
+
const state = sessionId === null ? void 0 : states.get(sessionId);
|
|
155
|
+
const name = typeof execution.name === "string" ? execution.name : null;
|
|
156
|
+
if (state === void 0 || name === null || !state.systemTools.has(name)) return void 0;
|
|
157
|
+
return `${GUARD_DENIAL_PREFIX} "${name}" (re-enable from the Capability Panel)`;
|
|
158
|
+
});
|
|
159
|
+
if (guardDispose === void 0) return;
|
|
160
|
+
guardRegistered = true;
|
|
161
|
+
ctx.effect(() => guardDispose, "capability-panel: tool guard");
|
|
162
|
+
};
|
|
163
|
+
ensureGuard();
|
|
164
|
+
ctx.on("tools/result", (exec, result) => {
|
|
165
|
+
const agent = exec.agent;
|
|
166
|
+
if (agent === void 0 || typeof agent.id !== "string") return;
|
|
167
|
+
const state = states.get(agent.id);
|
|
168
|
+
if (state === void 0) return;
|
|
169
|
+
const hit = classifyBlockedCall({
|
|
170
|
+
name: exec.name,
|
|
171
|
+
arguments: exec.arguments,
|
|
172
|
+
agent,
|
|
173
|
+
...result.isError && result.error !== void 0 ? { error: result.error } : {}
|
|
174
|
+
}, new Set(state.skills.keys()), disabledToolNames(state));
|
|
175
|
+
if (hit === null) return;
|
|
176
|
+
blockedCounts[hit.name] = (blockedCounts[hit.name] ?? 0) + 1;
|
|
177
|
+
appendStats({
|
|
178
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
179
|
+
sessionId: agent.id,
|
|
180
|
+
kind: hit.kind,
|
|
181
|
+
name: hit.name
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
const ensurePromptNote = (agent, state) => {
|
|
185
|
+
if (state.noteDispose !== void 0) return;
|
|
186
|
+
const systemPrompt = agent.ctx?.get("systemPrompt");
|
|
187
|
+
if (systemPrompt === void 0) return;
|
|
188
|
+
state.noteDispose = systemPrompt.context({
|
|
189
|
+
name: "capability-panel:disabled-capabilities",
|
|
190
|
+
order: 900,
|
|
191
|
+
text: () => renderDisabledNote(state)
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
const getAgentTools = (sessionId) => {
|
|
195
|
+
const agent = ctx.get("agents")?.get(sessionId);
|
|
196
|
+
const tools = agent?.ctx?.get("tools");
|
|
197
|
+
if (agent === void 0) throw new HttpError(404, "session agent is not available");
|
|
198
|
+
if (tools === void 0) throw new HttpError(503, "session tools service is not available");
|
|
199
|
+
return {
|
|
200
|
+
agent,
|
|
201
|
+
tools
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const setSkill = async (sessionId, name, enabled) => {
|
|
205
|
+
const state = stateFor(sessionId);
|
|
206
|
+
const existing = state.skills.get(name);
|
|
207
|
+
if (enabled && existing !== void 0) {
|
|
208
|
+
existing();
|
|
209
|
+
state.skills.delete(name);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const agent = ctx.get("agents")?.get(sessionId);
|
|
213
|
+
const scopedSkills = agent?.ctx?.get("skills");
|
|
214
|
+
if (agent === void 0 || scopedSkills === void 0) throw new HttpError(agent === void 0 ? 404 : 503, agent === void 0 ? "session agent is not available" : "session skills service is not available");
|
|
215
|
+
if (enabled || existing !== void 0) return;
|
|
216
|
+
const skills = ctx.get("skills");
|
|
217
|
+
if (skills === void 0) throw new HttpError(503, "skills service unavailable");
|
|
218
|
+
const cwd = agent.session?.header?.cwd;
|
|
219
|
+
const original = await skills.get(name, {
|
|
220
|
+
...cwd === void 0 ? {} : { cwd },
|
|
221
|
+
scope: agent
|
|
222
|
+
});
|
|
223
|
+
if (original === void 0 || typeof original.name !== "string" || typeof original.description !== "string" || typeof original.content !== "string") throw new HttpError(404, `skill "${name}" is not available in this session`);
|
|
224
|
+
state.skills.set(name, scopedSkills.register({
|
|
225
|
+
name: original.name,
|
|
226
|
+
description: original.description,
|
|
227
|
+
content: original.content,
|
|
228
|
+
source: "custom",
|
|
229
|
+
provider: "capability-panel",
|
|
230
|
+
...original.resourceBase === void 0 ? {} : { resourceBase: original.resourceBase },
|
|
231
|
+
invocation: {
|
|
232
|
+
modelInvocable: false,
|
|
233
|
+
userInvocable: true
|
|
234
|
+
}
|
|
235
|
+
}));
|
|
236
|
+
ensurePromptNote(agent, state);
|
|
237
|
+
};
|
|
238
|
+
const setServer = (sessionId, server, enabled) => {
|
|
239
|
+
const state = stateFor(sessionId);
|
|
240
|
+
const existing = state.mcpServers.get(server);
|
|
241
|
+
if (enabled && existing !== void 0) {
|
|
242
|
+
existing();
|
|
243
|
+
state.mcpServers.delete(server);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const { agent, tools } = getAgentTools(sessionId);
|
|
247
|
+
if (enabled || existing !== void 0) return;
|
|
248
|
+
const toolService = ctx.get("tools");
|
|
249
|
+
if (toolService === void 0) throw new HttpError(503, "tools service unavailable");
|
|
250
|
+
const prefix = `mcp__${server}__`;
|
|
251
|
+
const names = [...toolService.schemas()].map((schema) => schema.name).filter((name) => typeof name === "string" && name.startsWith(prefix));
|
|
252
|
+
if (names.length === 0) throw new HttpError(404, `MCP server "${server}" exposes no tools`);
|
|
253
|
+
state.mcpServers.set(server, tools.restrict({ deny: names }));
|
|
254
|
+
ensurePromptNote(agent, state);
|
|
255
|
+
};
|
|
256
|
+
const setTool = (sessionId, name, enabled, system) => {
|
|
257
|
+
const state = stateFor(sessionId);
|
|
258
|
+
const map = system ? state.systemTools : state.mcpTools;
|
|
259
|
+
const existing = map.get(name);
|
|
260
|
+
if (enabled && existing !== void 0) {
|
|
261
|
+
existing();
|
|
262
|
+
map.delete(name);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (system && name === RESERVED_TOOL) throw new HttpError(409, "run_code is the reserved Code Mode transport and cannot be restricted");
|
|
266
|
+
const { agent, tools } = getAgentTools(sessionId);
|
|
267
|
+
if (enabled || existing !== void 0) return;
|
|
268
|
+
const toolService = ctx.get("tools");
|
|
269
|
+
if (toolService === void 0) throw new HttpError(503, "tools service unavailable");
|
|
270
|
+
const globalNames = new Set([...toolService.schemas()].map((schema) => schema.name).filter((entry) => typeof entry === "string"));
|
|
271
|
+
const scopedNames = system ? new Set([...toolService.schemas(agent)].map((schema) => schema.name).filter((entry) => typeof entry === "string")) : globalNames;
|
|
272
|
+
if (!(system ? scopedNames.has(name) : globalNames.has(name) && name.startsWith("mcp__"))) throw new HttpError(404, `${system ? "system tool" : "MCP tool"} "${name}" is not available in this session`);
|
|
273
|
+
if (system) ensureGuard();
|
|
274
|
+
map.set(name, globalNames.has(name) ? tools.restrict({ deny: [name] }) : () => {});
|
|
275
|
+
ensurePromptNote(agent, state);
|
|
276
|
+
};
|
|
277
|
+
ctx.effect(() => () => {
|
|
278
|
+
for (const state of states.values()) {
|
|
279
|
+
for (const map of [
|
|
280
|
+
state.skills,
|
|
281
|
+
state.mcpServers,
|
|
282
|
+
state.mcpTools,
|
|
283
|
+
state.systemTools
|
|
284
|
+
]) for (const dispose of map.values()) dispose();
|
|
285
|
+
state.noteDispose?.();
|
|
286
|
+
}
|
|
287
|
+
states.clear();
|
|
288
|
+
}, "capability-panel: capability masks");
|
|
289
|
+
const seed = async (sessionId, defaults) => {
|
|
290
|
+
const agent = ctx.get("agents")?.get(sessionId);
|
|
291
|
+
if (agent === void 0) return;
|
|
292
|
+
let state = states.get(sessionId);
|
|
293
|
+
const ensureState = () => {
|
|
294
|
+
if (state === void 0) state = stateFor(sessionId);
|
|
295
|
+
return state;
|
|
296
|
+
};
|
|
297
|
+
let maskedAny = false;
|
|
298
|
+
const scopedTools = agent.ctx?.get("tools");
|
|
299
|
+
const toolsService = ctx.get("tools");
|
|
300
|
+
if (scopedTools !== void 0 && toolsService !== void 0) {
|
|
301
|
+
const globalNames = /* @__PURE__ */ new Set();
|
|
302
|
+
const scopedNames = /* @__PURE__ */ new Set();
|
|
303
|
+
for (const schema of toolsService.schemas()) if (typeof schema.name === "string") globalNames.add(schema.name);
|
|
304
|
+
for (const schema of toolsService.schemas(agent)) if (typeof schema.name === "string") scopedNames.add(schema.name);
|
|
305
|
+
for (const name of defaults.tools) {
|
|
306
|
+
if (!globalNames.has(name)) continue;
|
|
307
|
+
if (name.startsWith("mcp__")) {
|
|
308
|
+
if (state?.mcpTools.has(name) === true) continue;
|
|
309
|
+
ensureState().mcpTools.set(name, scopedTools.restrict({ deny: [name] }));
|
|
310
|
+
maskedAny = true;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (name === RESERVED_TOOL || state?.systemTools.has(name) === true) continue;
|
|
314
|
+
if (!scopedNames.has(name)) continue;
|
|
315
|
+
ensureState().systemTools.set(name, scopedTools.restrict({ deny: [name] }));
|
|
316
|
+
ensureGuard();
|
|
317
|
+
maskedAny = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const scopedSkills = agent.ctx?.get("skills");
|
|
321
|
+
const skillsService = ctx.get("skills");
|
|
322
|
+
if (scopedSkills !== void 0 && skillsService !== void 0) {
|
|
323
|
+
const cwd = agent.session?.header?.cwd;
|
|
324
|
+
const lookup = {
|
|
325
|
+
...cwd === void 0 ? {} : { cwd },
|
|
326
|
+
scope: agent
|
|
327
|
+
};
|
|
328
|
+
const disposers = await Promise.all(defaults.skills.map(async (name) => {
|
|
329
|
+
if (state?.skills.has(name) === true) return void 0;
|
|
330
|
+
try {
|
|
331
|
+
const original = await skillsService.get(name, lookup);
|
|
332
|
+
if (original === void 0) return void 0;
|
|
333
|
+
if (typeof original.name !== "string" || typeof original.description !== "string") return void 0;
|
|
334
|
+
if (typeof original.content !== "string") return void 0;
|
|
335
|
+
return scopedSkills.register({
|
|
336
|
+
name: original.name,
|
|
337
|
+
description: original.description,
|
|
338
|
+
content: original.content,
|
|
339
|
+
source: "custom",
|
|
340
|
+
provider: "capability-panel",
|
|
341
|
+
...original.resourceBase === void 0 ? {} : { resourceBase: original.resourceBase },
|
|
342
|
+
invocation: {
|
|
343
|
+
modelInvocable: false,
|
|
344
|
+
userInvocable: true
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
} catch {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}));
|
|
351
|
+
for (let i = 0; i < disposers.length; i += 1) {
|
|
352
|
+
const dispose = disposers[i];
|
|
353
|
+
const name = defaults.skills[i];
|
|
354
|
+
if (dispose !== void 0 && name !== void 0 && state?.skills.has(name) !== true) {
|
|
355
|
+
ensureState().skills.set(name, dispose);
|
|
356
|
+
maskedAny = true;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (maskedAny) ensurePromptNote(agent, ensureState());
|
|
361
|
+
};
|
|
362
|
+
return {
|
|
363
|
+
states,
|
|
364
|
+
state: (sessionId) => states.get(sessionId),
|
|
365
|
+
seed,
|
|
366
|
+
async restore(sessionId, overrides) {
|
|
367
|
+
const groups = [
|
|
368
|
+
["skill", overrides.skills],
|
|
369
|
+
["mcp-server", overrides.mcpServers],
|
|
370
|
+
["mcp-tool", overrides.mcpTools],
|
|
371
|
+
["system-tool", overrides.systemTools]
|
|
372
|
+
];
|
|
373
|
+
for (const [kind, positions] of groups) for (const [name, enabled] of Object.entries(positions)) {
|
|
374
|
+
if (states.get(sessionId)?.userToggled.has(`${kind}:${name}`) === true) continue;
|
|
375
|
+
try {
|
|
376
|
+
if (kind === "skill") await setSkill(sessionId, name, enabled);
|
|
377
|
+
else if (kind === "mcp-server") setServer(sessionId, name, enabled);
|
|
378
|
+
else setTool(sessionId, name, enabled, kind === "system-tool");
|
|
379
|
+
} catch {}
|
|
380
|
+
}
|
|
381
|
+
const st = states.get(sessionId);
|
|
382
|
+
if (st !== void 0 && st.userToggled.size === 0 && st.skills.size === 0 && st.mcpServers.size === 0 && st.mcpTools.size === 0 && st.systemTools.size === 0) {
|
|
383
|
+
st.noteDispose?.();
|
|
384
|
+
delete st.noteDispose;
|
|
385
|
+
states.delete(sessionId);
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
async set(sessionId, kind, name, enabled) {
|
|
389
|
+
stateFor(sessionId).userToggled.add(`${kind}:${name}`);
|
|
390
|
+
if (kind === "skill") await setSkill(sessionId, name, enabled);
|
|
391
|
+
else if (kind === "mcp-server") setServer(sessionId, name, enabled);
|
|
392
|
+
else setTool(sessionId, name, enabled, kind === "system-tool");
|
|
393
|
+
appendStats({
|
|
394
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
395
|
+
sessionId,
|
|
396
|
+
kind: enabled ? "enable" : "disable",
|
|
397
|
+
name: `${kind}:${name}`
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/host/preset-enforcement.ts
|
|
405
|
+
/**
|
|
406
|
+
* Applies stored capability positions to freshly created agents, in two
|
|
407
|
+
* layers: the preset's stored defaults first, then the session's own recorded
|
|
408
|
+
* toggles. The second layer is what survives a restart — a restored session
|
|
409
|
+
* creates a new agent, and its session-bound overrides land on top of the
|
|
410
|
+
* preset defaults, so the user's last word wins (including an explicit
|
|
411
|
+
* re-enable of a preset default).
|
|
412
|
+
*
|
|
413
|
+
* This is deliberately NOT part of the HTTP controller: the listener has no
|
|
414
|
+
* request, and living next to one is how it grew a settings schema, a write
|
|
415
|
+
* queue, and two event subscriptions in a single file. Here it owns exactly
|
|
416
|
+
* one job -- at agent/created, read both stored layers and seed them into the
|
|
417
|
+
* session's capability state.
|
|
418
|
+
*
|
|
419
|
+
* Seeding into the session state (rather than registering private masks) is
|
|
420
|
+
* the point: the session panel's enable path disposes whatever the state
|
|
421
|
+
* holds, whichever layer put it there, so a stored position remains a
|
|
422
|
+
* starting point the user can flip in the session instead of an invisible
|
|
423
|
+
* wall. Session overrides are keyed by session id, so one session's switches
|
|
424
|
+
* can never leak into another.
|
|
425
|
+
*/
|
|
426
|
+
function registerPresetEnforcement(ctx, capabilities, presetTools, sessionOverrides) {
|
|
427
|
+
/**
|
|
428
|
+
* Subscribe to `agent/created` so that NOTHING this plugin does can veto the
|
|
429
|
+
* agent. Cordis treats a synchronous listener failure as a veto and only
|
|
430
|
+
* reports a rejected promise, so both halves are contained: the synchronous
|
|
431
|
+
* prelude (reading settings, resolving services) is wrapped here, and the
|
|
432
|
+
* asynchronous body reports through the returned promise. Applying a stored
|
|
433
|
+
* preference is never worth costing the user their session.
|
|
434
|
+
*/
|
|
435
|
+
ctx.on("agent/created", ({ agent }) => {
|
|
436
|
+
try {
|
|
437
|
+
if (typeof agent.id !== "string") return void 0;
|
|
438
|
+
const sessionId = agent.id;
|
|
439
|
+
const presetId = ctx.get("agentPresets")?.composedPreset(agent.ctx);
|
|
440
|
+
const defaults = presetId === void 0 ? void 0 : presetTools.defaultsFor(presetId);
|
|
441
|
+
const hasDefaults = defaults !== void 0 && (defaults.tools.length > 0 || defaults.skills.length > 0);
|
|
442
|
+
const overrides = sessionOverrides.overridesFor(sessionId);
|
|
443
|
+
if (!hasDefaults && overrides === void 0) return void 0;
|
|
444
|
+
return (async () => {
|
|
445
|
+
if (hasDefaults && defaults !== void 0) await capabilities.seed(sessionId, defaults);
|
|
446
|
+
if (overrides !== void 0) await capabilities.restore(sessionId, overrides);
|
|
447
|
+
})();
|
|
448
|
+
} catch {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/host/settings-scope.ts
|
|
456
|
+
const TOOLKIT_SETTINGS_NAMESPACE = "capability-panel";
|
|
457
|
+
const SessionOverrideSchema = z.object({
|
|
458
|
+
skills: z.dict(z.boolean()).default({}),
|
|
459
|
+
mcpServers: z.dict(z.boolean()).default({}),
|
|
460
|
+
mcpTools: z.dict(z.boolean()).default({}),
|
|
461
|
+
systemTools: z.dict(z.boolean()).default({})
|
|
462
|
+
});
|
|
463
|
+
const ToolkitSettingsSchema = z.object({
|
|
464
|
+
presets: z.dict(z.array(z.string())).default({}),
|
|
465
|
+
presetSkills: z.dict(z.array(z.string())).default({}),
|
|
466
|
+
sessions: z.dict(SessionOverrideSchema).default({})
|
|
467
|
+
});
|
|
468
|
+
function createToolkitSettingsAccess(ctx) {
|
|
469
|
+
let scope;
|
|
470
|
+
let writeQueue = Promise.resolve();
|
|
471
|
+
return {
|
|
472
|
+
scope() {
|
|
473
|
+
if (scope === void 0) scope = ctx.get("settings")?.register(TOOLKIT_SETTINGS_NAMESPACE, ToolkitSettingsSchema, { applies: "live" });
|
|
474
|
+
return scope;
|
|
475
|
+
},
|
|
476
|
+
serialize(work) {
|
|
477
|
+
const next = writeQueue.then(work, work);
|
|
478
|
+
writeQueue = next.then(() => void 0, () => void 0);
|
|
479
|
+
return next;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/load-state.ts
|
|
486
|
+
/** Pull the skill name out of a `skill` tool call's stringified arguments. */
|
|
487
|
+
function skillNameOf(args) {
|
|
488
|
+
if (typeof args !== "string") return null;
|
|
489
|
+
try {
|
|
490
|
+
const parsed = JSON.parse(args);
|
|
491
|
+
if (parsed !== null && typeof parsed === "object" && "name" in parsed) {
|
|
492
|
+
const name = parsed.name;
|
|
493
|
+
return typeof name === "string" && name !== "" ? name : null;
|
|
494
|
+
}
|
|
495
|
+
} catch {}
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
function collectLoadRecords(events) {
|
|
499
|
+
const out = [];
|
|
500
|
+
for (const event of events) {
|
|
501
|
+
if (event.type !== "tool/call") continue;
|
|
502
|
+
if (event.data?.name !== "skill") continue;
|
|
503
|
+
const skillName = skillNameOf(event.data.arguments);
|
|
504
|
+
if (skillName === null) continue;
|
|
505
|
+
const seq = event.seq;
|
|
506
|
+
if (typeof seq !== "number") continue;
|
|
507
|
+
out.push({
|
|
508
|
+
seq,
|
|
509
|
+
skillName,
|
|
510
|
+
callId: event.data.callId ?? ""
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Map a tool result's pairing callId to its seq.
|
|
517
|
+
*
|
|
518
|
+
* Verified against real logs: `tool/result` events carry no
|
|
519
|
+
* top-level callId; the pairing lives at `data.message.source.callId` and
|
|
520
|
+
* matches the call's `data.callId` exactly.
|
|
521
|
+
*
|
|
522
|
+
* Last write wins on purpose: the middle-pruner appends a stub `tool/result`
|
|
523
|
+
* carrying the SAME callId and a replace surfaceOp over the original's seq
|
|
524
|
+
* (verified against a real compacted session), so the callId resolves to the
|
|
525
|
+
* stub — the node whose fold verdict actually tracks the surface position.
|
|
526
|
+
*/
|
|
527
|
+
function indexToolResultSeqs(events) {
|
|
528
|
+
const out = /* @__PURE__ */ new Map();
|
|
529
|
+
for (const event of events) {
|
|
530
|
+
if (event.type !== "tool/result") continue;
|
|
531
|
+
const callId = event.data?.message?.source?.callId;
|
|
532
|
+
const seq = event.seq;
|
|
533
|
+
if (typeof callId !== "string" || callId === "" || typeof seq !== "number") continue;
|
|
534
|
+
out.set(callId, seq);
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* The load seqs whose skill content is gone from the model surface.
|
|
540
|
+
*
|
|
541
|
+
* A `tool/call` never joins the surface itself — SURFACE_EVENT_TYPES in
|
|
542
|
+
* dsh-session is exactly { user/message, assistant/message, tool/result }, and
|
|
543
|
+
* real skill calls carry `surfaceOp: null`. What the model actually sees of a
|
|
544
|
+
* skill is its tool RESULT (a surface node), so eviction keys on the paired
|
|
545
|
+
* result's surface membership, not on the call's position.
|
|
546
|
+
*
|
|
547
|
+
* `surfaceSeqs` is the live session's CURRENT surface (`session.surface.nodes`),
|
|
548
|
+
* which the session maintains incrementally — reading it is O(1), no log fold
|
|
549
|
+
* is ever run for this panel. A paired result seq absent from that set was
|
|
550
|
+
* displaced by a prune stub's or a summary's `replace` op, i.e. shadowed.
|
|
551
|
+
*
|
|
552
|
+
* A load with no paired result is in flight or its result never landed; it is
|
|
553
|
+
* NOT counted as shadowed, so it reports `loaded` — the honest reading of "the
|
|
554
|
+
* model is about to see it".
|
|
555
|
+
*/
|
|
556
|
+
function shadowedLoadSeqs(loads, resultSeqByCallId, surfaceSeqs) {
|
|
557
|
+
const out = /* @__PURE__ */ new Set();
|
|
558
|
+
for (const load of loads) {
|
|
559
|
+
const resultSeq = resultSeqByCallId.get(load.callId);
|
|
560
|
+
if (resultSeq === void 0) continue;
|
|
561
|
+
if (!surfaceSeqs.has(resultSeq)) out.add(load.seq);
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Substring of dsh-compaction-tool-result-pruner's PRUNE_MARKER. Matching on
|
|
567
|
+
* the bracketed phrase (without the surrounding newlines) keeps the check
|
|
568
|
+
* robust to marker framing changes across pruner versions.
|
|
569
|
+
*/
|
|
570
|
+
const PRUNE_MARKER_TEXT = "[... tool result middle pruned ...]";
|
|
571
|
+
function textBlocksOf(event) {
|
|
572
|
+
const content = event.data?.message?.content;
|
|
573
|
+
if (!Array.isArray(content)) return [];
|
|
574
|
+
const out = [];
|
|
575
|
+
const walk = (blocks) => {
|
|
576
|
+
for (const block of blocks) {
|
|
577
|
+
if (block === null || typeof block !== "object") continue;
|
|
578
|
+
const record = block;
|
|
579
|
+
if (record.type === "text" && typeof record.text === "string") out.push(record.text);
|
|
580
|
+
else if (Array.isArray(record.content)) walk(record.content);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
walk(content);
|
|
584
|
+
return out;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* The load seqs whose paired tool result sits on the surface TRUNCATED —
|
|
588
|
+
* head and tail visible, middle replaced by the pruner's marker. The model
|
|
589
|
+
* partially sees these skills, so they read as their own state rather than
|
|
590
|
+
* either extreme. Only surface-resident results are inspected; a shadowed
|
|
591
|
+
* result is already reported by shadowedLoadSeqs and its content is
|
|
592
|
+
* irrelevant to the model now.
|
|
593
|
+
*/
|
|
594
|
+
function prunedLoadSeqs(loads, resultSeqByCallId, surfaceSeqs, events) {
|
|
595
|
+
const prunedResults = /* @__PURE__ */ new Set();
|
|
596
|
+
for (const event of events) {
|
|
597
|
+
if (event.type !== "tool/result") continue;
|
|
598
|
+
const seq = event.seq;
|
|
599
|
+
if (typeof seq !== "number" || !surfaceSeqs.has(seq)) continue;
|
|
600
|
+
if (textBlocksOf(event).some((text) => text.includes(PRUNE_MARKER_TEXT))) prunedResults.add(seq);
|
|
601
|
+
}
|
|
602
|
+
const out = /* @__PURE__ */ new Set();
|
|
603
|
+
for (const load of loads) {
|
|
604
|
+
const resultSeq = resultSeqByCallId.get(load.callId);
|
|
605
|
+
if (resultSeq !== void 0 && prunedResults.has(resultSeq)) out.add(load.seq);
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Decide each skill's state.
|
|
611
|
+
*
|
|
612
|
+
* `shadowedSeqs` holds LOAD seqs whose paired tool result is absent from the
|
|
613
|
+
* current surface (see shadowedLoadSeqs for why the result, not the call,
|
|
614
|
+
* carries the verdict); `prunedSeqs` holds load seqs whose result survives on
|
|
615
|
+
* the surface with its middle truncated. Do NOT re-derive either from
|
|
616
|
+
* replacement ranges here: after a replacement lands, a high-seq summary node
|
|
617
|
+
* sits at the shadowed range's *position*, so surface order stops tracking
|
|
618
|
+
* seq order and a numeric `start <= seq <= end` test silently misjudges later
|
|
619
|
+
* compactions.
|
|
620
|
+
*
|
|
621
|
+
* Verified against real data: the middle-pruner had replaced the lark-shared /
|
|
622
|
+
* lark-im / lark-event results with stubs (those now read `pruned`), and a
|
|
623
|
+
* later full-history compaction (one replace over [7..16114]) shadowed those
|
|
624
|
+
* stubs plus the find-skills / git-worktree-discipline results (those read
|
|
625
|
+
* `evicted`).
|
|
626
|
+
*/
|
|
627
|
+
function decideStates(available, loads, shadowedSeqs, disabledSkills = /* @__PURE__ */ new Set(), prunedSeqs = /* @__PURE__ */ new Set()) {
|
|
628
|
+
const byName = /* @__PURE__ */ new Map();
|
|
629
|
+
for (const record of loads) {
|
|
630
|
+
const bucket = byName.get(record.skillName);
|
|
631
|
+
if (bucket === void 0) byName.set(record.skillName, [record]);
|
|
632
|
+
else bucket.push(record);
|
|
633
|
+
}
|
|
634
|
+
return available.map(({ name, description, masked }) => {
|
|
635
|
+
const records = byName.get(name) ?? [];
|
|
636
|
+
let state = "unloaded";
|
|
637
|
+
if (records.length > 0) {
|
|
638
|
+
const current = records.filter((r) => !shadowedSeqs.has(r.seq));
|
|
639
|
+
if (current.length === 0) state = "evicted";
|
|
640
|
+
else state = current.some((r) => !prunedSeqs.has(r.seq)) ? "loaded" : "pruned";
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
name,
|
|
644
|
+
...description === void 0 ? {} : { description },
|
|
645
|
+
state,
|
|
646
|
+
enabled: !disabledSkills.has(name) && masked !== true,
|
|
647
|
+
loadCount: records.length
|
|
648
|
+
};
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
/** Group MCP tools by server: `mcp__<server>__<tool>`. */
|
|
652
|
+
function groupMcpTools(toolNames) {
|
|
653
|
+
const byServer = /* @__PURE__ */ new Map();
|
|
654
|
+
for (const raw of toolNames) {
|
|
655
|
+
if (!raw.startsWith("mcp__")) continue;
|
|
656
|
+
const rest = raw.slice(5);
|
|
657
|
+
const cut = rest.indexOf("__");
|
|
658
|
+
if (cut <= 0) continue;
|
|
659
|
+
const server = rest.slice(0, cut);
|
|
660
|
+
const tool = rest.slice(cut + 2);
|
|
661
|
+
if (tool === "") continue;
|
|
662
|
+
const bucket = byServer.get(server);
|
|
663
|
+
if (bucket === void 0) byServer.set(server, [tool]);
|
|
664
|
+
else bucket.push(tool);
|
|
665
|
+
}
|
|
666
|
+
return [...byServer.entries()].map(([server, tools]) => ({
|
|
667
|
+
server,
|
|
668
|
+
tools: tools.sort()
|
|
669
|
+
})).sort((a, b) => a.server.localeCompare(b.server));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/host/preset-tools.ts
|
|
674
|
+
function requireService(service, message) {
|
|
675
|
+
if (service === void 0) throw new HttpError(503, message);
|
|
676
|
+
return service;
|
|
677
|
+
}
|
|
678
|
+
function toolSummaries(tools, scope) {
|
|
679
|
+
const entries = /* @__PURE__ */ new Map();
|
|
680
|
+
for (const schema of tools.schemas(scope)) {
|
|
681
|
+
if (typeof schema.name !== "string" || schema.name === "" || entries.has(schema.name)) continue;
|
|
682
|
+
entries.set(schema.name, {
|
|
683
|
+
name: schema.name,
|
|
684
|
+
...typeof schema.description === "string" ? { description: schema.description } : {}
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
return [...entries.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Read the skills one preset can see, marking those that came from the reading
|
|
691
|
+
* process's project root.
|
|
692
|
+
*
|
|
693
|
+
* The panel has no agent and therefore no session cwd, so it reports what THIS
|
|
694
|
+
* workspace would contribute. A project skill is marked rather than dropped:
|
|
695
|
+
* hiding it would silently shorten the list, while marking it says plainly
|
|
696
|
+
* that a session opened elsewhere will not see the row.
|
|
697
|
+
*/
|
|
698
|
+
async function presetSkillRows(skills, scope, disabled, cwd) {
|
|
699
|
+
const seen = /* @__PURE__ */ new Map();
|
|
700
|
+
const add = (summaries, project) => {
|
|
701
|
+
for (const summary of summaries) {
|
|
702
|
+
if (typeof summary.name !== "string" || summary.name === "" || seen.has(summary.name)) continue;
|
|
703
|
+
const description = typeof summary.description === "string" ? summary.description : void 0;
|
|
704
|
+
seen.set(summary.name, {
|
|
705
|
+
name: summary.name,
|
|
706
|
+
...description === void 0 ? {} : { description },
|
|
707
|
+
enabled: !disabled.has(summary.name),
|
|
708
|
+
...project ? { project: true } : {}
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
add(await skills.list({ scope }), false);
|
|
713
|
+
add(await skills.list({
|
|
714
|
+
scope,
|
|
715
|
+
cwd
|
|
716
|
+
}), true);
|
|
717
|
+
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
718
|
+
}
|
|
719
|
+
async function presetAndTools(agentPresets, tools, presetId) {
|
|
720
|
+
const preset = (await agentPresets.list()).find((entry) => entry.id === presetId);
|
|
721
|
+
if (preset === void 0) throw new HttpError(404, `preset "${presetId}" is not available`);
|
|
722
|
+
if (preset.broken !== void 0) throw new HttpError(409, `preset "${presetId}" is broken: ${preset.broken}`);
|
|
723
|
+
try {
|
|
724
|
+
return {
|
|
725
|
+
preset,
|
|
726
|
+
tools: toolSummaries(tools, await agentPresets.standingKeyFor(presetId))
|
|
727
|
+
};
|
|
728
|
+
} catch (error) {
|
|
729
|
+
throw new HttpError(503, `preset "${presetId}" tools are unavailable: ${errorMessage(error)}`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function createPresetToolController(ctx, access) {
|
|
733
|
+
const settingsScope = () => access.scope();
|
|
734
|
+
const services = () => ({
|
|
735
|
+
agentPresets: requireService(ctx.get("agentPresets"), "agentPresets service unavailable"),
|
|
736
|
+
tools: requireService(ctx.get("tools"), "tools service unavailable"),
|
|
737
|
+
settings: requireService(settingsScope(), "settings service unavailable")
|
|
738
|
+
});
|
|
739
|
+
const list = async () => {
|
|
740
|
+
const { agentPresets, tools, settings: settingsScope$1 } = services();
|
|
741
|
+
const stored = settingsScope$1.get();
|
|
742
|
+
const configured = stored.presets;
|
|
743
|
+
const skills = ctx.get("skills");
|
|
744
|
+
const cwd = process.cwd();
|
|
745
|
+
const presets = await agentPresets.list();
|
|
746
|
+
return {
|
|
747
|
+
presets: await Promise.all(presets.map(async (preset) => {
|
|
748
|
+
let entries = [];
|
|
749
|
+
let skillRows = [];
|
|
750
|
+
if (preset.broken === void 0) {
|
|
751
|
+
let scope;
|
|
752
|
+
try {
|
|
753
|
+
scope = await agentPresets.standingKeyFor(preset.id);
|
|
754
|
+
entries = toolSummaries(tools, scope);
|
|
755
|
+
} catch (error) {
|
|
756
|
+
throw new HttpError(503, `preset "${preset.id}" tools are unavailable: ${errorMessage(error)}`);
|
|
757
|
+
}
|
|
758
|
+
if (skills !== void 0) {
|
|
759
|
+
const disabledSkills = new Set(stored.presetSkills[preset.id] ?? []);
|
|
760
|
+
try {
|
|
761
|
+
skillRows = await presetSkillRows(skills, scope, disabledSkills, cwd);
|
|
762
|
+
} catch (error) {
|
|
763
|
+
throw new HttpError(503, `preset "${preset.id}" skills are unavailable: ${errorMessage(error)}`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const disabled = new Set(configured[preset.id] ?? []);
|
|
768
|
+
const byName = new Map(entries.map((entry) => [entry.name, entry]));
|
|
769
|
+
const row = (name, label) => {
|
|
770
|
+
const description = byName.get(name)?.description;
|
|
771
|
+
return {
|
|
772
|
+
name,
|
|
773
|
+
label,
|
|
774
|
+
...description === void 0 ? {} : { description },
|
|
775
|
+
enabled: name === RESERVED_TOOL || !disabled.has(name),
|
|
776
|
+
...name === RESERVED_TOOL ? { reserved: true } : {}
|
|
777
|
+
};
|
|
778
|
+
};
|
|
779
|
+
const mcp = groupMcpTools(entries.map((entry) => entry.name)).map((group) => {
|
|
780
|
+
const tools$1 = group.tools.map((tool) => row(`mcp__${group.server}__${tool}`, tool));
|
|
781
|
+
return {
|
|
782
|
+
server: group.server,
|
|
783
|
+
tools: tools$1,
|
|
784
|
+
enabled: tools$1.some((tool) => tool.enabled)
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
const systemTools = entries.filter((entry) => !entry.name.startsWith("mcp__")).map((entry) => row(entry.name, entry.name));
|
|
788
|
+
return {
|
|
789
|
+
id: preset.id,
|
|
790
|
+
name: preset.name ?? preset.id,
|
|
791
|
+
trust: preset.trust,
|
|
792
|
+
...preset.description === void 0 ? {} : { description: preset.description },
|
|
793
|
+
...preset.broken === void 0 ? {} : { broken: preset.broken },
|
|
794
|
+
skills: skillRows,
|
|
795
|
+
mcp,
|
|
796
|
+
systemTools
|
|
797
|
+
};
|
|
798
|
+
})),
|
|
799
|
+
writable: ctx.get("settings")?.writable === true
|
|
800
|
+
};
|
|
801
|
+
};
|
|
802
|
+
/** Persist one preset's disabled set for one registry, then re-read. */
|
|
803
|
+
const persist = (presetId, settingsScope$1, disabled, registry = "presets") => access.serialize(async () => {
|
|
804
|
+
const stored = settingsScope$1.get();
|
|
805
|
+
const prune = (map) => Object.fromEntries(Object.entries(map).filter(([, value]) => value.length > 0));
|
|
806
|
+
const next = prune({
|
|
807
|
+
...stored[registry],
|
|
808
|
+
[presetId]: [...disabled].sort()
|
|
809
|
+
});
|
|
810
|
+
const presets = registry === "presets" ? next : prune(stored.presets);
|
|
811
|
+
const presetSkills = registry === "presetSkills" ? next : prune(stored.presetSkills);
|
|
812
|
+
await settingsScope$1.replace({
|
|
813
|
+
presets,
|
|
814
|
+
presetSkills,
|
|
815
|
+
sessions: stored.sessions
|
|
816
|
+
});
|
|
817
|
+
return list();
|
|
818
|
+
});
|
|
819
|
+
return {
|
|
820
|
+
defaultsFor(presetId) {
|
|
821
|
+
let stored;
|
|
822
|
+
try {
|
|
823
|
+
const settings = settingsScope();
|
|
824
|
+
if (settings === void 0) return void 0;
|
|
825
|
+
stored = settings.get();
|
|
826
|
+
} catch {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
tools: (stored.presets[presetId] ?? []).filter((name) => name !== RESERVED_TOOL),
|
|
831
|
+
skills: stored.presetSkills[presetId] ?? []
|
|
832
|
+
};
|
|
833
|
+
},
|
|
834
|
+
list,
|
|
835
|
+
async set(presetId, name, enabled) {
|
|
836
|
+
if (name === RESERVED_TOOL && !enabled) throw new HttpError(409, "run_code is the reserved Code Mode transport and cannot be restricted");
|
|
837
|
+
const { agentPresets, tools, settings: settingsScope$1 } = services();
|
|
838
|
+
const { tools: available } = await presetAndTools(agentPresets, tools, presetId);
|
|
839
|
+
if (!available.some((tool) => tool.name === name)) throw new HttpError(404, `tool "${name}" is not available in preset "${presetId}"`);
|
|
840
|
+
const disabled = new Set(settingsScope$1.get().presets[presetId] ?? []);
|
|
841
|
+
if (enabled) disabled.delete(name);
|
|
842
|
+
else disabled.add(name);
|
|
843
|
+
return persist(presetId, settingsScope$1, disabled);
|
|
844
|
+
},
|
|
845
|
+
async setServer(presetId, server, enabled) {
|
|
846
|
+
const { agentPresets, tools, settings: settingsScope$1 } = services();
|
|
847
|
+
const { tools: available } = await presetAndTools(agentPresets, tools, presetId);
|
|
848
|
+
const prefix = `mcp__${server}__`;
|
|
849
|
+
const names = available.map((tool) => tool.name).filter((name) => name.startsWith(prefix));
|
|
850
|
+
if (names.length === 0) throw new HttpError(404, `MCP server "${server}" is not available in preset "${presetId}"`);
|
|
851
|
+
const disabled = new Set(settingsScope$1.get().presets[presetId] ?? []);
|
|
852
|
+
for (const name of names) if (enabled) disabled.delete(name);
|
|
853
|
+
else disabled.add(name);
|
|
854
|
+
return persist(presetId, settingsScope$1, disabled);
|
|
855
|
+
},
|
|
856
|
+
async setSkill(presetId, name, enabled) {
|
|
857
|
+
const { agentPresets, settings: settingsScope$1 } = services();
|
|
858
|
+
const skills = requireService(ctx.get("skills"), "skills service unavailable");
|
|
859
|
+
const preset = (await agentPresets.list()).find((entry) => entry.id === presetId);
|
|
860
|
+
if (preset === void 0) throw new HttpError(404, `preset "${presetId}" is not available`);
|
|
861
|
+
if (preset.broken !== void 0) throw new HttpError(409, `preset "${presetId}" is broken: ${preset.broken}`);
|
|
862
|
+
let visible;
|
|
863
|
+
try {
|
|
864
|
+
visible = await presetSkillRows(skills, await agentPresets.standingKeyFor(presetId), /* @__PURE__ */ new Set(), process.cwd());
|
|
865
|
+
} catch (error) {
|
|
866
|
+
throw new HttpError(503, `preset "${presetId}" skills are unavailable: ${errorMessage(error)}`);
|
|
867
|
+
}
|
|
868
|
+
if (!visible.some((skill) => skill.name === name)) throw new HttpError(404, `skill "${name}" is not available in preset "${presetId}"`);
|
|
869
|
+
const disabled = new Set(settingsScope$1.get().presetSkills[presetId] ?? []);
|
|
870
|
+
if (enabled) disabled.delete(name);
|
|
871
|
+
else disabled.add(name);
|
|
872
|
+
return persist(presetId, settingsScope$1, disabled, "presetSkills");
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region src/loopback.ts
|
|
879
|
+
const LOOPBACK_ADDRS = new Set([
|
|
880
|
+
"127.0.0.1",
|
|
881
|
+
"::1",
|
|
882
|
+
"::ffff:127.0.0.1"
|
|
883
|
+
]);
|
|
884
|
+
/**
|
|
885
|
+
* Loopback-only guard.
|
|
886
|
+
*
|
|
887
|
+
* The connection's peer address is the only trustworthy evidence, and it is the
|
|
888
|
+
* only one that still holds when the host binds to a LAN interface
|
|
889
|
+
* (`--host` / `--trusted-host`). The Host and Origin headers are set by the
|
|
890
|
+
* caller, so they are a fallback used only when the socket is unavailable —
|
|
891
|
+
* with neither present, this fails closed.
|
|
892
|
+
*/
|
|
893
|
+
function isLoopback(req) {
|
|
894
|
+
const addr = req.socket?.remoteAddress;
|
|
895
|
+
if (typeof addr === "string") return LOOPBACK_ADDRS.has(addr);
|
|
896
|
+
const originHeader = req.headers["origin"];
|
|
897
|
+
const origin = typeof originHeader === "string" ? originHeader : "";
|
|
898
|
+
if (origin === "") {
|
|
899
|
+
const hostHeader = req.headers["host"];
|
|
900
|
+
const host = typeof hostHeader === "string" ? hostHeader : "";
|
|
901
|
+
return /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(host);
|
|
902
|
+
}
|
|
903
|
+
return /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(origin);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
//#endregion
|
|
907
|
+
//#region src/host/catalog.ts
|
|
908
|
+
const EMPTY_STATE = {
|
|
909
|
+
skills: /* @__PURE__ */ new Map(),
|
|
910
|
+
mcpServers: /* @__PURE__ */ new Map(),
|
|
911
|
+
mcpTools: /* @__PURE__ */ new Map(),
|
|
912
|
+
systemTools: /* @__PURE__ */ new Map(),
|
|
913
|
+
userToggled: /* @__PURE__ */ new Set()
|
|
914
|
+
};
|
|
915
|
+
async function readAvailable(services, sessionId, degraded) {
|
|
916
|
+
const skills = services.get("skills");
|
|
917
|
+
if (skills === void 0) {
|
|
918
|
+
degraded.push("skills service unavailable");
|
|
919
|
+
return [];
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
const agents = services.get("agents");
|
|
923
|
+
if (agents === void 0) {
|
|
924
|
+
degraded.push("agents service unavailable: session skill view cannot be determined");
|
|
925
|
+
return [];
|
|
926
|
+
}
|
|
927
|
+
const agent = agents.get(sessionId);
|
|
928
|
+
if (agent === void 0) {
|
|
929
|
+
degraded.push(`session agent "${sessionId}" unavailable: session skill view cannot be determined`);
|
|
930
|
+
return [];
|
|
931
|
+
}
|
|
932
|
+
const cwd = agent.session?.header?.cwd;
|
|
933
|
+
const list = await skills.list({
|
|
934
|
+
...cwd === void 0 ? {} : { cwd },
|
|
935
|
+
scope: agent
|
|
936
|
+
});
|
|
937
|
+
const out = [];
|
|
938
|
+
const seen = /* @__PURE__ */ new Map();
|
|
939
|
+
for (const item of list) {
|
|
940
|
+
if (typeof item.name !== "string" || item.name === "") continue;
|
|
941
|
+
const masked = item.invocation?.modelInvocable === false;
|
|
942
|
+
const existing = seen.get(item.name);
|
|
943
|
+
if (existing !== void 0) {
|
|
944
|
+
if (masked) existing.masked = true;
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
const description = typeof item.description === "string" ? item.description : void 0;
|
|
948
|
+
const row = {
|
|
949
|
+
name: item.name,
|
|
950
|
+
...description === void 0 ? {} : { description },
|
|
951
|
+
...masked ? { masked: true } : {}
|
|
952
|
+
};
|
|
953
|
+
seen.set(item.name, row);
|
|
954
|
+
out.push(row);
|
|
955
|
+
}
|
|
956
|
+
return out;
|
|
957
|
+
} catch (error) {
|
|
958
|
+
degraded.push(`skills read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
959
|
+
return [];
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Read load facts off the LIVE session's in-memory log — the same object the
|
|
964
|
+
* agent loop itself reads to assemble the next request.
|
|
965
|
+
*
|
|
966
|
+
* This used to go through `sessionQuery.readSession` + `listEvents`, which are
|
|
967
|
+
* built for cross-session/cold reads: each call structuredClones the ENTIRE
|
|
968
|
+
* event log, `readSession` additionally replay-validates it via Session.create,
|
|
969
|
+
* and a write landing between the two parallel reads forced a whole second
|
|
970
|
+
* round. On a long session (60k events / tens of MB) that meant four full-log
|
|
971
|
+
* clones plus two full replays of synchronous CPU work on every panel open —
|
|
972
|
+
* blocking the Node event loop and freezing the GUI served by the same process.
|
|
973
|
+
*
|
|
974
|
+
* The live session needs none of that: `snapshotEvents()` hands back borrowed
|
|
975
|
+
* references (zero-copy), and `surface.nodes` is maintained incrementally as
|
|
976
|
+
* events land, so "what the model sees right now" is an O(1) membership test.
|
|
977
|
+
* Scanning references for the few `skill` tool calls costs microseconds even
|
|
978
|
+
* on the longest log. Both reads happen in one synchronous tick, so the view
|
|
979
|
+
* is always self-consistent — no cross-read race, no retry loop.
|
|
980
|
+
*
|
|
981
|
+
* A session without a live in-memory view (e.g. restored but not yet attached)
|
|
982
|
+
* degrades honestly instead of paying for a cold-log read the panel never
|
|
983
|
+
* asked for.
|
|
984
|
+
*/
|
|
985
|
+
function readLogFacts(services, sessionId, degraded) {
|
|
986
|
+
const empty = {
|
|
987
|
+
loads: [],
|
|
988
|
+
shadowed: /* @__PURE__ */ new Set(),
|
|
989
|
+
pruned: /* @__PURE__ */ new Set()
|
|
990
|
+
};
|
|
991
|
+
try {
|
|
992
|
+
const session = services.get("agents")?.get(sessionId)?.session;
|
|
993
|
+
const nodes = session?.surface?.nodes;
|
|
994
|
+
if (session === void 0 || typeof session.snapshotEvents !== "function" || !Array.isArray(nodes)) {
|
|
995
|
+
degraded.push("live session view unavailable: load states cannot be determined");
|
|
996
|
+
return empty;
|
|
997
|
+
}
|
|
998
|
+
const events = session.snapshotEvents();
|
|
999
|
+
if (!Array.isArray(events)) {
|
|
1000
|
+
degraded.push("snapshotEvents() returned an unexpected shape; cannot read skill loads");
|
|
1001
|
+
return empty;
|
|
1002
|
+
}
|
|
1003
|
+
const surfaceSeqs = /* @__PURE__ */ new Set();
|
|
1004
|
+
for (const seq of nodes) if (typeof seq === "number") surfaceSeqs.add(seq);
|
|
1005
|
+
const loads = collectLoadRecords(events);
|
|
1006
|
+
const resultSeqs = indexToolResultSeqs(events);
|
|
1007
|
+
return {
|
|
1008
|
+
loads,
|
|
1009
|
+
shadowed: shadowedLoadSeqs(loads, resultSeqs, surfaceSeqs),
|
|
1010
|
+
pruned: prunedLoadSeqs(loads, resultSeqs, surfaceSeqs, events)
|
|
1011
|
+
};
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
degraded.push(`event read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1014
|
+
return empty;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
function readMcp(services, degraded, disabledServers, disabledTools) {
|
|
1018
|
+
const tools = services.get("tools");
|
|
1019
|
+
if (tools === void 0) {
|
|
1020
|
+
degraded.push("tools service unavailable");
|
|
1021
|
+
return [];
|
|
1022
|
+
}
|
|
1023
|
+
try {
|
|
1024
|
+
const names = [];
|
|
1025
|
+
const descriptions = /* @__PURE__ */ new Map();
|
|
1026
|
+
for (const schema of tools.schemas()) {
|
|
1027
|
+
if (typeof schema.name !== "string") continue;
|
|
1028
|
+
names.push(schema.name);
|
|
1029
|
+
if (typeof schema.description === "string" && schema.description !== "") descriptions.set(schema.name, schema.description);
|
|
1030
|
+
}
|
|
1031
|
+
return groupMcpTools(names).map((group) => {
|
|
1032
|
+
const enabled = !disabledServers.has(group.server);
|
|
1033
|
+
const entries = group.tools.map((tool) => {
|
|
1034
|
+
const name = `mcp__${group.server}__${tool}`;
|
|
1035
|
+
const description = descriptions.get(name);
|
|
1036
|
+
return {
|
|
1037
|
+
name,
|
|
1038
|
+
label: tool,
|
|
1039
|
+
...description === void 0 ? {} : { description },
|
|
1040
|
+
enabled: enabled && !disabledTools.has(name)
|
|
1041
|
+
};
|
|
1042
|
+
});
|
|
1043
|
+
return {
|
|
1044
|
+
server: group.server,
|
|
1045
|
+
tools: entries,
|
|
1046
|
+
enabled
|
|
1047
|
+
};
|
|
1048
|
+
});
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
degraded.push(`tool read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1051
|
+
return [];
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
function readSystemTools(services, degraded, disabledTools, agent) {
|
|
1055
|
+
const tools = services.get("tools");
|
|
1056
|
+
if (tools === void 0) {
|
|
1057
|
+
if (!degraded.includes("tools service unavailable")) degraded.push("tools service unavailable");
|
|
1058
|
+
return [];
|
|
1059
|
+
}
|
|
1060
|
+
try {
|
|
1061
|
+
let reachable;
|
|
1062
|
+
if (agent !== void 0) {
|
|
1063
|
+
reachable = /* @__PURE__ */ new Set();
|
|
1064
|
+
for (const schema of tools.schemas(agent)) if (typeof schema.name === "string") reachable.add(schema.name);
|
|
1065
|
+
}
|
|
1066
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1067
|
+
const collect = (scope) => {
|
|
1068
|
+
for (const schema of tools.schemas(scope)) {
|
|
1069
|
+
if (typeof schema.name !== "string" || schema.name.startsWith("mcp__") || byName.has(schema.name)) continue;
|
|
1070
|
+
const description = typeof schema.description === "string" && schema.description !== "" ? schema.description : void 0;
|
|
1071
|
+
byName.set(schema.name, {
|
|
1072
|
+
name: schema.name,
|
|
1073
|
+
label: schema.name,
|
|
1074
|
+
...description === void 0 ? {} : { description },
|
|
1075
|
+
enabled: !disabledTools.has(schema.name) && (reachable === void 0 || reachable.has(schema.name)),
|
|
1076
|
+
...schema.name === RESERVED_TOOL ? { reserved: true } : {}
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
collect(void 0);
|
|
1081
|
+
if (agent !== void 0) collect(agent);
|
|
1082
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
degraded.push(`tool read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1085
|
+
return [];
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
async function buildPayload(services, sessionId, capabilityState = EMPTY_STATE, blocked = {}) {
|
|
1089
|
+
const degraded = [];
|
|
1090
|
+
const disabledSkills = new Set(capabilityState.skills.keys());
|
|
1091
|
+
const disabledServers = new Set(capabilityState.mcpServers.keys());
|
|
1092
|
+
const disabledTools = new Set(capabilityState.mcpTools.keys());
|
|
1093
|
+
const disabledSystem = new Set(capabilityState.systemTools.keys());
|
|
1094
|
+
if (sessionId === null) return {
|
|
1095
|
+
sessionId: null,
|
|
1096
|
+
skills: [],
|
|
1097
|
+
mcp: readMcp(services, degraded, disabledServers, disabledTools),
|
|
1098
|
+
systemTools: readSystemTools(services, degraded, disabledSystem),
|
|
1099
|
+
blocked,
|
|
1100
|
+
...degraded.length > 0 ? { degraded } : {}
|
|
1101
|
+
};
|
|
1102
|
+
const agent = services.get("agents")?.get(sessionId);
|
|
1103
|
+
const available = await readAvailable(services, sessionId, degraded);
|
|
1104
|
+
const logFacts = readLogFacts(services, sessionId, degraded);
|
|
1105
|
+
return {
|
|
1106
|
+
sessionId,
|
|
1107
|
+
skills: decideStates(available, logFacts.loads, logFacts.shadowed, disabledSkills, logFacts.pruned),
|
|
1108
|
+
mcp: readMcp(services, degraded, disabledServers, disabledTools),
|
|
1109
|
+
systemTools: readSystemTools(services, degraded, disabledSystem, agent),
|
|
1110
|
+
blocked,
|
|
1111
|
+
...degraded.length > 0 ? { degraded } : {}
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
//#endregion
|
|
1116
|
+
//#region src/host/route.ts
|
|
1117
|
+
const ROUTE = "/api/capability-panel";
|
|
1118
|
+
const KINDS = [
|
|
1119
|
+
"skill",
|
|
1120
|
+
"mcp-server",
|
|
1121
|
+
"mcp-tool",
|
|
1122
|
+
"system-tool"
|
|
1123
|
+
];
|
|
1124
|
+
var ClientRequestError = class extends HttpError {
|
|
1125
|
+
constructor(message, status = 400) {
|
|
1126
|
+
super(status, message);
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
function readRequestBody(req) {
|
|
1130
|
+
return new Promise((resolve, reject) => {
|
|
1131
|
+
if (req.on === void 0) {
|
|
1132
|
+
reject(new ClientRequestError("request body stream unavailable"));
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
let body = "";
|
|
1136
|
+
let settled = false;
|
|
1137
|
+
const fail = (error) => {
|
|
1138
|
+
if (settled) return;
|
|
1139
|
+
settled = true;
|
|
1140
|
+
reject(error instanceof ClientRequestError ? error : new Error(String(error)));
|
|
1141
|
+
};
|
|
1142
|
+
req.on("data", (chunk) => {
|
|
1143
|
+
if (settled) return;
|
|
1144
|
+
body += String(chunk);
|
|
1145
|
+
if (body.length > 16384) fail(new ClientRequestError("request body too large", 413));
|
|
1146
|
+
});
|
|
1147
|
+
req.on("end", () => {
|
|
1148
|
+
if (settled) return;
|
|
1149
|
+
settled = true;
|
|
1150
|
+
try {
|
|
1151
|
+
resolve(body === "" ? {} : JSON.parse(body));
|
|
1152
|
+
} catch {
|
|
1153
|
+
reject(new ClientRequestError("invalid JSON body"));
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
req.on("error", fail);
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Every response here reflects live process state, so `no-store` is the
|
|
1161
|
+
* default and opting out is explicit. The flag reads as what it does: the
|
|
1162
|
+
* previous spelling was `cache = true` on the ERROR branch, which left a
|
|
1163
|
+
* transient 503 as the only cacheable response the route produced.
|
|
1164
|
+
*/
|
|
1165
|
+
function json(res, status, body, allowCaching = false) {
|
|
1166
|
+
res.writeHead(status, {
|
|
1167
|
+
"content-type": "application/json; charset=utf-8",
|
|
1168
|
+
...allowCaching ? {} : { "cache-control": "no-store" }
|
|
1169
|
+
});
|
|
1170
|
+
res.end(JSON.stringify(body));
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* `kind` mirrors the session route's toggle shape: one tool, or a whole MCP
|
|
1174
|
+
* server in a single write.
|
|
1175
|
+
*/
|
|
1176
|
+
function validatePresetToggle(body) {
|
|
1177
|
+
if (body === null || typeof body !== "object") throw new ClientRequestError("invalid request body");
|
|
1178
|
+
const record = body;
|
|
1179
|
+
if (typeof record.presetId !== "string" || record.presetId === "") throw new ClientRequestError("presetId is required");
|
|
1180
|
+
const kind = record.kind ?? "tool";
|
|
1181
|
+
if (kind !== "tool" && kind !== "mcp-server" && kind !== "skill") throw new ClientRequestError("kind must be \"tool\", \"mcp-server\" or \"skill\"");
|
|
1182
|
+
if (typeof record.name !== "string" || record.name === "") throw new ClientRequestError("name is required");
|
|
1183
|
+
if (typeof record.enabled !== "boolean") throw new ClientRequestError("enabled must be boolean");
|
|
1184
|
+
return {
|
|
1185
|
+
presetId: record.presetId,
|
|
1186
|
+
kind,
|
|
1187
|
+
name: record.name,
|
|
1188
|
+
enabled: record.enabled
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
function validatePresetContentType(req, res) {
|
|
1192
|
+
const contentType = req.headers["content-type"];
|
|
1193
|
+
if (typeof contentType === "string" && contentType.startsWith("application/json")) return true;
|
|
1194
|
+
res.writeHead(415, { "content-type": "text/plain; charset=utf-8" });
|
|
1195
|
+
res.end("expected application/json");
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
function validateToggle(sessionId, body) {
|
|
1199
|
+
if (sessionId === null) throw new ClientRequestError("session is required");
|
|
1200
|
+
if (body === null || typeof body !== "object") throw new ClientRequestError("invalid request body");
|
|
1201
|
+
const record = body;
|
|
1202
|
+
if (typeof record.kind !== "string" || !KINDS.includes(record.kind) || typeof record.enabled !== "boolean") throw new ClientRequestError("kind must be skill, mcp-server, mcp-tool or system-tool and enabled must be boolean");
|
|
1203
|
+
if (typeof record.name !== "string" || record.name === "") throw new ClientRequestError("name is required");
|
|
1204
|
+
return {
|
|
1205
|
+
sessionId,
|
|
1206
|
+
kind: record.kind,
|
|
1207
|
+
name: record.name,
|
|
1208
|
+
enabled: record.enabled
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
function createRouteHandler(services, capabilities, stats, blockedCounts, presetTools, sessionOverrides) {
|
|
1212
|
+
return async (req, res) => {
|
|
1213
|
+
if (!isLoopback(req)) {
|
|
1214
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
1215
|
+
res.end("forbidden");
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
const url = new URL(req.url ?? "/", "http://dsh.local");
|
|
1220
|
+
if (url.pathname === `${ROUTE}/presets`) {
|
|
1221
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
1222
|
+
res.writeHead(405, {
|
|
1223
|
+
allow: "GET, POST",
|
|
1224
|
+
"content-type": "text/plain; charset=utf-8"
|
|
1225
|
+
});
|
|
1226
|
+
res.end("method not allowed");
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (req.method === "POST") {
|
|
1230
|
+
if (!validatePresetContentType(req, res)) return;
|
|
1231
|
+
const toggle = validatePresetToggle(await readRequestBody(req));
|
|
1232
|
+
json(res, 200, toggle.kind === "skill" ? await presetTools.setSkill(toggle.presetId, toggle.name, toggle.enabled) : toggle.kind === "mcp-server" ? await presetTools.setServer(toggle.presetId, toggle.name, toggle.enabled) : await presetTools.set(toggle.presetId, toggle.name, toggle.enabled));
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
json(res, 200, await presetTools.list());
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (url.pathname === `${ROUTE}/stats`) {
|
|
1239
|
+
if (req.method !== "GET") {
|
|
1240
|
+
res.writeHead(405, {
|
|
1241
|
+
allow: "GET",
|
|
1242
|
+
"content-type": "text/plain; charset=utf-8"
|
|
1243
|
+
});
|
|
1244
|
+
res.end("method not allowed");
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const snapshot = stats.read();
|
|
1248
|
+
json(res, 200, {
|
|
1249
|
+
logFile: stats.file,
|
|
1250
|
+
blocked: blockedCounts,
|
|
1251
|
+
records: snapshot.records,
|
|
1252
|
+
...snapshot.warnings.length > 0 ? { warnings: snapshot.warnings } : {}
|
|
1253
|
+
}, true);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (url.pathname !== ROUTE && url.pathname !== "/") {
|
|
1257
|
+
res.writeHead(404, {
|
|
1258
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1259
|
+
"cache-control": "no-store"
|
|
1260
|
+
});
|
|
1261
|
+
res.end("not found");
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
1265
|
+
res.writeHead(405, {
|
|
1266
|
+
allow: "GET, POST",
|
|
1267
|
+
"content-type": "text/plain; charset=utf-8"
|
|
1268
|
+
});
|
|
1269
|
+
res.end("method not allowed");
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const sessionId = url.searchParams.get("session");
|
|
1273
|
+
let persistNote;
|
|
1274
|
+
if (req.method === "POST") {
|
|
1275
|
+
const contentType = req.headers["content-type"];
|
|
1276
|
+
if (typeof contentType !== "string" || !contentType.startsWith("application/json")) {
|
|
1277
|
+
res.writeHead(415, { "content-type": "text/plain; charset=utf-8" });
|
|
1278
|
+
res.end("expected application/json");
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const toggle = validateToggle(sessionId, await readRequestBody(req));
|
|
1282
|
+
await capabilities.set(toggle.sessionId, toggle.kind, toggle.name, toggle.enabled);
|
|
1283
|
+
try {
|
|
1284
|
+
await sessionOverrides.record(toggle.sessionId, toggle.kind, toggle.name, toggle.enabled);
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
persistNote = `switch applied for this session but could not be persisted across a restart: ${errorMessage(error)}`;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
const payload = await buildPayload(services, sessionId, sessionId === null ? EMPTY_STATE : capabilities.state(sessionId) ?? EMPTY_STATE, blockedCounts);
|
|
1290
|
+
json(res, 200, persistNote === void 0 ? payload : {
|
|
1291
|
+
...payload,
|
|
1292
|
+
degraded: [...payload.degraded ?? [], persistNote]
|
|
1293
|
+
});
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
json(res, error instanceof HttpError ? error.status : 500, { error: errorMessage(error) });
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
//#endregion
|
|
1301
|
+
//#region src/host/session-overrides.ts
|
|
1302
|
+
/**
|
|
1303
|
+
* Session-bound switch positions, persisted in the capability-panel settings namespace
|
|
1304
|
+
* so a restored session gets its own toggles back after a restart.
|
|
1305
|
+
*
|
|
1306
|
+
* The binding is the session id: a record is read only by the session that
|
|
1307
|
+
* made it, so one session's switch can never leak into another. What changes
|
|
1308
|
+
* at a restart is only WHEN it is read — the masks themselves are re-applied
|
|
1309
|
+
* by the same capability controller the panel uses, over the fresh agent.
|
|
1310
|
+
*
|
|
1311
|
+
* The store is a record of user intent, not a snapshot of derived state: an
|
|
1312
|
+
* explicit `true` matters only when a preset default masks the name, and
|
|
1313
|
+
* recording every toggle's final value keeps the write path dumb (no preset
|
|
1314
|
+
* lookup needed) while restore order (defaults first, overrides second) makes
|
|
1315
|
+
* the user's last word win.
|
|
1316
|
+
*/
|
|
1317
|
+
/** Kind keys map 1:1 onto SessionOverrideState's fields. */
|
|
1318
|
+
const KIND_KEYS = {
|
|
1319
|
+
skill: "skills",
|
|
1320
|
+
"mcp-server": "mcpServers",
|
|
1321
|
+
"mcp-tool": "mcpTools",
|
|
1322
|
+
"system-tool": "systemTools"
|
|
1323
|
+
};
|
|
1324
|
+
/**
|
|
1325
|
+
* Retained session records cap. A record costs a handful of names, and a
|
|
1326
|
+
* session the user never revisits is pruned oldest-first — the alternative
|
|
1327
|
+
* (unbounded growth in a settings file) trades a real read cost for a
|
|
1328
|
+
* scenario nobody has.
|
|
1329
|
+
*/
|
|
1330
|
+
const MAX_SESSION_RECORDS = 200;
|
|
1331
|
+
function createSessionOverrideStore(access) {
|
|
1332
|
+
return {
|
|
1333
|
+
overridesFor(sessionId) {
|
|
1334
|
+
try {
|
|
1335
|
+
return access.scope()?.get().sessions[sessionId];
|
|
1336
|
+
} catch {
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
},
|
|
1340
|
+
async record(sessionId, kind, name, enabled) {
|
|
1341
|
+
const scope = access.scope();
|
|
1342
|
+
if (scope === void 0) throw new HttpError(503, "settings service unavailable");
|
|
1343
|
+
await access.serialize(async () => {
|
|
1344
|
+
const stored = scope.get();
|
|
1345
|
+
const kindKey = KIND_KEYS[kind];
|
|
1346
|
+
const current = stored.sessions[sessionId];
|
|
1347
|
+
const next = {
|
|
1348
|
+
skills: { ...current?.skills },
|
|
1349
|
+
mcpServers: { ...current?.mcpServers },
|
|
1350
|
+
mcpTools: { ...current?.mcpTools },
|
|
1351
|
+
systemTools: { ...current?.systemTools },
|
|
1352
|
+
[kindKey]: {
|
|
1353
|
+
...current?.[kindKey],
|
|
1354
|
+
[name]: enabled
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
const entries = Object.entries(stored.sessions).filter(([id]) => id !== sessionId);
|
|
1358
|
+
entries.push([sessionId, next]);
|
|
1359
|
+
const sessions = Object.fromEntries(entries.slice(-MAX_SESSION_RECORDS));
|
|
1360
|
+
await scope.replace({
|
|
1361
|
+
presets: stored.presets,
|
|
1362
|
+
presetSkills: stored.presetSkills,
|
|
1363
|
+
sessions
|
|
1364
|
+
});
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
//#endregion
|
|
1371
|
+
//#region src/host/stats-store.ts
|
|
1372
|
+
function statsFilePath(environment = process.env, home = homedir()) {
|
|
1373
|
+
return join(environment["DSH_HOME"] ?? join(home, ".dsh"), "capability-panel", "stats.jsonl");
|
|
1374
|
+
}
|
|
1375
|
+
function isEnoent(error) {
|
|
1376
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
1377
|
+
}
|
|
1378
|
+
function createStatsStore(file = statsFilePath()) {
|
|
1379
|
+
const appendWarnings = [];
|
|
1380
|
+
return {
|
|
1381
|
+
file,
|
|
1382
|
+
read() {
|
|
1383
|
+
let text;
|
|
1384
|
+
try {
|
|
1385
|
+
text = readFileSync(file, "utf8");
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
if (isEnoent(error)) return {
|
|
1388
|
+
records: [],
|
|
1389
|
+
blocked: {},
|
|
1390
|
+
warnings: [...appendWarnings]
|
|
1391
|
+
};
|
|
1392
|
+
return {
|
|
1393
|
+
records: [],
|
|
1394
|
+
blocked: {},
|
|
1395
|
+
warnings: [`stats read failed: ${String(error)}`, ...appendWarnings]
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
const records = [];
|
|
1399
|
+
const warnings = [...appendWarnings];
|
|
1400
|
+
const validLines = [];
|
|
1401
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
1402
|
+
if (line.trim() === "") continue;
|
|
1403
|
+
try {
|
|
1404
|
+
const parsed = JSON.parse(line);
|
|
1405
|
+
records.push(parsed);
|
|
1406
|
+
validLines.push(line);
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
warnings.push(`stats line ${index + 1} skipped: ${String(error)}`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return {
|
|
1412
|
+
records,
|
|
1413
|
+
blocked: aggregateBlocked(validLines),
|
|
1414
|
+
warnings
|
|
1415
|
+
};
|
|
1416
|
+
},
|
|
1417
|
+
append(record) {
|
|
1418
|
+
try {
|
|
1419
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1420
|
+
appendFileSync(file, `${JSON.stringify(record)}\n`);
|
|
1421
|
+
return null;
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
const warning = `stats append failed: ${String(error)}`;
|
|
1424
|
+
appendWarnings.push(warning);
|
|
1425
|
+
return warning;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
//#endregion
|
|
1432
|
+
//#region src/index.ts
|
|
1433
|
+
/** Host composition root: construct stores/controllers and register the route. */
|
|
1434
|
+
function apply(ctx) {
|
|
1435
|
+
const webServer = ctx.webServer;
|
|
1436
|
+
if (webServer === void 0) return;
|
|
1437
|
+
const stats = createStatsStore();
|
|
1438
|
+
const blockedCounts = stats.read().blocked;
|
|
1439
|
+
const appendStats = (record) => {
|
|
1440
|
+
stats.append(record);
|
|
1441
|
+
};
|
|
1442
|
+
const capabilities = createCapabilityController(ctx, appendStats, blockedCounts);
|
|
1443
|
+
const settingsAccess = createToolkitSettingsAccess(ctx);
|
|
1444
|
+
const presetTools = createPresetToolController(ctx, settingsAccess);
|
|
1445
|
+
const sessionOverrides = createSessionOverrideStore(settingsAccess);
|
|
1446
|
+
registerPresetEnforcement(ctx, capabilities, presetTools, sessionOverrides);
|
|
1447
|
+
const handler = createRouteHandler(ctx, capabilities, stats, blockedCounts, presetTools, sessionOverrides);
|
|
1448
|
+
ctx.effect(() => webServer.register({
|
|
1449
|
+
kind: "prefix",
|
|
1450
|
+
path: ROUTE,
|
|
1451
|
+
handler
|
|
1452
|
+
}), "capability-panel: data route");
|
|
1453
|
+
}
|
|
1454
|
+
const inject = ["webServer"];
|
|
1455
|
+
|
|
1456
|
+
//#endregion
|
|
1457
|
+
export { apply, inject };
|
|
1458
|
+
//# sourceMappingURL=index.js.map
|