dsh-capability-panel 1.1.0 → 1.2.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/README.i18n.yaml +4 -4
- package/README.ja.md +1 -0
- package/README.ko.md +1 -0
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +230 -39
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +31 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +517 -233
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -74,6 +74,198 @@ function aggregateBlocked(lines) {
|
|
|
74
74
|
return counts;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/load-state.ts
|
|
79
|
+
/** Pull the skill name out of a `skill` tool call's stringified arguments. */
|
|
80
|
+
function skillNameOf(args) {
|
|
81
|
+
if (typeof args !== "string") return null;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(args);
|
|
84
|
+
if (parsed !== null && typeof parsed === "object" && "name" in parsed) {
|
|
85
|
+
const name = parsed.name;
|
|
86
|
+
return typeof name === "string" && name !== "" ? name : null;
|
|
87
|
+
}
|
|
88
|
+
} catch {}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function collectLoadRecords(events) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const event of events) {
|
|
94
|
+
if (event.type !== "tool/call") continue;
|
|
95
|
+
if (event.data?.name !== "skill") continue;
|
|
96
|
+
const skillName = skillNameOf(event.data.arguments);
|
|
97
|
+
if (skillName === null) continue;
|
|
98
|
+
const seq = event.seq;
|
|
99
|
+
if (typeof seq !== "number") continue;
|
|
100
|
+
out.push({
|
|
101
|
+
seq,
|
|
102
|
+
skillName,
|
|
103
|
+
callId: event.data.callId ?? ""
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Map a tool result's pairing callId to its seq.
|
|
110
|
+
*
|
|
111
|
+
* Verified against real logs: `tool/result` events carry no
|
|
112
|
+
* top-level callId; the pairing lives at `data.message.source.callId` and
|
|
113
|
+
* matches the call's `data.callId` exactly.
|
|
114
|
+
*
|
|
115
|
+
* Last write wins on purpose: the middle-pruner appends a stub `tool/result`
|
|
116
|
+
* carrying the SAME callId and a replace surfaceOp over the original's seq
|
|
117
|
+
* (verified against a real compacted session), so the callId resolves to the
|
|
118
|
+
* stub — the node whose fold verdict actually tracks the surface position.
|
|
119
|
+
*/
|
|
120
|
+
function indexToolResultSeqs(events) {
|
|
121
|
+
const out = /* @__PURE__ */ new Map();
|
|
122
|
+
for (const event of events) {
|
|
123
|
+
if (event.type !== "tool/result") continue;
|
|
124
|
+
const callId = event.data?.message?.source?.callId;
|
|
125
|
+
const seq = event.seq;
|
|
126
|
+
if (typeof callId !== "string" || callId === "" || typeof seq !== "number") continue;
|
|
127
|
+
out.set(callId, seq);
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The load seqs whose skill content is gone from the model surface.
|
|
133
|
+
*
|
|
134
|
+
* A `tool/call` never joins the surface itself — SURFACE_EVENT_TYPES in
|
|
135
|
+
* dsh-session is exactly { user/message, assistant/message, tool/result }, and
|
|
136
|
+
* real skill calls carry `surfaceOp: null`. What the model actually sees of a
|
|
137
|
+
* skill is its tool RESULT (a surface node), so eviction keys on the paired
|
|
138
|
+
* result's surface membership, not on the call's position.
|
|
139
|
+
*
|
|
140
|
+
* `surfaceSeqs` is the live session's CURRENT surface (`session.surface.nodes`),
|
|
141
|
+
* which the session maintains incrementally — reading it is O(1), no log fold
|
|
142
|
+
* is ever run for this panel. A paired result seq absent from that set was
|
|
143
|
+
* displaced by a prune stub's or a summary's `replace` op, i.e. shadowed.
|
|
144
|
+
*
|
|
145
|
+
* A load with no paired result is in flight or its result never landed; it is
|
|
146
|
+
* NOT counted as shadowed, so it reports `loaded` — the honest reading of "the
|
|
147
|
+
* model is about to see it".
|
|
148
|
+
*/
|
|
149
|
+
function shadowedLoadSeqs(loads, resultSeqByCallId, surfaceSeqs) {
|
|
150
|
+
const out = /* @__PURE__ */ new Set();
|
|
151
|
+
for (const load of loads) {
|
|
152
|
+
const resultSeq = resultSeqByCallId.get(load.callId);
|
|
153
|
+
if (resultSeq === void 0) continue;
|
|
154
|
+
if (!surfaceSeqs.has(resultSeq)) out.add(load.seq);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Substring of dsh-compaction-tool-result-pruner's PRUNE_MARKER. Matching on
|
|
160
|
+
* the bracketed phrase (without the surrounding newlines) keeps the check
|
|
161
|
+
* robust to marker framing changes across pruner versions.
|
|
162
|
+
*/
|
|
163
|
+
const PRUNE_MARKER_TEXT = "[... tool result middle pruned ...]";
|
|
164
|
+
function textBlocksOf(event) {
|
|
165
|
+
const content = event.data?.message?.content;
|
|
166
|
+
if (!Array.isArray(content)) return [];
|
|
167
|
+
const out = [];
|
|
168
|
+
const walk = (blocks) => {
|
|
169
|
+
for (const block of blocks) {
|
|
170
|
+
if (block === null || typeof block !== "object") continue;
|
|
171
|
+
const record = block;
|
|
172
|
+
if (record.type === "text" && typeof record.text === "string") out.push(record.text);
|
|
173
|
+
else if (Array.isArray(record.content)) walk(record.content);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
walk(content);
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The load seqs whose paired tool result sits on the surface TRUNCATED —
|
|
181
|
+
* head and tail visible, middle replaced by the pruner's marker. The model
|
|
182
|
+
* partially sees these skills, so they read as their own state rather than
|
|
183
|
+
* either extreme. Only surface-resident results are inspected; a shadowed
|
|
184
|
+
* result is already reported by shadowedLoadSeqs and its content is
|
|
185
|
+
* irrelevant to the model now.
|
|
186
|
+
*/
|
|
187
|
+
function prunedLoadSeqs(loads, resultSeqByCallId, surfaceSeqs, events) {
|
|
188
|
+
const prunedResults = /* @__PURE__ */ new Set();
|
|
189
|
+
for (const event of events) {
|
|
190
|
+
if (event.type !== "tool/result") continue;
|
|
191
|
+
const seq = event.seq;
|
|
192
|
+
if (typeof seq !== "number" || !surfaceSeqs.has(seq)) continue;
|
|
193
|
+
if (textBlocksOf(event).some((text) => text.includes(PRUNE_MARKER_TEXT))) prunedResults.add(seq);
|
|
194
|
+
}
|
|
195
|
+
const out = /* @__PURE__ */ new Set();
|
|
196
|
+
for (const load of loads) {
|
|
197
|
+
const resultSeq = resultSeqByCallId.get(load.callId);
|
|
198
|
+
if (resultSeq !== void 0 && prunedResults.has(resultSeq)) out.add(load.seq);
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Decide each skill's state.
|
|
204
|
+
*
|
|
205
|
+
* `shadowedSeqs` holds LOAD seqs whose paired tool result is absent from the
|
|
206
|
+
* current surface (see shadowedLoadSeqs for why the result, not the call,
|
|
207
|
+
* carries the verdict); `prunedSeqs` holds load seqs whose result survives on
|
|
208
|
+
* the surface with its middle truncated. Do NOT re-derive either from
|
|
209
|
+
* replacement ranges here: after a replacement lands, a high-seq summary node
|
|
210
|
+
* sits at the shadowed range's *position*, so surface order stops tracking
|
|
211
|
+
* seq order and a numeric `start <= seq <= end` test silently misjudges later
|
|
212
|
+
* compactions.
|
|
213
|
+
*
|
|
214
|
+
* Verified against real data: the middle-pruner had replaced the lark-shared /
|
|
215
|
+
* lark-im / lark-event results with stubs (those now read `pruned`), and a
|
|
216
|
+
* later full-history compaction (one replace over [7..16114]) shadowed those
|
|
217
|
+
* stubs plus the find-skills / git-worktree-discipline results (those read
|
|
218
|
+
* `evicted`).
|
|
219
|
+
*/
|
|
220
|
+
function decideStates(available, loads, shadowedSeqs, disabledSkills = /* @__PURE__ */ new Set(), prunedSeqs = /* @__PURE__ */ new Set()) {
|
|
221
|
+
const byName = /* @__PURE__ */ new Map();
|
|
222
|
+
for (const record of loads) {
|
|
223
|
+
const bucket = byName.get(record.skillName);
|
|
224
|
+
if (bucket === void 0) byName.set(record.skillName, [record]);
|
|
225
|
+
else bucket.push(record);
|
|
226
|
+
}
|
|
227
|
+
return available.map(({ name, description, masked, source, provider, path, group }) => {
|
|
228
|
+
const records = byName.get(name) ?? [];
|
|
229
|
+
let state = "unloaded";
|
|
230
|
+
if (records.length > 0) {
|
|
231
|
+
const current = records.filter((r) => !shadowedSeqs.has(r.seq));
|
|
232
|
+
if (current.length === 0) state = "evicted";
|
|
233
|
+
else state = current.some((r) => !prunedSeqs.has(r.seq)) ? "loaded" : "pruned";
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
name,
|
|
237
|
+
...description === void 0 ? {} : { description },
|
|
238
|
+
state,
|
|
239
|
+
enabled: !disabledSkills.has(name) && masked !== true,
|
|
240
|
+
loadCount: records.length,
|
|
241
|
+
source,
|
|
242
|
+
provider,
|
|
243
|
+
...path === void 0 ? {} : { path },
|
|
244
|
+
...group === void 0 ? {} : { group }
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** Group MCP tools by server: `mcp__<server>__<tool>`. */
|
|
249
|
+
function groupMcpTools(toolNames) {
|
|
250
|
+
const byServer = /* @__PURE__ */ new Map();
|
|
251
|
+
for (const raw of toolNames) {
|
|
252
|
+
if (!raw.startsWith("mcp__")) continue;
|
|
253
|
+
const rest = raw.slice(5);
|
|
254
|
+
const cut = rest.indexOf("__");
|
|
255
|
+
if (cut <= 0) continue;
|
|
256
|
+
const server = rest.slice(0, cut);
|
|
257
|
+
const tool = rest.slice(cut + 2);
|
|
258
|
+
if (tool === "") continue;
|
|
259
|
+
const bucket = byServer.get(server);
|
|
260
|
+
if (bucket === void 0) byServer.set(server, [tool]);
|
|
261
|
+
else bucket.push(tool);
|
|
262
|
+
}
|
|
263
|
+
return [...byServer.entries()].map(([server, tools]) => ({
|
|
264
|
+
server,
|
|
265
|
+
tools: tools.sort()
|
|
266
|
+
})).sort((a, b) => a.server.localeCompare(b.server));
|
|
267
|
+
}
|
|
268
|
+
|
|
77
269
|
//#endregion
|
|
78
270
|
//#region src/host/errors.ts
|
|
79
271
|
var HttpError = class extends Error {
|
|
@@ -239,33 +431,45 @@ function createCapabilityController(ctx, appendStats, blockedCounts) {
|
|
|
239
431
|
const setServer = (sessionId, server, enabled) => {
|
|
240
432
|
const state = stateFor(sessionId);
|
|
241
433
|
const existing = state.mcpServers.get(server);
|
|
242
|
-
if (enabled
|
|
243
|
-
|
|
244
|
-
state.
|
|
434
|
+
if (enabled) {
|
|
435
|
+
const prefix$1 = `mcp__${server}__`;
|
|
436
|
+
for (const [name, dispose] of [...state.mcpTools]) if (name.startsWith(prefix$1)) {
|
|
437
|
+
dispose();
|
|
438
|
+
state.mcpTools.delete(name);
|
|
439
|
+
}
|
|
440
|
+
if (existing !== void 0) {
|
|
441
|
+
existing.dispose();
|
|
442
|
+
state.mcpServers.delete(server);
|
|
443
|
+
}
|
|
245
444
|
return;
|
|
246
445
|
}
|
|
446
|
+
if (existing !== void 0) return;
|
|
247
447
|
const { agent, tools } = getAgentTools(sessionId);
|
|
248
|
-
if (enabled || existing !== void 0) return;
|
|
249
448
|
const toolService = ctx.get("tools");
|
|
250
449
|
if (toolService === void 0) throw new HttpError(503, "tools service unavailable");
|
|
251
450
|
const prefix = `mcp__${server}__`;
|
|
252
451
|
const names = [...toolService.schemas()].map((schema) => schema.name).filter((name) => typeof name === "string" && name.startsWith(prefix));
|
|
253
452
|
if (names.length === 0) throw new HttpError(404, `MCP server "${server}" exposes no tools`);
|
|
254
|
-
state.mcpServers.set(server,
|
|
453
|
+
state.mcpServers.set(server, {
|
|
454
|
+
dispose: tools.restrict({ deny: names }),
|
|
455
|
+
names
|
|
456
|
+
});
|
|
255
457
|
ensurePromptNote(agent, state);
|
|
256
458
|
};
|
|
257
459
|
const setTool = (sessionId, name, enabled, system) => {
|
|
258
460
|
const state = stateFor(sessionId);
|
|
259
461
|
const map = system ? state.systemTools : state.mcpTools;
|
|
260
462
|
const existing = map.get(name);
|
|
261
|
-
if (enabled
|
|
262
|
-
existing
|
|
263
|
-
|
|
463
|
+
if (enabled) {
|
|
464
|
+
if (existing !== void 0) {
|
|
465
|
+
existing();
|
|
466
|
+
map.delete(name);
|
|
467
|
+
}
|
|
264
468
|
return;
|
|
265
469
|
}
|
|
470
|
+
if (existing !== void 0) return;
|
|
266
471
|
if (system && name === RESERVED_TOOL) throw new HttpError(409, "run_code is the reserved Code Mode transport and cannot be restricted");
|
|
267
472
|
const { agent, tools } = getAgentTools(sessionId);
|
|
268
|
-
if (enabled || existing !== void 0) return;
|
|
269
473
|
const toolService = ctx.get("tools");
|
|
270
474
|
if (toolService === void 0) throw new HttpError(503, "tools service unavailable");
|
|
271
475
|
const globalNames = new Set([...toolService.schemas()].map((schema) => schema.name).filter((entry) => typeof entry === "string"));
|
|
@@ -277,17 +481,15 @@ function createCapabilityController(ctx, appendStats, blockedCounts) {
|
|
|
277
481
|
};
|
|
278
482
|
ctx.effect(() => () => {
|
|
279
483
|
for (const state of states.values()) {
|
|
280
|
-
for (const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
state.systemTools
|
|
285
|
-
]) for (const dispose of map.values()) dispose();
|
|
484
|
+
for (const dispose of state.skills.values()) dispose();
|
|
485
|
+
for (const mask of state.mcpServers.values()) mask.dispose();
|
|
486
|
+
for (const dispose of state.mcpTools.values()) dispose();
|
|
487
|
+
for (const dispose of state.systemTools.values()) dispose();
|
|
286
488
|
state.noteDispose?.();
|
|
287
489
|
}
|
|
288
490
|
states.clear();
|
|
289
491
|
}, "capability-panel: capability masks");
|
|
290
|
-
const seed = async (sessionId, defaults) => {
|
|
492
|
+
const seed = async (sessionId, defaults, includeSkills = true) => {
|
|
291
493
|
const agent = ctx.get("agents")?.get(sessionId);
|
|
292
494
|
if (agent === void 0) return;
|
|
293
495
|
let state = states.get(sessionId);
|
|
@@ -305,22 +507,46 @@ function createCapabilityController(ctx, appendStats, blockedCounts) {
|
|
|
305
507
|
for (const schema of toolsService.schemas(agent)) if (typeof schema.name === "string") scopedNames.add(schema.name);
|
|
306
508
|
for (const name of defaults.tools) {
|
|
307
509
|
if (!globalNames.has(name)) continue;
|
|
308
|
-
if (name.startsWith("mcp__"))
|
|
309
|
-
if (state?.mcpTools.has(name) === true) continue;
|
|
310
|
-
ensureState().mcpTools.set(name, scopedTools.restrict({ deny: [name] }));
|
|
311
|
-
maskedAny = true;
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
510
|
+
if (name.startsWith("mcp__")) continue;
|
|
314
511
|
if (name === RESERVED_TOOL || state?.systemTools.has(name) === true) continue;
|
|
315
512
|
if (!scopedNames.has(name)) continue;
|
|
316
513
|
ensureState().systemTools.set(name, scopedTools.restrict({ deny: [name] }));
|
|
317
514
|
ensureGuard();
|
|
318
515
|
maskedAny = true;
|
|
319
516
|
}
|
|
517
|
+
const mcpDefaults = defaults.tools.filter((name) => globalNames.has(name));
|
|
518
|
+
const fullByServer = new Map(groupMcpTools([...globalNames]).map((group) => [group.server, group.tools]));
|
|
519
|
+
for (const group of groupMcpTools(mcpDefaults)) {
|
|
520
|
+
const full = fullByServer.get(group.server);
|
|
521
|
+
if (full.every((tool) => group.tools.includes(tool))) {
|
|
522
|
+
const st = ensureState();
|
|
523
|
+
const prefix = `mcp__${group.server}__`;
|
|
524
|
+
const fullNames = full.map((tool) => `${prefix}${tool}`);
|
|
525
|
+
const existingMask = st.mcpServers.get(group.server);
|
|
526
|
+
if (existingMask !== void 0 && fullNames.every((name) => existingMask.names.includes(name))) continue;
|
|
527
|
+
for (const [name, dispose] of [...st.mcpTools]) if (name.startsWith(prefix)) {
|
|
528
|
+
dispose();
|
|
529
|
+
st.mcpTools.delete(name);
|
|
530
|
+
}
|
|
531
|
+
existingMask?.dispose();
|
|
532
|
+
st.mcpServers.set(group.server, {
|
|
533
|
+
dispose: scopedTools.restrict({ deny: fullNames }),
|
|
534
|
+
names: fullNames
|
|
535
|
+
});
|
|
536
|
+
maskedAny = true;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
for (const tool of group.tools) {
|
|
540
|
+
const name = `mcp__${group.server}__${tool}`;
|
|
541
|
+
if (state?.mcpTools.has(name) === true) continue;
|
|
542
|
+
ensureState().mcpTools.set(name, scopedTools.restrict({ deny: [name] }));
|
|
543
|
+
maskedAny = true;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
320
546
|
}
|
|
321
547
|
const scopedSkills = agent.ctx?.get("skills");
|
|
322
548
|
const skillsService = ctx.get("skills");
|
|
323
|
-
if (scopedSkills !== void 0 && skillsService !== void 0) {
|
|
549
|
+
if (includeSkills && scopedSkills !== void 0 && skillsService !== void 0) {
|
|
324
550
|
const cwd = agent.session?.header?.cwd;
|
|
325
551
|
const lookup = {
|
|
326
552
|
...cwd === void 0 ? {} : { cwd },
|
|
@@ -360,33 +586,70 @@ function createCapabilityController(ctx, appendStats, blockedCounts) {
|
|
|
360
586
|
}
|
|
361
587
|
if (maskedAny) ensurePromptNote(agent, ensureState());
|
|
362
588
|
};
|
|
589
|
+
const mutationQueues = /* @__PURE__ */ new Map();
|
|
590
|
+
const enqueue = (sessionId, op) => {
|
|
591
|
+
const next = (mutationQueues.get(sessionId) ?? Promise.resolve()).then(op, op);
|
|
592
|
+
mutationQueues.set(sessionId, next.then(() => void 0, () => void 0));
|
|
593
|
+
return next;
|
|
594
|
+
};
|
|
595
|
+
const restoreImpl = async (sessionId, overrides) => {
|
|
596
|
+
const groups = [
|
|
597
|
+
["skill", overrides.skills],
|
|
598
|
+
["mcp-server", overrides.mcpServers],
|
|
599
|
+
["mcp-tool", overrides.mcpTools],
|
|
600
|
+
["system-tool", overrides.systemTools]
|
|
601
|
+
];
|
|
602
|
+
for (const [kind, positions] of groups) for (const [name, enabled] of Object.entries(positions)) {
|
|
603
|
+
if (states.get(sessionId)?.userToggled.has(`${kind}:${name}`) === true) continue;
|
|
604
|
+
try {
|
|
605
|
+
if (kind === "skill") await setSkill(sessionId, name, enabled);
|
|
606
|
+
else if (kind === "mcp-server") setServer(sessionId, name, enabled);
|
|
607
|
+
else setTool(sessionId, name, enabled, kind === "system-tool");
|
|
608
|
+
} catch {}
|
|
609
|
+
}
|
|
610
|
+
const st = states.get(sessionId);
|
|
611
|
+
if (st !== void 0 && st.userToggled.size === 0 && st.skills.size === 0 && st.mcpServers.size === 0 && st.mcpTools.size === 0 && st.systemTools.size === 0) {
|
|
612
|
+
st.noteDispose?.();
|
|
613
|
+
delete st.noteDispose;
|
|
614
|
+
states.delete(sessionId);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
363
617
|
return {
|
|
364
618
|
states,
|
|
365
619
|
state: (sessionId) => states.get(sessionId),
|
|
366
|
-
seed,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
["skill", overrides.skills],
|
|
370
|
-
["mcp-server", overrides.mcpServers],
|
|
371
|
-
["mcp-tool", overrides.mcpTools],
|
|
372
|
-
["system-tool", overrides.systemTools]
|
|
373
|
-
];
|
|
374
|
-
for (const [kind, positions] of groups) for (const [name, enabled] of Object.entries(positions)) {
|
|
375
|
-
if (states.get(sessionId)?.userToggled.has(`${kind}:${name}`) === true) continue;
|
|
376
|
-
try {
|
|
377
|
-
if (kind === "skill") await setSkill(sessionId, name, enabled);
|
|
378
|
-
else if (kind === "mcp-server") setServer(sessionId, name, enabled);
|
|
379
|
-
else setTool(sessionId, name, enabled, kind === "system-tool");
|
|
380
|
-
} catch {}
|
|
381
|
-
}
|
|
620
|
+
seed: (sessionId, defaults) => enqueue(sessionId, () => seed(sessionId, defaults)),
|
|
621
|
+
restore: (sessionId, overrides) => enqueue(sessionId, () => restoreImpl(sessionId, overrides)),
|
|
622
|
+
reseed: (sessionId, defaults, overrides) => enqueue(sessionId, async () => {
|
|
382
623
|
const st = states.get(sessionId);
|
|
383
|
-
if (st !== void 0
|
|
624
|
+
if (st !== void 0) {
|
|
625
|
+
for (const dispose of st.systemTools.values()) dispose();
|
|
626
|
+
for (const mask of st.mcpServers.values()) mask.dispose();
|
|
627
|
+
for (const dispose of st.mcpTools.values()) dispose();
|
|
628
|
+
for (const dispose of st.skills.values()) dispose();
|
|
629
|
+
for (const map of [
|
|
630
|
+
st.systemTools,
|
|
631
|
+
st.mcpServers,
|
|
632
|
+
st.mcpTools,
|
|
633
|
+
st.skills
|
|
634
|
+
]) map.clear();
|
|
384
635
|
st.noteDispose?.();
|
|
385
636
|
delete st.noteDispose;
|
|
637
|
+
st.userToggled.clear();
|
|
386
638
|
states.delete(sessionId);
|
|
387
639
|
}
|
|
388
|
-
|
|
389
|
-
|
|
640
|
+
if (defaults.tools.length > 0 || defaults.skills.length > 0) await seed(sessionId, defaults);
|
|
641
|
+
if (overrides !== void 0) await restoreImpl(sessionId, overrides);
|
|
642
|
+
}),
|
|
643
|
+
remask: (sessionId, defaults, overrides) => enqueue(sessionId, async () => {
|
|
644
|
+
if (defaults.tools.length > 0) await seed(sessionId, defaults, false);
|
|
645
|
+
if (overrides !== void 0) await restoreImpl(sessionId, {
|
|
646
|
+
skills: {},
|
|
647
|
+
mcpServers: overrides.mcpServers,
|
|
648
|
+
mcpTools: overrides.mcpTools,
|
|
649
|
+
systemTools: overrides.systemTools
|
|
650
|
+
});
|
|
651
|
+
}),
|
|
652
|
+
set: (sessionId, kind, name, enabled) => enqueue(sessionId, async () => {
|
|
390
653
|
stateFor(sessionId).userToggled.add(`${kind}:${name}`);
|
|
391
654
|
if (kind === "skill") await setSkill(sessionId, name, enabled);
|
|
392
655
|
else if (kind === "mcp-server") setServer(sessionId, name, enabled);
|
|
@@ -397,7 +660,7 @@ function createCapabilityController(ctx, appendStats, blockedCounts) {
|
|
|
397
660
|
kind: enabled ? "enable" : "disable",
|
|
398
661
|
name: `${kind}:${name}`
|
|
399
662
|
});
|
|
400
|
-
}
|
|
663
|
+
})
|
|
401
664
|
};
|
|
402
665
|
}
|
|
403
666
|
|
|
@@ -450,198 +713,155 @@ function registerPresetEnforcement(ctx, capabilities, presetTools, sessionOverri
|
|
|
450
713
|
return;
|
|
451
714
|
}
|
|
452
715
|
});
|
|
716
|
+
/**
|
|
717
|
+
* A preset switch lands AFTER creation (the session header records the
|
|
718
|
+
* composing preset at creation, and the picker's select() recomposes the
|
|
719
|
+
* blank session later): the first agent/created seeded the ORIGINAL
|
|
720
|
+
* preset's defaults, so the new preset's defaults must re-seed on top of a
|
|
721
|
+
* clean slate. Session overrides replay last — the user's own switches in
|
|
722
|
+
* this session outrank either preset's defaults.
|
|
723
|
+
*/
|
|
724
|
+
ctx.on("agent-preset/selected", (sessionId, presetId) => {
|
|
725
|
+
try {
|
|
726
|
+
if (typeof sessionId !== "string" || typeof presetId !== "string") return void 0;
|
|
727
|
+
const defaults = presetTools.defaultsFor(presetId) ?? {
|
|
728
|
+
tools: [],
|
|
729
|
+
skills: []
|
|
730
|
+
};
|
|
731
|
+
const overrides = sessionOverrides.overridesFor(sessionId);
|
|
732
|
+
return capabilities.reseed(sessionId, defaults, overrides);
|
|
733
|
+
} catch {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
/**
|
|
738
|
+
* Re-mask when the tool registry changes. seed() can only mask names that
|
|
739
|
+
* are registered at that moment (tools.restrict refuses unknown names), so
|
|
740
|
+
* an on-demand MCP server that connects AFTER the session was created would
|
|
741
|
+
* otherwise arrive with every tool enabled, preset default or not. The
|
|
742
|
+
* registry broadcasts every layer change through this event — late
|
|
743
|
+
* registration, a reconnect's fresh generation, a teardown — and remask is
|
|
744
|
+
* idempotent over names already masked.
|
|
745
|
+
*
|
|
746
|
+
* The guard breaks the echo loop: our own restrict() calls re-emit
|
|
747
|
+
* tools/change, and without it every mask write would schedule another
|
|
748
|
+
* sweep. A change arriving mid-sweep is not lost, though: it is marked
|
|
749
|
+
* pending and one trailing sweep runs when the current one settles, so an
|
|
750
|
+
* external registration landing while a session's slow queue is still
|
|
751
|
+
* processing does not leave stale masks behind.
|
|
752
|
+
*/
|
|
753
|
+
let remasking = false;
|
|
754
|
+
let pendingSweep = false;
|
|
755
|
+
ctx.on("tools/change", () => {
|
|
756
|
+
if (remasking) {
|
|
757
|
+
pendingSweep = true;
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
const agents = ctx.get("agents");
|
|
762
|
+
if (agents === void 0 || typeof agents.list !== "function") return void 0;
|
|
763
|
+
return (async () => {
|
|
764
|
+
do {
|
|
765
|
+
remasking = true;
|
|
766
|
+
pendingSweep = false;
|
|
767
|
+
try {
|
|
768
|
+
let live;
|
|
769
|
+
try {
|
|
770
|
+
live = agents.list();
|
|
771
|
+
} catch {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
for (const agent of live) try {
|
|
775
|
+
const sessionId = agent?.id;
|
|
776
|
+
if (typeof sessionId !== "string") continue;
|
|
777
|
+
const presetId = ctx.get("agentPresets")?.composedPreset(agent.ctx);
|
|
778
|
+
const defaults = presetId === void 0 ? void 0 : presetTools.defaultsFor(presetId);
|
|
779
|
+
const overrides = sessionOverrides.overridesFor(sessionId);
|
|
780
|
+
const hasToolDefaults = defaults !== void 0 && defaults.tools.length > 0;
|
|
781
|
+
const hasToolOverrides = overrides !== void 0 && (Object.keys(overrides.mcpServers).length > 0 || Object.keys(overrides.mcpTools).length > 0 || Object.keys(overrides.systemTools).length > 0);
|
|
782
|
+
if (!hasToolDefaults && !hasToolOverrides) continue;
|
|
783
|
+
await capabilities.remask(sessionId, defaults ?? {
|
|
784
|
+
tools: [],
|
|
785
|
+
skills: []
|
|
786
|
+
}, overrides);
|
|
787
|
+
} catch {}
|
|
788
|
+
} finally {
|
|
789
|
+
remasking = false;
|
|
790
|
+
}
|
|
791
|
+
} while (pendingSweep);
|
|
792
|
+
})();
|
|
793
|
+
} catch {
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
});
|
|
453
797
|
}
|
|
454
798
|
|
|
455
799
|
//#endregion
|
|
456
|
-
//#region src/
|
|
457
|
-
|
|
458
|
-
function
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
if (parsed !== null && typeof parsed === "object" && "name" in parsed) {
|
|
463
|
-
const name = parsed.name;
|
|
464
|
-
return typeof name === "string" && name !== "" ? name : null;
|
|
465
|
-
}
|
|
466
|
-
} catch {}
|
|
467
|
-
return null;
|
|
468
|
-
}
|
|
469
|
-
function collectLoadRecords(events) {
|
|
470
|
-
const out = [];
|
|
471
|
-
for (const event of events) {
|
|
472
|
-
if (event.type !== "tool/call") continue;
|
|
473
|
-
if (event.data?.name !== "skill") continue;
|
|
474
|
-
const skillName = skillNameOf(event.data.arguments);
|
|
475
|
-
if (skillName === null) continue;
|
|
476
|
-
const seq = event.seq;
|
|
477
|
-
if (typeof seq !== "number") continue;
|
|
478
|
-
out.push({
|
|
479
|
-
seq,
|
|
480
|
-
skillName,
|
|
481
|
-
callId: event.data.callId ?? ""
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
return out;
|
|
485
|
-
}
|
|
486
|
-
/**
|
|
487
|
-
* Map a tool result's pairing callId to its seq.
|
|
488
|
-
*
|
|
489
|
-
* Verified against real logs: `tool/result` events carry no
|
|
490
|
-
* top-level callId; the pairing lives at `data.message.source.callId` and
|
|
491
|
-
* matches the call's `data.callId` exactly.
|
|
492
|
-
*
|
|
493
|
-
* Last write wins on purpose: the middle-pruner appends a stub `tool/result`
|
|
494
|
-
* carrying the SAME callId and a replace surfaceOp over the original's seq
|
|
495
|
-
* (verified against a real compacted session), so the callId resolves to the
|
|
496
|
-
* stub — the node whose fold verdict actually tracks the surface position.
|
|
497
|
-
*/
|
|
498
|
-
function indexToolResultSeqs(events) {
|
|
499
|
-
const out = /* @__PURE__ */ new Map();
|
|
500
|
-
for (const event of events) {
|
|
501
|
-
if (event.type !== "tool/result") continue;
|
|
502
|
-
const callId = event.data?.message?.source?.callId;
|
|
503
|
-
const seq = event.seq;
|
|
504
|
-
if (typeof callId !== "string" || callId === "" || typeof seq !== "number") continue;
|
|
505
|
-
out.set(callId, seq);
|
|
506
|
-
}
|
|
507
|
-
return out;
|
|
800
|
+
//#region src/host/mcp-connections.ts
|
|
801
|
+
const MCP_CLIENT_NAME = "@deepseek-ai/dsh-mcp-client";
|
|
802
|
+
function entryConfig(entry) {
|
|
803
|
+
const config = entry.options?.config;
|
|
804
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) return void 0;
|
|
805
|
+
return config;
|
|
508
806
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
* dsh-session is exactly { user/message, assistant/message, tool/result }, and
|
|
514
|
-
* real skill calls carry `surfaceOp: null`. What the model actually sees of a
|
|
515
|
-
* skill is its tool RESULT (a surface node), so eviction keys on the paired
|
|
516
|
-
* result's surface membership, not on the call's position.
|
|
517
|
-
*
|
|
518
|
-
* `surfaceSeqs` is the live session's CURRENT surface (`session.surface.nodes`),
|
|
519
|
-
* which the session maintains incrementally — reading it is O(1), no log fold
|
|
520
|
-
* is ever run for this panel. A paired result seq absent from that set was
|
|
521
|
-
* displaced by a prune stub's or a summary's `replace` op, i.e. shadowed.
|
|
522
|
-
*
|
|
523
|
-
* A load with no paired result is in flight or its result never landed; it is
|
|
524
|
-
* NOT counted as shadowed, so it reports `loaded` — the honest reading of "the
|
|
525
|
-
* model is about to see it".
|
|
526
|
-
*/
|
|
527
|
-
function shadowedLoadSeqs(loads, resultSeqByCallId, surfaceSeqs) {
|
|
528
|
-
const out = /* @__PURE__ */ new Set();
|
|
529
|
-
for (const load of loads) {
|
|
530
|
-
const resultSeq = resultSeqByCallId.get(load.callId);
|
|
531
|
-
if (resultSeq === void 0) continue;
|
|
532
|
-
if (!surfaceSeqs.has(resultSeq)) out.add(load.seq);
|
|
533
|
-
}
|
|
534
|
-
return out;
|
|
807
|
+
function declaredServerName(entry) {
|
|
808
|
+
if (entry.options?.name !== MCP_CLIENT_NAME) return void 0;
|
|
809
|
+
const serverName = entryConfig(entry)?.["serverName"];
|
|
810
|
+
return typeof serverName === "string" && serverName !== "" ? serverName : void 0;
|
|
535
811
|
}
|
|
536
812
|
/**
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
813
|
+
* Server names the host composition declares via `@deepseek-ai/dsh-mcp-client`
|
|
814
|
+
* entries — connected or not. Matched by plugin name, not by the `serverName`
|
|
815
|
+
* key: an unrelated entry carrying a same-named config key is not an MCP
|
|
816
|
+
* server.
|
|
540
817
|
*/
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
if (record.type === "text" && typeof record.text === "string") out.push(record.text);
|
|
551
|
-
else if (Array.isArray(record.content)) walk(record.content);
|
|
818
|
+
function readConfiguredMcpServers(ctx) {
|
|
819
|
+
const servers = /* @__PURE__ */ new Set();
|
|
820
|
+
const loader = ctx.loader;
|
|
821
|
+
if (loader === void 0) return servers;
|
|
822
|
+
try {
|
|
823
|
+
for (const entry of loader.entries()) {
|
|
824
|
+
if (entry.disabled === true) continue;
|
|
825
|
+
const name = declaredServerName(entry);
|
|
826
|
+
if (name !== void 0) servers.add(name);
|
|
552
827
|
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
return out;
|
|
828
|
+
} catch {}
|
|
829
|
+
return servers;
|
|
556
830
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
*/
|
|
565
|
-
function prunedLoadSeqs(loads, resultSeqByCallId, surfaceSeqs, events) {
|
|
566
|
-
const prunedResults = /* @__PURE__ */ new Set();
|
|
567
|
-
for (const event of events) {
|
|
568
|
-
if (event.type !== "tool/result") continue;
|
|
569
|
-
const seq = event.seq;
|
|
570
|
-
if (typeof seq !== "number" || !surfaceSeqs.has(seq)) continue;
|
|
571
|
-
if (textBlocksOf(event).some((text) => text.includes(PRUNE_MARKER_TEXT))) prunedResults.add(seq);
|
|
572
|
-
}
|
|
573
|
-
const out = /* @__PURE__ */ new Set();
|
|
574
|
-
for (const load of loads) {
|
|
575
|
-
const resultSeq = resultSeqByCallId.get(load.callId);
|
|
576
|
-
if (resultSeq !== void 0 && prunedResults.has(resultSeq)) out.add(load.seq);
|
|
831
|
+
function entryFor(ctx, server) {
|
|
832
|
+
const loader = ctx.loader;
|
|
833
|
+
if (loader === void 0) return void 0;
|
|
834
|
+
try {
|
|
835
|
+
for (const entry of loader.entries()) if (declaredServerName(entry) === server) return entry;
|
|
836
|
+
} catch {
|
|
837
|
+
return;
|
|
577
838
|
}
|
|
578
|
-
return out;
|
|
579
839
|
}
|
|
580
840
|
/**
|
|
581
|
-
*
|
|
841
|
+
* Reload one declared MCP server's plugin instance: dispose its fiber, then
|
|
842
|
+
* refresh the entry — the loader's own hot-swap pair. This is exactly a
|
|
843
|
+
* manual HMR reload: it tears down the current connection (and stops its
|
|
844
|
+
* reconnect timer) and re-initializes the client, which reconnects and re-syncs
|
|
845
|
+
* the whole tool generation.
|
|
582
846
|
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
* the
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
* seq order and a numeric `start <= seq <= end` test silently misjudges later
|
|
590
|
-
* compactions.
|
|
847
|
+
* Honest scope of what this guarantees: only that a fresh connection attempt
|
|
848
|
+
* starts NOW instead of waiting out the client's backoff. It does NOT prove
|
|
849
|
+
* the server was "down" (the panel cannot observe connection state), and for a
|
|
850
|
+
* stdio transport the reload respawns the child process. Registration lands
|
|
851
|
+
* asynchronously after refresh resolves; the registry's `tools/change`
|
|
852
|
+
* broadcast re-applies stored defaults, so callers need no readiness wait.
|
|
591
853
|
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
* `evicted`).
|
|
854
|
+
* Assumes a globally unique serverName: the settings view is host-global, and
|
|
855
|
+
* dsh reserves one serverName per global mcp-client instance, so at most one
|
|
856
|
+
* entry matches here. Agent-scoped instances may reuse a name across Agents,
|
|
857
|
+
* but those are not reachable from this global walk.
|
|
597
858
|
*/
|
|
598
|
-
function
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
}
|
|
605
|
-
return available.map(({ name, description, masked, source, provider, path, group }) => {
|
|
606
|
-
const records = byName.get(name) ?? [];
|
|
607
|
-
let state = "unloaded";
|
|
608
|
-
if (records.length > 0) {
|
|
609
|
-
const current = records.filter((r) => !shadowedSeqs.has(r.seq));
|
|
610
|
-
if (current.length === 0) state = "evicted";
|
|
611
|
-
else state = current.some((r) => !prunedSeqs.has(r.seq)) ? "loaded" : "pruned";
|
|
612
|
-
}
|
|
613
|
-
return {
|
|
614
|
-
name,
|
|
615
|
-
...description === void 0 ? {} : { description },
|
|
616
|
-
state,
|
|
617
|
-
enabled: !disabledSkills.has(name) && masked !== true,
|
|
618
|
-
loadCount: records.length,
|
|
619
|
-
source,
|
|
620
|
-
provider,
|
|
621
|
-
...path === void 0 ? {} : { path },
|
|
622
|
-
...group === void 0 ? {} : { group }
|
|
623
|
-
};
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
/** Group MCP tools by server: `mcp__<server>__<tool>`. */
|
|
627
|
-
function groupMcpTools(toolNames) {
|
|
628
|
-
const byServer = /* @__PURE__ */ new Map();
|
|
629
|
-
for (const raw of toolNames) {
|
|
630
|
-
if (!raw.startsWith("mcp__")) continue;
|
|
631
|
-
const rest = raw.slice(5);
|
|
632
|
-
const cut = rest.indexOf("__");
|
|
633
|
-
if (cut <= 0) continue;
|
|
634
|
-
const server = rest.slice(0, cut);
|
|
635
|
-
const tool = rest.slice(cut + 2);
|
|
636
|
-
if (tool === "") continue;
|
|
637
|
-
const bucket = byServer.get(server);
|
|
638
|
-
if (bucket === void 0) byServer.set(server, [tool]);
|
|
639
|
-
else bucket.push(tool);
|
|
640
|
-
}
|
|
641
|
-
return [...byServer.entries()].map(([server, tools]) => ({
|
|
642
|
-
server,
|
|
643
|
-
tools: tools.sort()
|
|
644
|
-
})).sort((a, b) => a.server.localeCompare(b.server));
|
|
859
|
+
async function restartMcpServer(ctx, server) {
|
|
860
|
+
const entry = entryFor(ctx, server);
|
|
861
|
+
if (entry === void 0) throw new HttpError(404, `MCP server "${server}" is not configured on this host`);
|
|
862
|
+
if (entry.disabled === true) throw new HttpError(409, `MCP server "${server}" is disabled in the host composition; enable it there instead of reloading it`);
|
|
863
|
+
await entry._dispose();
|
|
864
|
+
await entry.refresh();
|
|
645
865
|
}
|
|
646
866
|
|
|
647
867
|
//#endregion
|
|
@@ -668,7 +888,7 @@ function parentDir(path) {
|
|
|
668
888
|
function displayPath(path, cwd) {
|
|
669
889
|
if (cwd !== void 0 && cwd !== "" && path.startsWith(`${cwd}/`)) return path.slice(cwd.length + 1);
|
|
670
890
|
const home = process.env["HOME"];
|
|
671
|
-
if (home !== void 0 && home !== "" && path.startsWith(home)) return `~${path.slice(home.length)}`;
|
|
891
|
+
if (home !== void 0 && home !== "" && (path === home || path.startsWith(`${home}/`))) return `~${path.slice(home.length)}`;
|
|
672
892
|
return path;
|
|
673
893
|
}
|
|
674
894
|
/** The DSH home, matching the runtime's own resolution order. */
|
|
@@ -806,7 +1026,7 @@ function readLogFacts(services, sessionId, degraded) {
|
|
|
806
1026
|
return empty;
|
|
807
1027
|
}
|
|
808
1028
|
}
|
|
809
|
-
function readMcp(services, degraded, disabledServers, disabledTools, agent, presetName, presetPath) {
|
|
1029
|
+
function readMcp(services, degraded, disabledServers, disabledTools, agent, presetName, presetPath, maskedServerNames) {
|
|
810
1030
|
const tools = services.get("tools");
|
|
811
1031
|
if (tools === void 0) {
|
|
812
1032
|
degraded.push("tools service unavailable");
|
|
@@ -828,7 +1048,8 @@ function readMcp(services, degraded, disabledServers, disabledTools, agent, pres
|
|
|
828
1048
|
if (agent !== void 0) collect(agent);
|
|
829
1049
|
const globalNames = /* @__PURE__ */ new Set();
|
|
830
1050
|
for (const schema of tools.schemas()) if (typeof schema.name === "string" && schema.name.startsWith("mcp__")) globalNames.add(schema.name);
|
|
831
|
-
|
|
1051
|
+
const configuredServers = readConfiguredMcpServers(services);
|
|
1052
|
+
const groups = groupMcpTools(names).map((group) => {
|
|
832
1053
|
const enabled = !disabledServers.has(group.server);
|
|
833
1054
|
const entries = group.tools.map((tool) => {
|
|
834
1055
|
const name = `mcp__${group.server}__${tool}`;
|
|
@@ -847,10 +1068,32 @@ function readMcp(services, degraded, disabledServers, disabledTools, agent, pres
|
|
|
847
1068
|
server: group.server,
|
|
848
1069
|
tools: entries,
|
|
849
1070
|
enabled,
|
|
1071
|
+
...configuredServers.has(group.server) ? { reconnectable: true } : {},
|
|
850
1072
|
source,
|
|
851
1073
|
...rawPath === void 0 ? {} : { path: displayPath(rawPath) }
|
|
852
1074
|
};
|
|
853
1075
|
});
|
|
1076
|
+
const hostPath = dshHome();
|
|
1077
|
+
for (const server of configuredServers) {
|
|
1078
|
+
if (groups.some((group) => group.server === server)) continue;
|
|
1079
|
+
const prefix = `mcp__${server}__`;
|
|
1080
|
+
const roster = maskedServerNames?.get(server) ?? [...disabledTools].filter((name) => name.startsWith(prefix)).sort();
|
|
1081
|
+
groups.push({
|
|
1082
|
+
server,
|
|
1083
|
+
tools: roster.map((name) => ({
|
|
1084
|
+
name,
|
|
1085
|
+
label: name.slice(prefix.length),
|
|
1086
|
+
enabled: false
|
|
1087
|
+
})),
|
|
1088
|
+
enabled: false,
|
|
1089
|
+
unavailable: true,
|
|
1090
|
+
reconnectable: true,
|
|
1091
|
+
source: "host",
|
|
1092
|
+
...hostPath === void 0 ? {} : { path: displayPath(hostPath) }
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
groups.sort((a, b) => a.server.localeCompare(b.server));
|
|
1096
|
+
return groups;
|
|
854
1097
|
} catch (error) {
|
|
855
1098
|
degraded.push(`tool read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
856
1099
|
return [];
|
|
@@ -930,7 +1173,7 @@ async function buildPayload(services, sessionId, capabilityState = EMPTY_STATE,
|
|
|
930
1173
|
return {
|
|
931
1174
|
sessionId,
|
|
932
1175
|
skills: decideStates(available, logFacts.loads, logFacts.shadowed, disabledSkills, logFacts.pruned),
|
|
933
|
-
mcp: readMcp(services, degraded, disabledServers, disabledTools, agent, presetName, presetPath),
|
|
1176
|
+
mcp: readMcp(services, degraded, disabledServers, disabledTools, agent, presetName, presetPath, new Map([...capabilityState.mcpServers].map(([server, mask]) => [server, mask.names]))),
|
|
934
1177
|
systemTools: readSystemTools(services, degraded, disabledSystem, agent),
|
|
935
1178
|
blocked,
|
|
936
1179
|
...degraded.length > 0 ? { degraded } : {}
|
|
@@ -1029,27 +1272,25 @@ function createPresetToolController(ctx, access) {
|
|
|
1029
1272
|
key: p.name ?? p.id,
|
|
1030
1273
|
path: p.path
|
|
1031
1274
|
}));
|
|
1275
|
+
const configuredServers = readConfiguredMcpServers(ctx);
|
|
1032
1276
|
const globalNames = /* @__PURE__ */ new Set();
|
|
1033
1277
|
for (const schema of tools.schemas()) if (typeof schema.name === "string" && schema.name.startsWith("mcp__")) globalNames.add(schema.name);
|
|
1034
1278
|
return {
|
|
1035
1279
|
presets: await Promise.all(presets.map(async (preset) => {
|
|
1036
1280
|
let entries = [];
|
|
1037
1281
|
let skillRows = [];
|
|
1282
|
+
let mountError;
|
|
1038
1283
|
if (preset.broken === void 0) {
|
|
1039
1284
|
let scope;
|
|
1040
1285
|
try {
|
|
1041
1286
|
scope = await agentPresets.standingKeyFor(preset.id);
|
|
1042
1287
|
entries = toolSummaries(tools, scope);
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
}
|
|
1046
|
-
if (skills !== void 0) {
|
|
1047
|
-
const disabledSkills = new Set(stored.presetSkills[preset.id] ?? []);
|
|
1048
|
-
try {
|
|
1288
|
+
if (skills !== void 0) {
|
|
1289
|
+
const disabledSkills = new Set(stored.presetSkills[preset.id] ?? []);
|
|
1049
1290
|
skillRows = await presetSkillRows(skills, scope, disabledSkills, cwd, presetDirs);
|
|
1050
|
-
} catch (error) {
|
|
1051
|
-
throw new HttpError(503, `preset "${preset.id}" skills are unavailable: ${errorMessage(error)}`);
|
|
1052
1291
|
}
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
mountError = errorMessage(error);
|
|
1053
1294
|
}
|
|
1054
1295
|
}
|
|
1055
1296
|
const disabled = new Set(configured[preset.id] ?? []);
|
|
@@ -1073,17 +1314,33 @@ function createPresetToolController(ctx, access) {
|
|
|
1073
1314
|
server: group.server,
|
|
1074
1315
|
tools: tools$1,
|
|
1075
1316
|
enabled: tools$1.some((tool) => tool.enabled),
|
|
1317
|
+
...configuredServers.has(group.server) ? { reconnectable: true } : {},
|
|
1076
1318
|
source: allGlobal ? "host" : presetName,
|
|
1077
1319
|
...rawPath === void 0 ? {} : { path: displayPath(rawPath) }
|
|
1078
1320
|
};
|
|
1079
1321
|
});
|
|
1322
|
+
const hostPath = dshHome();
|
|
1323
|
+
for (const server of configuredServers) {
|
|
1324
|
+
if (mcp.some((group) => group.server === server)) continue;
|
|
1325
|
+
const prefix = `mcp__${server}__`;
|
|
1326
|
+
mcp.push({
|
|
1327
|
+
server,
|
|
1328
|
+
tools: [...disabled].filter((name) => name.startsWith(prefix)).sort().map((name) => row(name, name.slice(prefix.length))),
|
|
1329
|
+
enabled: false,
|
|
1330
|
+
unavailable: true,
|
|
1331
|
+
reconnectable: true,
|
|
1332
|
+
source: "host",
|
|
1333
|
+
...hostPath === void 0 ? {} : { path: displayPath(hostPath) }
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
mcp.sort((a, b) => a.server.localeCompare(b.server));
|
|
1080
1337
|
const systemTools = entries.filter((entry) => !entry.name.startsWith("mcp__")).map((entry) => row(entry.name, entry.name));
|
|
1081
1338
|
return {
|
|
1082
1339
|
id: preset.id,
|
|
1083
1340
|
name: preset.name ?? preset.id,
|
|
1084
1341
|
trust: preset.trust,
|
|
1085
1342
|
...preset.description === void 0 ? {} : { description: preset.description },
|
|
1086
|
-
...preset.broken === void 0 ? {} : { broken:
|
|
1343
|
+
...preset.broken !== void 0 ? { broken: preset.broken } : mountError === void 0 ? {} : { broken: `failed to mount: ${mountError}` },
|
|
1087
1344
|
skills: skillRows,
|
|
1088
1345
|
mcp,
|
|
1089
1346
|
systemTools
|
|
@@ -1355,6 +1612,33 @@ function createRouteHandler(services, capabilities, stats, blockedCounts, preset
|
|
|
1355
1612
|
}, true);
|
|
1356
1613
|
return;
|
|
1357
1614
|
}
|
|
1615
|
+
if (url.pathname === `${ROUTE}/reconnect`) {
|
|
1616
|
+
if (req.method !== "POST") {
|
|
1617
|
+
res.writeHead(405, {
|
|
1618
|
+
allow: "POST",
|
|
1619
|
+
"content-type": "text/plain; charset=utf-8"
|
|
1620
|
+
});
|
|
1621
|
+
res.end("method not allowed");
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (!validatePresetContentType(req, res)) return;
|
|
1625
|
+
const body = await readRequestBody(req);
|
|
1626
|
+
if (body === null || typeof body !== "object") {
|
|
1627
|
+
json(res, 400, { error: "invalid request body" });
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
const record = body;
|
|
1631
|
+
if (typeof record.server !== "string" || record.server === "") {
|
|
1632
|
+
json(res, 400, { error: "server is required" });
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
await restartMcpServer(services, record.server);
|
|
1636
|
+
json(res, 200, {
|
|
1637
|
+
ok: true,
|
|
1638
|
+
server: record.server
|
|
1639
|
+
});
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1358
1642
|
if (url.pathname === `${ROUTE}/open-folder`) {
|
|
1359
1643
|
if (req.method !== "POST") {
|
|
1360
1644
|
res.writeHead(405, {
|