weir-harness 2.1.1 → 2.1.3

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/client.js CHANGED
@@ -1,4 +1,4 @@
1
- // Weir client chunks: {"client.blackboard-model.js":"2e5abdd44edb599d10d9d547fa07133e84250d6007161c2c8704b58151896f93","client.blackboard-panel.js":"c9d6a0f02623075fc884f3519f982574ad33ad871f7bcd7e8c36f12512f0de39","client.capability-badge.js":"f5c5d21293360d00815446beb8455794a1778ae033e270af37e436329ac09185","client.capability-manager.js":"f02e08174487f2f70e90a5c50a4a44cbeb4d462f115a2d2ab8a52ed8b4e70aee","client.capability-model.js":"57127dc5ea7ca6aa70c619480684f4de84fcbff4922f70df9886f55fad66abbf","client.capability-presets.js":"12ac8da3568d013abd76b67cfe473a329f4af2feaac9cd55a82d8881e5bc1017","client.chain-editor.js":"1e9a33bc68cabc43bc27b91ef83eab5028a33256596c147e15722d684687765b","client.chain-model.js":"2e885922cf8c2972725c6eef8bf20c7d1202d961edfb8ad41574dac062d74371","client.disabled-categories-editor.js":"f9103fd2e2691eda9afd199e1a5462189097a9f3dee0518be5fa3587d896e033","client.file-lease-badge.js":"cd30546307c91c94fe05484557e46f58c53fe55657f62b0a0415a072dfb3b582","client.file-lease-tool-model.js":"f72d87b049454ec489b4dffac1f32dabc273947b8a4c55fe07c91ddf320bd899","client.file-lease-tool-view.js":"7004caa06b36d8497a1d2e448b6ce5e4c8efd22ad903d8b39e26220de64bb1fe","client.hash-edit-model.js":"71727d47a6ef32ebd2abfb063fa93499a5e607d6716e3ec8983ae056c4fb317f","client.hash-edit-view.js":"fc0f4dabddab9a48527553ad4232b3715e46e6863aab75e02ae2dcd6687265c2","client.lsp-model.js":"30943ed1ed11c1f2770a724da855ea2417c11566c2778c79e17cfc2de499cd80","client.lsp-panel.js":"61629a4d317dae9ed01c1cc0355632fb752788dc23f7606257188050734cd030","client.lsp-toggle.js":"d6af81be5e5b854e98563bbb9349a6db89834a9d7dee9cf0e97759ecb32090e8","client.mcp-manager.js":"cfcd4792251fcca8bfa6a2c35b80881ab6f6480cf2ebce24a8ce304cddf940a7","client.model-picker.js":"57f96826e00b37ae6d6474a6750f31b4e128e5fdb37e302875a41cdb2e18f14a","client.notify-permissions.js":"2be9bdbeffef96baa3cbe4ce430ab30a1d1bf797782453d287d54602ea47db8e","client.notify-web.js":"0943f67d5526ff0fb7cb4f69580e0a9ea54ed06465c513afc51a5a7fb1c55ccf","client.robash-editor.js":"cd6a283b72cb02598e32ca4ce03a1b8de5f328ecdd1f5f920377524f3bdcbe1c","client.robash-model.js":"f4395338a773e8bb48e4f96aeb737cd634edcca0b90af992a7dbbe47be42e451","client.settings-page.js":"f3c04a5b33cdb963357bc653f7050e598f19efc4cc833694379a4ba055e949d3","client.worktree-model.js":"8bfbaaa080f20e90a0128afccb9b4e232514615a8f2fee3a32a42dde78b34a8f","client.worktree-view.js":"356c3f5f683b8509ae8679243a6460ca02957fd15e057a629b2ac4fdd12ed057"}
1
+ // Weir client chunks: {"client.blackboard-model.js":"2e5abdd44edb599d10d9d547fa07133e84250d6007161c2c8704b58151896f93","client.blackboard-panel.js":"c9d6a0f02623075fc884f3519f982574ad33ad871f7bcd7e8c36f12512f0de39","client.capability-badge.js":"f5c5d21293360d00815446beb8455794a1778ae033e270af37e436329ac09185","client.capability-manager.js":"0c5b0e469b66b1ccdf720bb3db91ae15fd0be70c84287739de36a3bfceaed223","client.capability-model.js":"9beddda506617152c51d8159964fee74aecb99e8d50b50de82ab050d597ec9d1","client.capability-presets.js":"12ac8da3568d013abd76b67cfe473a329f4af2feaac9cd55a82d8881e5bc1017","client.chain-editor.js":"1e9a33bc68cabc43bc27b91ef83eab5028a33256596c147e15722d684687765b","client.chain-model.js":"2e885922cf8c2972725c6eef8bf20c7d1202d961edfb8ad41574dac062d74371","client.disabled-categories-editor.js":"f9103fd2e2691eda9afd199e1a5462189097a9f3dee0518be5fa3587d896e033","client.file-lease-badge.js":"cd30546307c91c94fe05484557e46f58c53fe55657f62b0a0415a072dfb3b582","client.file-lease-tool-model.js":"f72d87b049454ec489b4dffac1f32dabc273947b8a4c55fe07c91ddf320bd899","client.file-lease-tool-view.js":"7004caa06b36d8497a1d2e448b6ce5e4c8efd22ad903d8b39e26220de64bb1fe","client.hash-edit-model.js":"71727d47a6ef32ebd2abfb063fa93499a5e607d6716e3ec8983ae056c4fb317f","client.hash-edit-view.js":"fc0f4dabddab9a48527553ad4232b3715e46e6863aab75e02ae2dcd6687265c2","client.lsp-model.js":"30943ed1ed11c1f2770a724da855ea2417c11566c2778c79e17cfc2de499cd80","client.lsp-panel.js":"61629a4d317dae9ed01c1cc0355632fb752788dc23f7606257188050734cd030","client.lsp-toggle.js":"d6af81be5e5b854e98563bbb9349a6db89834a9d7dee9cf0e97759ecb32090e8","client.mcp-manager.js":"cfcd4792251fcca8bfa6a2c35b80881ab6f6480cf2ebce24a8ce304cddf940a7","client.model-picker.js":"57f96826e00b37ae6d6474a6750f31b4e128e5fdb37e302875a41cdb2e18f14a","client.notify-permissions.js":"2be9bdbeffef96baa3cbe4ce430ab30a1d1bf797782453d287d54602ea47db8e","client.notify-web.js":"0943f67d5526ff0fb7cb4f69580e0a9ea54ed06465c513afc51a5a7fb1c55ccf","client.robash-editor.js":"cd6a283b72cb02598e32ca4ce03a1b8de5f328ecdd1f5f920377524f3bdcbe1c","client.robash-model.js":"f4395338a773e8bb48e4f96aeb737cd634edcca0b90af992a7dbbe47be42e451","client.settings-page.js":"f3c04a5b33cdb963357bc653f7050e598f19efc4cc833694379a4ba055e949d3","client.worktree-model.js":"8bfbaaa080f20e90a0128afccb9b4e232514615a8f2fee3a32a42dde78b34a8f","client.worktree-view.js":"356c3f5f683b8509ae8679243a6460ca02957fd15e057a629b2ac4fdd12ed057"}
2
2
  window.__ModuleLoader__.load({
3
3
  id: "weir-harness",
4
4
  factory: (require) => {
@@ -891,6 +891,10 @@ window.__ModuleLoader__.load({
891
891
  const capabilityVerbs = (sessionId) => ({
892
892
  sessionId,
893
893
  fetchReceipt: (sid) => capabilityRead("receipt", sid, null),
894
+ fetchSkillPaths: (sid) => capabilityRead("skillPaths", sid, {
895
+ status: "unavailable", saved: false, revision: 0, paths: [],
896
+ error: { code: "unavailable", message: "Skill scan paths are unavailable." }
897
+ }),
894
898
  // Forwarded host events arrive via ctx.remote.$on — NOT a
895
899
  // remote.session.subscribe (that API does not exist; the old
896
900
  // wiring silently never subscribed, which is why cold sessions
@@ -910,6 +914,8 @@ window.__ModuleLoader__.load({
910
914
  mcpPreview: (sid, request) => capabilityMutation("mutate", sid, request),
911
915
  mcpMutate: (sid, request) => capabilityMutation("mutate", sid, request),
912
916
  mcpBatch: (sid, request) => capabilityMutation("batch", sid, request),
917
+ skillPathAdd: (sid, request) => capabilityMutation("skillPathAdd", sid, request),
918
+ skillPathRemove: (sid, request) => capabilityMutation("skillPathRemove", sid, request),
913
919
  fetchListing: (sid) => capabilityRead("list", sid, { error: true }),
914
920
  fetchConditions: (sid) => capabilityRead("conditions", sid, { error: true }),
915
921
  // The manager forwards only its established props. Adapt the lazy view
@@ -935,6 +941,10 @@ window.__ModuleLoader__.load({
935
941
  // True once the right-sidebar package mounted the capabilities tab
936
942
  // (the optional inject below ran); the Badge's openPanel verb exists
937
943
  // only then — without it the Badge falls back to its composer popover.
944
+ const capabilityDictionaries = (model, panel) => ({
945
+ en: { ...model.dictionaries?.en, ...panel.dictionaries?.en },
946
+ zh: { ...model.dictionaries?.zh, ...panel.dictionaries?.zh }
947
+ });
938
948
  let capabilitySidebarTab = false;
939
949
  const CapabilityBadgeWrapper = (props) => {
940
950
  const [impl, setImpl] = react.useState(null);
@@ -946,8 +956,19 @@ window.__ModuleLoader__.load({
946
956
  require.async("./client.capability-manager.js"),
947
957
  require.async("./client.mcp-manager.js").catch(() => null),
948
958
  ]).then(([badge, model, panel, mcp]) => {
949
- registerChunkDictionaries("settings.weir.capability", model.dictionaries);
950
- if (alive) setImpl(() => ({ Badge: badge.CapabilityBadge, model, Panel: panel.CapabilityManagerPanel, McpManagerView: mcp?.McpManagerView, McpManagerBoundary: mcp?.McpManagerBoundary }));
959
+ registerChunkDictionaries("settings.weir.capability", capabilityDictionaries(model, panel));
960
+ if (alive) setImpl(() => {
961
+ // The Badge chunk's established prop bridge is intentionally unchanged.
962
+ // Bind the new verbs once at this composition-root adapter so the
963
+ // composer popover and sidebar drive the identical path child.
964
+ const Panel = (panelProps) => react_jsx_runtime.jsx(panel.CapabilityManagerPanel, {
965
+ ...panelProps,
966
+ fetchSkillPaths: props.fetchSkillPaths,
967
+ skillPathAdd: props.skillPathAdd,
968
+ skillPathRemove: props.skillPathRemove
969
+ });
970
+ return { Badge: badge.CapabilityBadge, model, Panel, McpManagerView: mcp?.McpManagerView, McpManagerBoundary: mcp?.McpManagerBoundary };
971
+ });
951
972
  });
952
973
  return () => { alive = false; };
953
974
  }, [props.sessionId]);
@@ -984,7 +1005,7 @@ window.__ModuleLoader__.load({
984
1005
  require.async("./client.capability-manager.js"),
985
1006
  require.async("./client.mcp-manager.js").catch(() => null),
986
1007
  ]).then((chunks) => {
987
- registerChunkDictionaries("settings.weir.capability", chunks[0].dictionaries);
1008
+ registerChunkDictionaries("settings.weir.capability", capabilityDictionaries(chunks[0], chunks[1]));
988
1009
  return chunks;
989
1010
  }));
990
1011
  function CapabilityPanelWrapper(props) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weir-harness",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "type": "module",
5
5
  "description": "Weir \u2014 orchestrator-centric DSH agent preset: intent gate, category delegation, todo driver, pull-based background jobs, auto-compaction, hashline edit.",
6
6
  "license": "MIT",
@@ -42,10 +42,11 @@ export const APPLY_AUDIT_TYPE = AUDIT_TYPES.capabilityApply
42
42
  * of the removed servers FOR THAT SESSION and returns a handle: snapshot()
43
43
  * reports the live in-flight counts (sent with the response, never awaited)
44
44
  * and abort() reopens exactly the gates begin() closed when the Apply does
45
- * not commit — abort() and begin() act on the SAME drain, so a failed Apply
46
- * never leaves a session permanently gated. Production wires the manager's
47
- * (agent, server)-keyed drain here (createSessionDrainAdapter, mcp-drain.js).
48
- * @typedef {{ begin(removed: string[], sessionId: string): { snapshot(): DrainStatus[], abort(): void } }} ApplyDrain
45
+ * not commit. After acceptance, reopen(restored, sessionId) opens only the
46
+ * servers the committed-over basis shows this Apply restored for that session.
47
+ * Production wires the manager's (agent, server)-keyed drain here
48
+ * (createSessionDrainAdapter, mcp-drain.js).
49
+ * @typedef {{ begin(removed: string[], sessionId: string): { snapshot(): DrainStatus[], abort(): void }, reopen(restored: string[], sessionId: string): void }} ApplyDrain
49
50
  * A step-tagged rejection keeps the old authority and the user's draft.
50
51
  * @typedef {{ status: 'applied', revision: number, receipt: unknown, effective: unknown, unresolvedWarnings: UnresolvedRef[], draining?: DrainStatus[], warnings?: string[] }
51
52
  * | { status: 'duplicate', revision: number, receipt: unknown, effective: unknown, warnings?: string[] }
@@ -164,6 +165,9 @@ export function createDrainCoordinator() {
164
165
  },
165
166
  }
166
167
  },
168
+ reopen(restored) {
169
+ for (const server of restored) stateOf(server).closed = false
170
+ },
167
171
  }
168
172
  }
169
173
 
@@ -607,6 +611,18 @@ export function createApplyEngine(options) {
607
611
  * degraded: (code: string, error?: unknown) => void }} segment
608
612
  */
609
613
  function publishAccepted({ sessionId, revision, payload, prepared, viewOptions, basisSets, lease, settle = null, degraded }) {
614
+ // The accepted set, not the transaction-start read, owns restoration. This
615
+ // runs only after a durable commit (or receipt proof), inside the held fence
616
+ // and before lifecycle publication/manager synchronization. A reopen failure
617
+ // cannot reject or roll back the landed write: degrade the response honestly.
618
+ const restoredServers = safeSets(payload).mcpServers.filter(server => !basisSets.mcpServers.includes(server))
619
+ if (restoredServers.length) {
620
+ try {
621
+ drain.reopen(restoredServers, sessionId)
622
+ } catch (error) {
623
+ degraded('drain-reopen-degraded', error)
624
+ }
625
+ }
610
626
  // Task 12.4: the net accepted delta is known only HERE (durable pre-write
611
627
  // record vs the just-committed payload); notify consumers queue it for
612
628
  // the next safe request. durable-pending-notice D2: the acceptance revision
@@ -22,13 +22,14 @@ import { buildReceiptPayload, buildListPayload, buildConditionsPayload, buildPre
22
22
  import { PRESET_SELECTED_EVENT } from './preset-invalidation.js'
23
23
  import { createAudit } from '../shared/audit.js'
24
24
  import { mcpDiagnostic, sanitizeMcpPayload } from './mcp-diagnostics.js'
25
+ import { createSkillScanPathRegistry } from './skill-scan-path-registry.js'
25
26
 
26
27
  /** The host-root service key the typert gateway resolves (S27: ctx.get from the host root). */
27
28
  export const CAPABILITY_READ_SERVICE_KEY = 'weirCapabilityRead'
28
29
  /** The wire namespace: POST /api/weirCapabilities/<method>, client ctx.remote.weirCapabilities.*. */
29
30
  export const CAPABILITY_READ_NAMESPACE = 'weirCapabilities'
30
- /** The five read methods, each taking exactly one parameter. */
31
- export const CAPABILITY_READ_METHODS = ['receipt', 'list', 'conditions', 'presets', 'defaultGet']
31
+ /** The six read methods, each taking exactly one parameter. */
32
+ export const CAPABILITY_READ_METHODS = ['receipt', 'list', 'conditions', 'presets', 'defaultGet', 'skillPaths']
32
33
 
33
34
  /** Typed infrastructure/ownership failure, distinct from domain status values. */
34
35
  export class CapabilityReadError extends Error {
@@ -157,6 +158,7 @@ export function capabilityOwnership(observation, sessionId) {
157
158
  export function createCapabilityReadService(dependencies = {}) {
158
159
  const bridgeFor = feedResolver(dependencies)
159
160
  const storeFor = dependencies.store ?? (profileContext => openCapabilityStore({ profileContext }))
161
+ const skillPathRegistryFor = dependencies.skillPathRegistry ?? (profileContext => createSkillScanPathRegistry({ store: storeFor(profileContext) }))
160
162
  const ensure = dependencies.ensureRegistered ?? (() => true)
161
163
  const onDegraded = dependencies.onDegraded ?? (() => {})
162
164
  const observeSession = dependencies.observeSession ?? (sessionId => dependencies.sessionQuery?.()?.observeSession(sessionId))
@@ -242,12 +244,13 @@ export function createCapabilityReadService(dependencies = {}) {
242
244
  defaultGet: sessionId => read(sessionId, 'defaultGet', (faces, options) => buildDefaultGetPayload({
243
245
  store: storeFor(faces.profileContext?.()),
244
246
  }, options)),
247
+ skillPaths: sessionId => read(sessionId, 'skillPaths', faces => skillPathRegistryFor(faces.profileContext?.()).read()),
245
248
  }
246
249
  }
247
250
 
248
251
  export const CAPABILITY_MUTATION_SERVICE_KEY = 'weirCapabilityMutation'
249
252
  export const CAPABILITY_MUTATION_NAMESPACE = 'weirCapabilityMutations'
250
- export const CAPABILITY_MUTATION_METHODS = ['mutate', 'batch']
253
+ export const CAPABILITY_MUTATION_METHODS = ['mutate', 'batch', 'skillPathAdd', 'skillPathRemove']
251
254
 
252
255
  export function sameMutationOwner(first, second) {
253
256
  const profile = value => value ? `${value.home ?? ''}\0${value.name ?? ''}` : ''
@@ -257,9 +260,38 @@ export function sameMutationOwner(first, second) {
257
260
  && profile(first.profile) === profile(second.profile)
258
261
  }
259
262
 
263
+ /** Call every accepting-process provider for exactly one changed Profile. */
264
+ export function invalidateSkillScanPathProviders(profile) {
265
+ const failures = []
266
+ if (typeof profile?.home !== 'string' || typeof profile?.name !== 'string') {
267
+ return [new Error('changed Profile identity is unavailable')]
268
+ }
269
+ for (const feeds of bridgeFeeds.values()) {
270
+ for (const token of feeds) {
271
+ const faces = token.faces ?? token
272
+ try {
273
+ const candidate = faces.profileContext?.()
274
+ if (candidate?.home !== profile.home || candidate?.name !== profile.name) continue
275
+ if (typeof faces.provider?.sourceChanged !== 'function') throw new Error('mounted Skill provider cannot invalidate')
276
+ faces.provider.sourceChanged()
277
+ } catch (cause) {
278
+ failures.push(cause)
279
+ }
280
+ }
281
+ }
282
+ return failures
283
+ }
284
+
285
+ const pathDiagnostic = (cause, code = cause?.code ?? 'validation-failed') => ({
286
+ code,
287
+ message: cause instanceof Error ? cause.message : String(cause),
288
+ })
289
+
260
290
  /** Session-preset/profile/feed ownership for mutations; selectionSeq is not compared. */
261
291
  export function createCapabilityMutationService(dependencies = {}) {
262
292
  const bridgeFor = feedResolver(dependencies)
293
+ const storeFor = dependencies.store ?? (profileContext => openCapabilityStore({ profileContext }))
294
+ const skillPathRegistryFor = dependencies.skillPathRegistry ?? (profileContext => createSkillScanPathRegistry({ store: storeFor(profileContext) }))
263
295
  const ensure = dependencies.ensureRegistered ?? (() => true)
264
296
  const observeSession = dependencies.observeSession ?? (sessionId => dependencies.sessionQuery?.()?.observeSession(sessionId))
265
297
  const observe = async sessionId => {
@@ -278,16 +310,18 @@ export function createCapabilityMutationService(dependencies = {}) {
278
310
  throw new CapabilityReadError('unknown-session', `capability mutation: cannot observe session ${JSON.stringify(sessionId)}`, { cause })
279
311
  }
280
312
  }
313
+ // The same exact observation/feed/Profile recheck serves MCP and path writes.
314
+ const ownershipAssertion = (sessionId, first) => async () => {
315
+ const current = await observe(sessionId)
316
+ try {
317
+ if (!sameMutationOwner(first, current)) throw new CapabilityReadError('owner-changed', 'capability mutation: session preset/profile/feed owner changed')
318
+ } finally { current.lease[Symbol.dispose]() }
319
+ }
281
320
  async function mutate(sessionId, request, batch = false) {
282
321
  if (!ensure()) throw new CapabilityReadError('channel-unavailable', 'capability mutation channel is not registered')
283
322
  const first = await observe(sessionId)
284
323
  try {
285
- const assertOwner = async () => {
286
- const current = await observe(sessionId)
287
- try {
288
- if (!sameMutationOwner(first, current)) throw new CapabilityReadError('owner-changed', 'capability mutation: session preset/profile/feed owner changed')
289
- } finally { current.lease[Symbol.dispose]() }
290
- }
324
+ const assertOwner = ownershipAssertion(sessionId, first)
291
325
  if (typeof first.faces.mcpOperations !== 'function') throw new CapabilityReadError('bridge-absent', 'capability mutation: owning preset has no MCP operations')
292
326
  const operations = first.faces.mcpOperations({ id: sessionId, header: { cwd: first.owner.cwd } }, { assertOwner })
293
327
  let result
@@ -322,9 +356,62 @@ export function createCapabilityMutationService(dependencies = {}) {
322
356
  first.lease[Symbol.dispose]()
323
357
  }
324
358
  }
359
+
360
+ async function mutatePath(sessionId, request, kind) {
361
+ if (!ensure()) throw new CapabilityReadError('channel-unavailable', 'capability mutation channel is not registered')
362
+ const first = await observe(sessionId)
363
+ try {
364
+ const assertOwner = ownershipAssertion(sessionId, first)
365
+ let result
366
+ try {
367
+ const registry = skillPathRegistryFor(first.profile)
368
+ result = kind === 'add'
369
+ ? await registry.add(request?.path, request?.expectedRevision, { assertOwner })
370
+ : await registry.remove(request?.path, request?.expectedRevision, { assertOwner })
371
+ } catch (cause) {
372
+ result = {
373
+ status: cause?.code === 'owner-changed' ? 'owner-changed' : 'validation-failed',
374
+ saved: false, revision: null, paths: null,
375
+ error: pathDiagnostic(cause, cause?.code ?? 'validation-failed'),
376
+ }
377
+ }
378
+
379
+ // A known or possibly landed registry write may already have changed the
380
+ // durable discovery scope. Invalidate every same-Profile mount before the
381
+ // response, without converting an ambiguous outcome into a definite one.
382
+ const indeterminate = result.status === 'indeterminate'
383
+ let invalidationFailure = null
384
+ if (result.saved === true || indeterminate) {
385
+ const failures = invalidateSkillScanPathProviders(first.profile)
386
+ if (failures.length > 0) invalidationFailure = failures.map(failure => pathDiagnostic(failure).message).join('; ')
387
+ }
388
+ try {
389
+ await assertOwner()
390
+ } catch (cause) {
391
+ return {
392
+ ...result,
393
+ status: indeterminate ? 'indeterminate' : 'owner-changed', ownerChanged: true, saved: result.saved === true,
394
+ error: pathDiagnostic(cause, 'owner-changed'),
395
+ }
396
+ }
397
+ if (invalidationFailure !== null) {
398
+ return {
399
+ ...result,
400
+ status: indeterminate ? 'indeterminate' : 'invalidation-failed',
401
+ error: { code: 'invalidation-failed', message: invalidationFailure },
402
+ }
403
+ }
404
+ return result
405
+ } finally {
406
+ first.lease[Symbol.dispose]()
407
+ }
408
+ }
409
+
325
410
  return {
326
411
  mutate: (sessionId, request) => mutate(sessionId, request, false),
327
412
  batch: (sessionId, request) => mutate(sessionId, request, true),
413
+ skillPathAdd: (sessionId, request) => mutatePath(sessionId, request, 'add'),
414
+ skillPathRemove: (sessionId, request) => mutatePath(sessionId, request, 'remove'),
328
415
  }
329
416
  }
330
417
 
@@ -343,7 +430,7 @@ export const CAPABILITY_ENDPOINTS = [
343
430
  * codecs, no zod, no generated artifacts). Parameters and results are plain
344
431
  * JSON; the envelope validates the argument name only.
345
432
  *
346
- * ALL five read and two mutation invocations live in ONE contribution, because
433
+ * ALL six read and four mutation invocations live in ONE contribution, because
347
434
  * the registry keys exactly one record per package+face and rejects any second
348
435
  * registration while that record is live: two separate `weir-harness` host-face
349
436
  * contributions could never overlap generations, and the face the mutation half
@@ -0,0 +1,47 @@
1
+ // Per-mount Capability Lifecycle Snapshot ownership. A live token authorizes a
2
+ // manager only when its lazy accessor returns that exact mutable manager face.
3
+ // No mount-order, process-face, preset-name, or sole-feed fallback is permitted.
4
+
5
+ /** @typedef {{ lifecycle: any, manager: (() => any) | null | undefined }} LifecycleFeedToken */
6
+ /**
7
+ * Create an isolated registry for tests or an explicit in-process composition.
8
+ * Registration returns an ownership-scoped, idempotent disposer.
9
+ */
10
+ export function createLifecycleFeedRegistry() {
11
+ /** @type {Set<LifecycleFeedToken>} */
12
+ const tokens = new Set()
13
+ return {
14
+ register({ lifecycle, manager }) {
15
+ const token = { lifecycle, manager }
16
+ tokens.add(token)
17
+ let live = true
18
+ return () => {
19
+ if (!live) return
20
+ live = false
21
+ tokens.delete(token)
22
+ }
23
+ },
24
+ lifecycleFor(managerFace) {
25
+ if (managerFace == null) return null
26
+ /** @type {LifecycleFeedToken | null} */
27
+ let owner = null
28
+ for (const token of tokens) {
29
+ let manager
30
+ try {
31
+ manager = typeof token.manager === 'function' ? token.manager() : null
32
+ } catch {
33
+ // A late, absent, or failing manager is denied on this adjudication.
34
+ // Do not cache either the failure or a previous successful owner.
35
+ continue
36
+ }
37
+ if (manager !== managerFace) continue
38
+ if (owner) return null
39
+ owner = token
40
+ }
41
+ return owner?.lifecycle ?? null
42
+ },
43
+ }
44
+ }
45
+
46
+ /** Internal module-local bridge shared by selection and manager rows. */
47
+ export const lifecycleFeedRegistry = createLifecycleFeedRegistry()
@@ -130,5 +130,11 @@ export function createSessionDrainAdapter(resolveDrain) {
130
130
  abort: () => { for (const server of removed) drain.reopen(sessionId, server) },
131
131
  }
132
132
  },
133
+ /** Reopen only servers restored by an accepted Apply for this session. */
134
+ reopen(restored, sessionId) {
135
+ const drain = resolveDrain()
136
+ if (!drain) return
137
+ for (const server of restored) drain.reopen(sessionId, server)
138
+ },
133
139
  }
134
140
  }
@@ -14,13 +14,23 @@ import { createMcpFacade } from './mcp-facade.js'
14
14
  */
15
15
  const bridge = new Map()
16
16
 
17
- export function registerMcpFacadeBridge(groupId, payload) {
18
- bridge.set(groupId, payload)
19
- return () => bridge.delete(groupId)
17
+ export function registerMcpFacadeBridge(groupId, payload, owner = payload) {
18
+ if (bridge.has(groupId)) {
19
+ throw Object.assign(new Error(`MCP facade group ${groupId} already has an owner`), { code: 'duplicate-mcp-facade-owner' })
20
+ }
21
+ const record = { payload, owner }
22
+ bridge.set(groupId, record)
23
+ return () => {
24
+ // A stale/repeated disposer compares the exact registration and owner. It
25
+ // can never delete a later registration that reused the confirmed-free ID.
26
+ if (bridge.get(groupId) !== record || record.owner !== owner) return false
27
+ bridge.delete(groupId)
28
+ return true
29
+ }
20
30
  }
21
31
 
22
32
  export function mcpFacadeBridgeFor(groupId) {
23
- return bridge.get(groupId) ?? null
33
+ return bridge.get(groupId)?.payload ?? null
24
34
  }
25
35
 
26
36
  export const name = 'weir-mcp-facade'
@@ -53,7 +53,7 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
53
53
  const disposeHost = typeof registration === 'function' ? registration : () => {}
54
54
  let rollbackRegistration = null
55
55
  try {
56
- onRegister?.(name, rollback => { rollbackRegistration = rollback })
56
+ onRegister?.(name, rollback => { rollbackRegistration = rollback }, wrapped)
57
57
  } catch (cause) {
58
58
  // Host-first rollback is fail-closed: owner facts and partial denies
59
59
  // stay in place unless Host removal is confirmed.
@@ -0,0 +1,152 @@
1
+ // The one read-only MCP Inventory Projection. Registry/runtime/mount/receipt and
2
+ // Loader facts are joined here; probing, mutation, credential resolution, agent
3
+ // visibility and every other operation remain with their existing owners.
4
+ // Pure dependencies keep this module independently testable and typechecked.
5
+ import { resolveMcpGroupIdentity } from './mcp-mount-ownership.js'
6
+
7
+ /**
8
+ * Classify Loader rows once and retain the facts needed by both public views.
9
+ * Every actual group remains a separate fact; same-identity groups never
10
+ * overwrite each other in an identity-keyed Map.
11
+ *
12
+ * @param {any[]} rows
13
+ * @param {Map<string, any>} mounted
14
+ * @param {Record<string, any>} registryServers
15
+ * @param {{ isResidue: (id: string) => boolean, groupPrefix: string, ownership?: any }} options
16
+ */
17
+ function joinLoaderRows(rows, mounted, registryServers, { isResidue, groupPrefix, ownership = null }) {
18
+ const managed = []
19
+ const unmanaged = []
20
+ for (const row of rows) {
21
+ if (typeof row?.id === 'string' && isResidue(row.id)) {
22
+ const resolved = resolveMcpGroupIdentity({ groupId: row.id, mounted, ownership, row })
23
+ // Unresolved generated suffixes are conservative residue, not evidence
24
+ // from which a logical registry identity or mount health may be invented.
25
+ if (!resolved) continue
26
+ managed.push({
27
+ identity: resolved.identity,
28
+ groupId: row.id,
29
+ registry: registryServers[resolved.identity],
30
+ isMounted: resolved.mount != null,
31
+ mount: resolved.mount,
32
+ source: resolved.source,
33
+ allocation: resolved.allocation,
34
+ })
35
+ } else if (row?.name === '@deepseek-ai/dsh-mcp-client' && !(typeof row.id === 'string' && row.id.startsWith(groupPrefix))) {
36
+ unmanaged.push({ serverName: row.config?.serverName ?? row.id ?? 'unknown', state: 'unmanaged', readOnly: true })
37
+ }
38
+ }
39
+ return { managed, unmanaged }
40
+ }
41
+
42
+ /**
43
+ * Internal implementation behind mcp-manager.js's exported partitionMcpRows.
44
+ * Its field selection is the historical compatibility contract; production
45
+ * manager.list() uses the same Loader join and the full projection below.
46
+ *
47
+ * @param {any[]} rows
48
+ * @param {Map<string, any>} mounted
49
+ * @param {Record<string, any>} registryServers
50
+ * @param {{ isResidue: (id: string) => boolean, groupPrefix: string, ownership?: any }} options
51
+ */
52
+ export function partitionMcpInventory(rows, mounted, registryServers, options) {
53
+ const joined = joinLoaderRows(rows, mounted, registryServers, options)
54
+ return {
55
+ managed: joined.managed.map(fact => ({
56
+ identity: fact.identity,
57
+ groupId: fact.groupId,
58
+ label: fact.registry?.label ?? fact.identity,
59
+ generation: fact.mount?.generation ?? fact.registry?.generation ?? null,
60
+ state: fact.isMounted ? 'mounted' : 'registered',
61
+ })),
62
+ unmanaged: joined.unmanaged,
63
+ }
64
+ }
65
+
66
+ /**
67
+ * @param {{
68
+ * registry: { read: () => Promise<any> },
69
+ * runtime: { snapshot: (identity: string) => any },
70
+ * mounted: Map<string, any>,
71
+ * receipts: { mount: Map<string, any>, test: Map<string, any> },
72
+ * listRows: () => any[],
73
+ * startErrors: Array<{ identity: string | null, reason: string }>,
74
+ * discoveryFor: (identity: string, generation: any) => any,
75
+ * support: () => any,
76
+ * isResidue: (id: string) => boolean,
77
+ * groupPrefix: string,
78
+ * ownership?: any,
79
+ * holdsReservation: (rows: any[], serverName: any) => any,
80
+ * redactEntry: (entry: any) => any,
81
+ * fingerprintEntry: (entry: any) => string,
82
+ * }} dependencies
83
+ */
84
+ export function createMcpInventoryProjection(dependencies) {
85
+ const {
86
+ registry, runtime, mounted, receipts, listRows, startErrors,
87
+ discoveryFor, support, isResidue, groupPrefix, ownership = null, holdsReservation,
88
+ redactEntry, fingerprintEntry,
89
+ } = dependencies
90
+
91
+ async function list() {
92
+ const current = await registry.read()
93
+ const rows = listRows()
94
+ const registryServers = current.kind === 'ok' ? current.servers : {}
95
+ const loader = joinLoaderRows(rows, mounted, registryServers, { isResidue, groupPrefix, ownership })
96
+ if (current.kind === 'absent') {
97
+ return { managed: [], unmanaged: loader.unmanaged, warnings: [], registryRevision: 0, capabilities: support() }
98
+ }
99
+ if (current.kind !== 'ok') {
100
+ return { managed: [], unmanaged: loader.unmanaged, unavailable: { status: current.kind }, registryRevision: current.revision ?? 0, capabilities: support() }
101
+ }
102
+
103
+ // Registry authority starts the full view. Join every actual Loader group,
104
+ // then select only this manager's exact own mount as mounted evidence.
105
+ const loaderByIdentity = new Map()
106
+ for (const fact of loader.managed) {
107
+ const facts = loaderByIdentity.get(fact.identity) ?? []
108
+ facts.push(fact)
109
+ loaderByIdentity.set(fact.identity, facts)
110
+ }
111
+ const defaults = new Set(current.enabled)
112
+ const managed = Object.values(current.servers).map(entry => {
113
+ const phase = runtime.snapshot(entry.identity)
114
+ const facts = loaderByIdentity.get(entry.identity) ?? []
115
+ const ownMount = mounted.get(entry.identity) ?? null
116
+ const ownLoaderFact = ownMount ? facts.find(fact => fact.groupId === ownMount.groupId) : null
117
+ // Some supported bare/test compositions expose no Loader listing. Keep
118
+ // their existing mount readback, but never let a different actual group
119
+ // satisfy this manager's strict mount evidence.
120
+ const mountedRecord = ownMount && (facts.length === 0 || ownLoaderFact) ? ownMount : null
121
+ const isMounted = phase.phase === 'mounted' && phase.generation === entry.generation && mountedRecord?.generation === entry.generation
122
+ const holder = holdsReservation(rows, entry.client?.serverName ?? entry.identity)
123
+ const state = holder ? 'conflict' : isMounted ? 'mounted' : phase.phase === 'saved-degraded' ? 'degraded' : ['cleanup-failed', 'cleanup-unknown'].includes(phase.phase) ? phase.phase : 'registered'
124
+ const discovery = discoveryFor(entry.identity, entry.generation)
125
+ return {
126
+ identity: entry.identity,
127
+ label: entry.label,
128
+ serverName: entry.client?.serverName ?? entry.identity,
129
+ transport: entry.transport?.kind ?? entry.client?.transport ?? 'unknown',
130
+ incarnation: entry.incarnation ?? null,
131
+ generation: entry.generation,
132
+ defaultEnabled: defaults.has(entry.identity),
133
+ state,
134
+ statusSource: isMounted ? 'registry-runtime-mount-readback' : holder ? 'serverName-reservation' : 'registry-runtime',
135
+ runtimePhase: phase.phase,
136
+ discovery: isMounted ? discovery : { toolCount: null, toolNamesPreview: [], truncated: false },
137
+ lastMount: receipts.mount.get(entry.identity) ?? null,
138
+ lastTest: receipts.test.get(entry.identity) ?? null,
139
+ startErrors: startErrors.filter(error => error.identity === entry.identity).map(error => ({ reason: error.reason })),
140
+ liveReconnect: 'unknown',
141
+ processExit: 'unknown',
142
+ stderr: 'unknown',
143
+ readOnly: false,
144
+ config: redactEntry(entry),
145
+ fingerprint: fingerprintEntry(entry),
146
+ }
147
+ })
148
+ return { managed, unmanaged: loader.unmanaged, warnings: current.warnings, registryRevision: current.revision, capabilities: support() }
149
+ }
150
+
151
+ return { list }
152
+ }