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/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