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/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 && existing !== void 0) {
243
- existing();
244
- state.mcpServers.delete(server);
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, tools.restrict({ deny: names }));
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 && existing !== void 0) {
262
- existing();
263
- map.delete(name);
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 map of [
281
- state.skills,
282
- state.mcpServers,
283
- state.mcpTools,
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/load-state.ts
505
- /** Pull the skill name out of a `skill` tool call's stringified arguments. */
506
- function skillNameOf(args) {
507
- if (typeof args !== "string") return null;
508
- try {
509
- const parsed = JSON.parse(args);
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 collectLoadRecords(events) {
518
- const out = [];
519
- for (const event of events) {
520
- if (event.type !== "tool/call") continue;
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
- * Map a tool result's pairing callId to its seq.
536
- *
537
- * Verified against real logs: `tool/result` events carry no
538
- * top-level callId; the pairing lives at `data.message.source.callId` and
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 indexToolResultSeqs(events) {
547
- const out = /* @__PURE__ */ new Map();
548
- for (const event of events) {
549
- if (event.type !== "tool/result") continue;
550
- const callId = event.data?.message?.source?.callId;
551
- const seq = event.seq;
552
- if (typeof callId !== "string" || callId === "" || typeof seq !== "number") continue;
553
- out.set(callId, seq);
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
- walk(content);
603
- return out;
828
+ } catch {}
829
+ return servers;
604
830
  }
605
- /**
606
- * The load seqs whose paired tool result sits on the surface TRUNCATED —
607
- * head and tail visible, middle replaced by the pruner's marker. The model
608
- * partially sees these skills, so they read as their own state rather than
609
- * either extreme. Only surface-resident results are inspected; a shadowed
610
- * result is already reported by shadowedLoadSeqs and its content is
611
- * irrelevant to the model now.
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
- * Decide each skill's state.
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
- * `shadowedSeqs` holds LOAD seqs whose paired tool result is absent from the
632
- * current surface (see shadowedLoadSeqs for why the result, not the call,
633
- * carries the verdict); `prunedSeqs` holds load seqs whose result survives on
634
- * the surface with its middle truncated. Do NOT re-derive either from
635
- * replacement ranges here: after a replacement lands, a high-seq summary node
636
- * sits at the shadowed range's *position*, so surface order stops tracking
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
- * Verified against real data: the middle-pruner had replaced the lark-shared /
641
- * lark-im / lark-event results with stubs (those now read `pruned`), and a
642
- * later full-history compaction (one replace over [7..16114]) shadowed those
643
- * stubs plus the find-skills / git-worktree-discipline results (those read
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 decideStates(available, loads, shadowedSeqs, disabledSkills = /* @__PURE__ */ new Set(), prunedSeqs = /* @__PURE__ */ new Set()) {
647
- const byName = /* @__PURE__ */ new Map();
648
- for (const record of loads) {
649
- const bucket = byName.get(record.skillName);
650
- if (bucket === void 0) byName.set(record.skillName, [record]);
651
- else bucket.push(record);
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
- return groupMcpTools(names).map((group) => {
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, {