dsh-capability-panel 1.1.1 → 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 +224 -38
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +29 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +447 -208
- 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 },
|
|
@@ -396,15 +622,16 @@ function createCapabilityController(ctx, appendStats, blockedCounts) {
|
|
|
396
622
|
reseed: (sessionId, defaults, overrides) => enqueue(sessionId, async () => {
|
|
397
623
|
const st = states.get(sessionId);
|
|
398
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();
|
|
399
629
|
for (const map of [
|
|
400
630
|
st.systemTools,
|
|
401
631
|
st.mcpServers,
|
|
402
632
|
st.mcpTools,
|
|
403
633
|
st.skills
|
|
404
|
-
])
|
|
405
|
-
for (const dispose of map.values()) dispose();
|
|
406
|
-
map.clear();
|
|
407
|
-
}
|
|
634
|
+
]) map.clear();
|
|
408
635
|
st.noteDispose?.();
|
|
409
636
|
delete st.noteDispose;
|
|
410
637
|
st.userToggled.clear();
|
|
@@ -413,6 +640,15 @@ function createCapabilityController(ctx, appendStats, blockedCounts) {
|
|
|
413
640
|
if (defaults.tools.length > 0 || defaults.skills.length > 0) await seed(sessionId, defaults);
|
|
414
641
|
if (overrides !== void 0) await restoreImpl(sessionId, overrides);
|
|
415
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
|
+
}),
|
|
416
652
|
set: (sessionId, kind, name, enabled) => enqueue(sessionId, async () => {
|
|
417
653
|
stateFor(sessionId).userToggled.add(`${kind}:${name}`);
|
|
418
654
|
if (kind === "skill") await setSkill(sessionId, name, enabled);
|
|
@@ -498,198 +734,134 @@ function registerPresetEnforcement(ctx, capabilities, presetTools, sessionOverri
|
|
|
498
734
|
return;
|
|
499
735
|
}
|
|
500
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
|
+
});
|
|
501
797
|
}
|
|
502
798
|
|
|
503
799
|
//#endregion
|
|
504
|
-
//#region src/
|
|
505
|
-
|
|
506
|
-
function
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (parsed !== null && typeof parsed === "object" && "name" in parsed) {
|
|
511
|
-
const name = parsed.name;
|
|
512
|
-
return typeof name === "string" && name !== "" ? name : null;
|
|
513
|
-
}
|
|
514
|
-
} catch {}
|
|
515
|
-
return null;
|
|
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;
|
|
516
806
|
}
|
|
517
|
-
function
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (event.data?.name !== "skill") continue;
|
|
522
|
-
const skillName = skillNameOf(event.data.arguments);
|
|
523
|
-
if (skillName === null) continue;
|
|
524
|
-
const seq = event.seq;
|
|
525
|
-
if (typeof seq !== "number") continue;
|
|
526
|
-
out.push({
|
|
527
|
-
seq,
|
|
528
|
-
skillName,
|
|
529
|
-
callId: event.data.callId ?? ""
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
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;
|
|
533
811
|
}
|
|
534
812
|
/**
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
* matches the call's `data.callId` exactly.
|
|
540
|
-
*
|
|
541
|
-
* Last write wins on purpose: the middle-pruner appends a stub `tool/result`
|
|
542
|
-
* carrying the SAME callId and a replace surfaceOp over the original's seq
|
|
543
|
-
* (verified against a real compacted session), so the callId resolves to the
|
|
544
|
-
* stub — the node whose fold verdict actually tracks the surface position.
|
|
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.
|
|
545
817
|
*/
|
|
546
|
-
function
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
return out;
|
|
556
|
-
}
|
|
557
|
-
/**
|
|
558
|
-
* The load seqs whose skill content is gone from the model surface.
|
|
559
|
-
*
|
|
560
|
-
* A `tool/call` never joins the surface itself — SURFACE_EVENT_TYPES in
|
|
561
|
-
* dsh-session is exactly { user/message, assistant/message, tool/result }, and
|
|
562
|
-
* real skill calls carry `surfaceOp: null`. What the model actually sees of a
|
|
563
|
-
* skill is its tool RESULT (a surface node), so eviction keys on the paired
|
|
564
|
-
* result's surface membership, not on the call's position.
|
|
565
|
-
*
|
|
566
|
-
* `surfaceSeqs` is the live session's CURRENT surface (`session.surface.nodes`),
|
|
567
|
-
* which the session maintains incrementally — reading it is O(1), no log fold
|
|
568
|
-
* is ever run for this panel. A paired result seq absent from that set was
|
|
569
|
-
* displaced by a prune stub's or a summary's `replace` op, i.e. shadowed.
|
|
570
|
-
*
|
|
571
|
-
* A load with no paired result is in flight or its result never landed; it is
|
|
572
|
-
* NOT counted as shadowed, so it reports `loaded` — the honest reading of "the
|
|
573
|
-
* model is about to see it".
|
|
574
|
-
*/
|
|
575
|
-
function shadowedLoadSeqs(loads, resultSeqByCallId, surfaceSeqs) {
|
|
576
|
-
const out = /* @__PURE__ */ new Set();
|
|
577
|
-
for (const load of loads) {
|
|
578
|
-
const resultSeq = resultSeqByCallId.get(load.callId);
|
|
579
|
-
if (resultSeq === void 0) continue;
|
|
580
|
-
if (!surfaceSeqs.has(resultSeq)) out.add(load.seq);
|
|
581
|
-
}
|
|
582
|
-
return out;
|
|
583
|
-
}
|
|
584
|
-
/**
|
|
585
|
-
* Substring of dsh-compaction-tool-result-pruner's PRUNE_MARKER. Matching on
|
|
586
|
-
* the bracketed phrase (without the surrounding newlines) keeps the check
|
|
587
|
-
* robust to marker framing changes across pruner versions.
|
|
588
|
-
*/
|
|
589
|
-
const PRUNE_MARKER_TEXT = "[... tool result middle pruned ...]";
|
|
590
|
-
function textBlocksOf(event) {
|
|
591
|
-
const content = event.data?.message?.content;
|
|
592
|
-
if (!Array.isArray(content)) return [];
|
|
593
|
-
const out = [];
|
|
594
|
-
const walk = (blocks) => {
|
|
595
|
-
for (const block of blocks) {
|
|
596
|
-
if (block === null || typeof block !== "object") continue;
|
|
597
|
-
const record = block;
|
|
598
|
-
if (record.type === "text" && typeof record.text === "string") out.push(record.text);
|
|
599
|
-
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);
|
|
600
827
|
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
return out;
|
|
828
|
+
} catch {}
|
|
829
|
+
return servers;
|
|
604
830
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
*/
|
|
613
|
-
function prunedLoadSeqs(loads, resultSeqByCallId, surfaceSeqs, events) {
|
|
614
|
-
const prunedResults = /* @__PURE__ */ new Set();
|
|
615
|
-
for (const event of events) {
|
|
616
|
-
if (event.type !== "tool/result") continue;
|
|
617
|
-
const seq = event.seq;
|
|
618
|
-
if (typeof seq !== "number" || !surfaceSeqs.has(seq)) continue;
|
|
619
|
-
if (textBlocksOf(event).some((text) => text.includes(PRUNE_MARKER_TEXT))) prunedResults.add(seq);
|
|
620
|
-
}
|
|
621
|
-
const out = /* @__PURE__ */ new Set();
|
|
622
|
-
for (const load of loads) {
|
|
623
|
-
const resultSeq = resultSeqByCallId.get(load.callId);
|
|
624
|
-
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;
|
|
625
838
|
}
|
|
626
|
-
return out;
|
|
627
839
|
}
|
|
628
840
|
/**
|
|
629
|
-
*
|
|
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.
|
|
630
846
|
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
* the
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
* seq order and a numeric `start <= seq <= end` test silently misjudges later
|
|
638
|
-
* 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.
|
|
639
853
|
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
* `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.
|
|
645
858
|
*/
|
|
646
|
-
function
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
}
|
|
653
|
-
return available.map(({ name, description, masked, source, provider, path, group }) => {
|
|
654
|
-
const records = byName.get(name) ?? [];
|
|
655
|
-
let state = "unloaded";
|
|
656
|
-
if (records.length > 0) {
|
|
657
|
-
const current = records.filter((r) => !shadowedSeqs.has(r.seq));
|
|
658
|
-
if (current.length === 0) state = "evicted";
|
|
659
|
-
else state = current.some((r) => !prunedSeqs.has(r.seq)) ? "loaded" : "pruned";
|
|
660
|
-
}
|
|
661
|
-
return {
|
|
662
|
-
name,
|
|
663
|
-
...description === void 0 ? {} : { description },
|
|
664
|
-
state,
|
|
665
|
-
enabled: !disabledSkills.has(name) && masked !== true,
|
|
666
|
-
loadCount: records.length,
|
|
667
|
-
source,
|
|
668
|
-
provider,
|
|
669
|
-
...path === void 0 ? {} : { path },
|
|
670
|
-
...group === void 0 ? {} : { group }
|
|
671
|
-
};
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
/** Group MCP tools by server: `mcp__<server>__<tool>`. */
|
|
675
|
-
function groupMcpTools(toolNames) {
|
|
676
|
-
const byServer = /* @__PURE__ */ new Map();
|
|
677
|
-
for (const raw of toolNames) {
|
|
678
|
-
if (!raw.startsWith("mcp__")) continue;
|
|
679
|
-
const rest = raw.slice(5);
|
|
680
|
-
const cut = rest.indexOf("__");
|
|
681
|
-
if (cut <= 0) continue;
|
|
682
|
-
const server = rest.slice(0, cut);
|
|
683
|
-
const tool = rest.slice(cut + 2);
|
|
684
|
-
if (tool === "") continue;
|
|
685
|
-
const bucket = byServer.get(server);
|
|
686
|
-
if (bucket === void 0) byServer.set(server, [tool]);
|
|
687
|
-
else bucket.push(tool);
|
|
688
|
-
}
|
|
689
|
-
return [...byServer.entries()].map(([server, tools]) => ({
|
|
690
|
-
server,
|
|
691
|
-
tools: tools.sort()
|
|
692
|
-
})).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();
|
|
693
865
|
}
|
|
694
866
|
|
|
695
867
|
//#endregion
|
|
@@ -716,7 +888,7 @@ function parentDir(path) {
|
|
|
716
888
|
function displayPath(path, cwd) {
|
|
717
889
|
if (cwd !== void 0 && cwd !== "" && path.startsWith(`${cwd}/`)) return path.slice(cwd.length + 1);
|
|
718
890
|
const home = process.env["HOME"];
|
|
719
|
-
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)}`;
|
|
720
892
|
return path;
|
|
721
893
|
}
|
|
722
894
|
/** The DSH home, matching the runtime's own resolution order. */
|
|
@@ -854,7 +1026,7 @@ function readLogFacts(services, sessionId, degraded) {
|
|
|
854
1026
|
return empty;
|
|
855
1027
|
}
|
|
856
1028
|
}
|
|
857
|
-
function readMcp(services, degraded, disabledServers, disabledTools, agent, presetName, presetPath) {
|
|
1029
|
+
function readMcp(services, degraded, disabledServers, disabledTools, agent, presetName, presetPath, maskedServerNames) {
|
|
858
1030
|
const tools = services.get("tools");
|
|
859
1031
|
if (tools === void 0) {
|
|
860
1032
|
degraded.push("tools service unavailable");
|
|
@@ -876,7 +1048,8 @@ function readMcp(services, degraded, disabledServers, disabledTools, agent, pres
|
|
|
876
1048
|
if (agent !== void 0) collect(agent);
|
|
877
1049
|
const globalNames = /* @__PURE__ */ new Set();
|
|
878
1050
|
for (const schema of tools.schemas()) if (typeof schema.name === "string" && schema.name.startsWith("mcp__")) globalNames.add(schema.name);
|
|
879
|
-
|
|
1051
|
+
const configuredServers = readConfiguredMcpServers(services);
|
|
1052
|
+
const groups = groupMcpTools(names).map((group) => {
|
|
880
1053
|
const enabled = !disabledServers.has(group.server);
|
|
881
1054
|
const entries = group.tools.map((tool) => {
|
|
882
1055
|
const name = `mcp__${group.server}__${tool}`;
|
|
@@ -895,10 +1068,32 @@ function readMcp(services, degraded, disabledServers, disabledTools, agent, pres
|
|
|
895
1068
|
server: group.server,
|
|
896
1069
|
tools: entries,
|
|
897
1070
|
enabled,
|
|
1071
|
+
...configuredServers.has(group.server) ? { reconnectable: true } : {},
|
|
898
1072
|
source,
|
|
899
1073
|
...rawPath === void 0 ? {} : { path: displayPath(rawPath) }
|
|
900
1074
|
};
|
|
901
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;
|
|
902
1097
|
} catch (error) {
|
|
903
1098
|
degraded.push(`tool read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
904
1099
|
return [];
|
|
@@ -978,7 +1173,7 @@ async function buildPayload(services, sessionId, capabilityState = EMPTY_STATE,
|
|
|
978
1173
|
return {
|
|
979
1174
|
sessionId,
|
|
980
1175
|
skills: decideStates(available, logFacts.loads, logFacts.shadowed, disabledSkills, logFacts.pruned),
|
|
981
|
-
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]))),
|
|
982
1177
|
systemTools: readSystemTools(services, degraded, disabledSystem, agent),
|
|
983
1178
|
blocked,
|
|
984
1179
|
...degraded.length > 0 ? { degraded } : {}
|
|
@@ -1077,6 +1272,7 @@ function createPresetToolController(ctx, access) {
|
|
|
1077
1272
|
key: p.name ?? p.id,
|
|
1078
1273
|
path: p.path
|
|
1079
1274
|
}));
|
|
1275
|
+
const configuredServers = readConfiguredMcpServers(ctx);
|
|
1080
1276
|
const globalNames = /* @__PURE__ */ new Set();
|
|
1081
1277
|
for (const schema of tools.schemas()) if (typeof schema.name === "string" && schema.name.startsWith("mcp__")) globalNames.add(schema.name);
|
|
1082
1278
|
return {
|
|
@@ -1118,10 +1314,26 @@ function createPresetToolController(ctx, access) {
|
|
|
1118
1314
|
server: group.server,
|
|
1119
1315
|
tools: tools$1,
|
|
1120
1316
|
enabled: tools$1.some((tool) => tool.enabled),
|
|
1317
|
+
...configuredServers.has(group.server) ? { reconnectable: true } : {},
|
|
1121
1318
|
source: allGlobal ? "host" : presetName,
|
|
1122
1319
|
...rawPath === void 0 ? {} : { path: displayPath(rawPath) }
|
|
1123
1320
|
};
|
|
1124
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));
|
|
1125
1337
|
const systemTools = entries.filter((entry) => !entry.name.startsWith("mcp__")).map((entry) => row(entry.name, entry.name));
|
|
1126
1338
|
return {
|
|
1127
1339
|
id: preset.id,
|
|
@@ -1400,6 +1612,33 @@ function createRouteHandler(services, capabilities, stats, blockedCounts, preset
|
|
|
1400
1612
|
}, true);
|
|
1401
1613
|
return;
|
|
1402
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
|
+
}
|
|
1403
1642
|
if (url.pathname === `${ROUTE}/open-folder`) {
|
|
1404
1643
|
if (req.method !== "POST") {
|
|
1405
1644
|
res.writeHead(405, {
|