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/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 },
@@ -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
- async restore(sessionId, overrides) {
368
- const groups = [
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 && st.userToggled.size === 0 && st.skills.size === 0 && st.mcpServers.size === 0 && st.mcpTools.size === 0 && st.systemTools.size === 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
- async set(sessionId, kind, name, enabled) {
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/load-state.ts
457
- /** Pull the skill name out of a `skill` tool call's stringified arguments. */
458
- function skillNameOf(args) {
459
- if (typeof args !== "string") return null;
460
- try {
461
- const parsed = JSON.parse(args);
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
- * The load seqs whose skill content is gone from the model surface.
511
- *
512
- * A `tool/call` never joins the surface itself SURFACE_EVENT_TYPES in
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
- * Substring of dsh-compaction-tool-result-pruner's PRUNE_MARKER. Matching on
538
- * the bracketed phrase (without the surrounding newlines) keeps the check
539
- * robust to marker framing changes across pruner versions.
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
- const PRUNE_MARKER_TEXT = "[... tool result middle pruned ...]";
542
- function textBlocksOf(event) {
543
- const content = event.data?.message?.content;
544
- if (!Array.isArray(content)) return [];
545
- const out = [];
546
- const walk = (blocks) => {
547
- for (const block of blocks) {
548
- if (block === null || typeof block !== "object") continue;
549
- const record = block;
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
- walk(content);
555
- return out;
828
+ } catch {}
829
+ return servers;
556
830
  }
557
- /**
558
- * The load seqs whose paired tool result sits on the surface TRUNCATED —
559
- * head and tail visible, middle replaced by the pruner's marker. The model
560
- * partially sees these skills, so they read as their own state rather than
561
- * either extreme. Only surface-resident results are inspected; a shadowed
562
- * result is already reported by shadowedLoadSeqs and its content is
563
- * irrelevant to the model now.
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
- * 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.
582
846
  *
583
- * `shadowedSeqs` holds LOAD seqs whose paired tool result is absent from the
584
- * current surface (see shadowedLoadSeqs for why the result, not the call,
585
- * carries the verdict); `prunedSeqs` holds load seqs whose result survives on
586
- * the surface with its middle truncated. Do NOT re-derive either from
587
- * replacement ranges here: after a replacement lands, a high-seq summary node
588
- * sits at the shadowed range's *position*, so surface order stops tracking
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
- * Verified against real data: the middle-pruner had replaced the lark-shared /
593
- * lark-im / lark-event results with stubs (those now read `pruned`), and a
594
- * later full-history compaction (one replace over [7..16114]) shadowed those
595
- * stubs plus the find-skills / git-worktree-discipline results (those read
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 decideStates(available, loads, shadowedSeqs, disabledSkills = /* @__PURE__ */ new Set(), prunedSeqs = /* @__PURE__ */ new Set()) {
599
- const byName = /* @__PURE__ */ new Map();
600
- for (const record of loads) {
601
- const bucket = byName.get(record.skillName);
602
- if (bucket === void 0) byName.set(record.skillName, [record]);
603
- else bucket.push(record);
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
- return groupMcpTools(names).map((group) => {
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
- } catch (error) {
1044
- throw new HttpError(503, `preset "${preset.id}" tools are unavailable: ${errorMessage(error)}`);
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: preset.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, {