weir-harness 2.0.0 → 2.1.1

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.
Files changed (46) hide show
  1. package/lib/client.capability-badge.js +2 -0
  2. package/lib/client.capability-manager.js +46 -179
  3. package/lib/client.capability-model.js +428 -3
  4. package/lib/client.file-lease-tool-model.js +189 -0
  5. package/lib/client.file-lease-tool-view.js +434 -0
  6. package/lib/client.js +105 -7
  7. package/lib/client.mcp-manager.js +563 -0
  8. package/package.json +1 -1
  9. package/skills/mcp-server-setup/SKILL.md +124 -0
  10. package/src/capabilities/apply-engine.js +18 -4
  11. package/src/capabilities/capability-remote.js +296 -45
  12. package/src/capabilities/initial-selection.js +3 -2
  13. package/src/capabilities/lifecycle-preload.js +69 -11
  14. package/src/capabilities/lifecycle-snapshot.js +127 -39
  15. package/src/capabilities/mcp-admission.js +2 -2
  16. package/src/capabilities/mcp-config.js +344 -0
  17. package/src/capabilities/mcp-credentials.js +180 -0
  18. package/src/capabilities/mcp-diagnostics.js +169 -0
  19. package/src/capabilities/mcp-drain.js +18 -4
  20. package/src/capabilities/mcp-facade.js +40 -9
  21. package/src/capabilities/mcp-gate.js +51 -52
  22. package/src/capabilities/mcp-install-tool.js +276 -0
  23. package/src/capabilities/mcp-manager.js +575 -146
  24. package/src/capabilities/mcp-mount.js +220 -79
  25. package/src/capabilities/mcp-operations.js +359 -0
  26. package/src/capabilities/mcp-process.js +43 -0
  27. package/src/capabilities/mcp-registry.js +74 -60
  28. package/src/capabilities/mcp-runtime.js +103 -0
  29. package/src/capabilities/read-payloads.js +8 -3
  30. package/src/capabilities/skill-applicability.js +185 -0
  31. package/src/capabilities/skill-builtin-migration.js +2 -1
  32. package/src/capabilities/skill-selection-plugin.js +170 -57
  33. package/src/file-lease/client.js +268 -35
  34. package/src/file-lease/index.js +4 -0
  35. package/src/file-lease/plugin.js +239 -15
  36. package/src/file-lease/tools.js +426 -38
  37. package/src/file-lease/watch.js +473 -0
  38. package/src/intent-gate/index.js +29 -12
  39. package/src/intent-gate/matcher.js +36 -2
  40. package/src/lease-broker/protocol.js +3 -2
  41. package/src/lease-broker/server.js +6 -3
  42. package/src/lease-broker/state.js +68 -11
  43. package/src/process/index.js +4 -2
  44. package/src/shared/audit.js +3 -0
  45. package/src/shared/child-scope.js +2 -0
  46. package/src/worktree/rules.js +1 -1
@@ -0,0 +1,124 @@
1
+ ---
2
+ name: mcp-server-setup
3
+ description: "Install, add, or configure a new MCP server with credential-safe preview, exact Host confirmation, strict discovery, and separate session Apply. Do not use for merely calling or discussing an existing MCP tool."
4
+ metadata:
5
+ short-description: Credential-safe MCP server installation
6
+ ---
7
+
8
+ # MCP Server Setup
9
+
10
+ Use this workflow for genuine requests to install, add, or configure a new MCP
11
+ server. It is not a tool-repair or general MCP discussion workflow.
12
+
13
+ ## Boundaries
14
+
15
+ - Weir supports `stdio` and `streamable-http` only. Do not propose legacy SSE,
16
+ OAuth flows, a marketplace, or adoption of an unmanaged server.
17
+ - Never ask the user to paste a token, password, private endpoint, or other
18
+ secret into chat. Use an existing DSH credential reference such as
19
+ `{ "ref": "GITHUB_TOKEN" }`. A reference may include a non-secret `prefix`.
20
+ - Every environment or HTTP-header value on this tool surface is a credential
21
+ reference. Mark each sensitive stdio argument with a reference object instead
22
+ of placing its value in a plain argument string.
23
+ - Any URL containing userinfo, a query, or a fragment is sensitive. Supply the
24
+ whole URL through a credential reference; do not put it in the plan.
25
+ - Installation never selects the server, changes a default, or Applies a
26
+ capability set. Do not imply that a mounted server is already authorized.
27
+
28
+ ## 1. Establish the plan
29
+
30
+ Read the server author's current documentation when it is available. Identify
31
+ the transport, executable or endpoint, non-secret arguments, required credential
32
+ references, working directory, and relevant timeout/reconnect options. Keep the
33
+ plan within the `mcp_install` schema. Unknown fields and cross-transport fields
34
+ are rejected.
35
+
36
+ For stdio, `command` and non-secret `args` describe the process. For
37
+ `streamable-http`, use `url` and credential-referenced `headers`; stdio fields
38
+ are forbidden. `failOnStartupError` is not configurable: initial mounting is
39
+ always strict. Discovering zero tools is still a valid result.
40
+
41
+ Example stdio plan shape (references, not secrets):
42
+
43
+ ```json
44
+ {
45
+ "action": "preview",
46
+ "plan": {
47
+ "identity": "github",
48
+ "label": "GitHub",
49
+ "transport": "stdio",
50
+ "command": "npx",
51
+ "args": ["-y", "@example/mcp-server", { "ref": "MCP_CLI_TOKEN" }],
52
+ "env": { "GITHUB_TOKEN": { "ref": "GITHUB_TOKEN" } }
53
+ }
54
+ }
55
+ ```
56
+
57
+ Example HTTP plan shape:
58
+
59
+ ```json
60
+ {
61
+ "action": "preview",
62
+ "plan": {
63
+ "identity": "private-docs",
64
+ "transport": "streamable-http",
65
+ "url": { "ref": "PRIVATE_MCP_URL" },
66
+ "headers": {
67
+ "Authorization": { "ref": "PRIVATE_MCP_TOKEN", "prefix": "Bearer " }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ ## 2. Preview before installation
74
+
75
+ Call `mcp_install` with `action: "preview"` and the plan. Check the bounded
76
+ redacted plan, digest, target conflicts, namespace conflicts, capabilities, and
77
+ validation outcome. Preview does not start a process, contact the server, write
78
+ credentials or configuration, or change selection. Correct the plan and preview
79
+ again when necessary.
80
+
81
+ `mcp_install` is single-server and fresh-identity only. Do not use it to edit,
82
+ restart, remove, batch-import, or set a default. Use the management UI or the
83
+ shared `/capabilities` MCP commands for those operations.
84
+
85
+ ## 3. Let the Host obtain approval
86
+
87
+ After a clean preview, call `mcp_install` with `action: "install"` and the same
88
+ plan. The Host displays the exact redacted plan, its digest, and process or
89
+ network effects. Only the user can approve that card. There is no `confirmed`
90
+ argument; chat text, custom answers, and extra selections are not approval.
91
+
92
+ Cancellation, close, timeout, missing confirmation support, delegated callers,
93
+ ownership changes, plan changes, registry changes, and namespace changes stop
94
+ before probe or write. Report the outcome and request a fresh preview when
95
+ required; never fabricate approval or retry an unchanged conflict in a loop.
96
+
97
+ ## 4. Interpret the actual outcome
98
+
99
+ An approved install probes before registration. A failed probe does not create
100
+ a registry entry. A mounted result reports the real identity, incarnation,
101
+ generation, and bounded discovery summary.
102
+
103
+ - `mounted`: registration and strict mount succeeded.
104
+ - `saved-degraded`: configuration was saved (`saved: true`) but strict mounting
105
+ failed. This is not installation success; offer Edit, Test, or Restart.
106
+ - `owner-changed` or `indeterminate`: inspect actual registry/mount facts. Never
107
+ claim rollback or zero effects.
108
+ - `cleanup-failed` / `cleanup-unknown`: follow the recovery action; do not start
109
+ an immediate same-name replacement.
110
+
111
+ Do not run an arbitrary discovered tool as an installation test. Discovery and
112
+ the reported mount outcome are the evidence.
113
+
114
+ ## 5. Keep authorization separate
115
+
116
+ After a mounted result, direct the user to the separate **Select for this
117
+ session** action and explicit **Apply**. Installation and the optional
118
+ new-session default are independent of the current draft and accepted sets.
119
+ Editing or restarting preserves the registration incarnation; removing and
120
+ re-adding the same identity creates a new one and requires reauthorization.
121
+
122
+ Credential rotation takes effect on the next explicit probe or restart, not as
123
+ a claimed live reconnect. Local credential storage avoids registry/profile and
124
+ transcript disclosure; it is not an isolation boundary against a same-UID agent.
@@ -203,7 +203,7 @@ export function digestApplyRequest(normalized) {
203
203
  */
204
204
  export function createApplyEngine(options) {
205
205
  const {
206
- store, locateSession, inventory, provider,
206
+ store, locateSession, inventory, provider, mcpRegistry,
207
207
  fence = createAdmissionFence(),
208
208
  drain = createDrainCoordinator(),
209
209
  conditions = () => [],
@@ -365,6 +365,19 @@ export function createApplyEngine(options) {
365
365
  const missing = normalized.skills.filter(key => (byKey.get(key) ?? []).length !== 1)
366
366
  if (missing.length) return { status: 'rejected', reason: 'selected-identity-missing', step: 2, missing }
367
367
 
368
+ let mcpIncarnations
369
+ if (mcpRegistry) {
370
+ let current
371
+ try { current = await mcpRegistry.read() } catch (error) {
372
+ return { status: 'rejected', reason: `mcp-registry-unavailable:${message(error)}`, step: 2 }
373
+ }
374
+ if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'rejected', reason: `mcp-registry-unavailable:${current.kind}`, step: 2 }
375
+ const servers = current.kind === 'ok' ? current.servers : {}
376
+ const missingMcp = normalized.mcpServers.filter(identity => !servers[identity])
377
+ if (missingMcp.length) return { status: 'rejected', reason: 'unregistered-mcp', step: 2, missing: missingMcp }
378
+ mcpIncarnations = Object.fromEntries(normalized.mcpServers.map(identity => [identity, servers[identity].incarnation ?? null]))
379
+ }
380
+
368
381
  // Step 3: prepare a pure snapshot, filtered view and verified content
369
382
  // handles. Nothing is published, installed or notified here.
370
383
  let prepared
@@ -388,7 +401,7 @@ export function createApplyEngine(options) {
388
401
 
389
402
  const previousSets = record.kind === 'ok' ? safeSets(record.payload) : { skills: [], mcpServers: [] }
390
403
  const removedServers = previousSets.mcpServers.filter(server => !normalized.mcpServers.includes(server))
391
- const payload = { skills: prepared.identities, mcpServers: normalized.mcpServers, origin: 'apply' }
404
+ const payload = { skills: prepared.identities, mcpServers: normalized.mcpServers, ...(mcpIncarnations ? { mcpIncarnations } : {}), origin: 'apply' }
392
405
  const requestDigest = digestApplyRequest(normalized)
393
406
 
394
407
  // Step 4: enter the admission fence, close removed-server gates, start draining.
@@ -496,7 +509,7 @@ export function createApplyEngine(options) {
496
509
  status: 'applied',
497
510
  revision: result.revision,
498
511
  receipt: result.receipt,
499
- effective: { skills: structuredClone(prepared.identities), mcpServers: [...normalized.mcpServers] },
512
+ effective: { skills: structuredClone(prepared.identities), mcpServers: [...normalized.mcpServers], ...(mcpIncarnations ? { mcpIncarnations } : {}) },
500
513
  unresolvedWarnings: normalized.unresolved.map(entry => ({ ...entry })),
501
514
  ...(draining.length ? { draining } : {}),
502
515
  ...(warnings.length ? { warnings } : {}),
@@ -564,6 +577,7 @@ export function createApplyEngine(options) {
564
577
  return {
565
578
  skills: Array.isArray(value.skills) ? value.skills : [],
566
579
  mcpServers: Array.isArray(value.mcpServers) ? /** @type {string[]} */ (value.mcpServers) : [],
580
+ ...(value.mcpIncarnations && typeof value.mcpIncarnations === 'object' && !Array.isArray(value.mcpIncarnations) ? { mcpIncarnations: structuredClone(value.mcpIncarnations) } : {}),
567
581
  }
568
582
  }
569
583
 
@@ -613,7 +627,7 @@ export function createApplyEngine(options) {
613
627
  // just-committed record — reported, never fatal.
614
628
  try {
615
629
  const sets = safeSets(payload)
616
- publishSnapshot?.(sessionId, { revision, skills: structuredClone(sets.skills), mcpServers: [...sets.mcpServers] })
630
+ publishSnapshot?.(sessionId, { revision, skills: structuredClone(sets.skills), mcpServers: [...sets.mcpServers], ...(sets.mcpIncarnations ? { mcpIncarnations: structuredClone(sets.mcpIncarnations) } : {}) })
617
631
  } catch (error) {
618
632
  degraded('snapshot-publication-degraded', error)
619
633
  }
@@ -21,6 +21,7 @@ import { openCapabilityStore } from './store/store.js'
21
21
  import { buildReceiptPayload, buildListPayload, buildConditionsPayload, buildPresetsPayload, buildDefaultGetPayload } from './read-payloads.js'
22
22
  import { PRESET_SELECTED_EVENT } from './preset-invalidation.js'
23
23
  import { createAudit } from '../shared/audit.js'
24
+ import { mcpDiagnostic, sanitizeMcpPayload } from './mcp-diagnostics.js'
24
25
 
25
26
  /** The host-root service key the typert gateway resolves (S27: ctx.get from the host root). */
26
27
  export const CAPABILITY_READ_SERVICE_KEY = 'weirCapabilityRead'
@@ -71,15 +72,74 @@ export function capabilityReadBridge(presetId) {
71
72
  return feeds?.size === 1 ? [...feeds][0].faces : null
72
73
  }
73
74
 
74
- function soleFeed(presetId) {
75
+ /**
76
+ * The isolated `weirMcpManager` owned by the exact preset revision a live
77
+ * session's Agent retains, or undefined when that cannot be proven. The
78
+ * registry's `serviceFor(agent, name)` reads `agent.ctx`'s retained mount, so
79
+ * an old session keeps resolving its old revision after a config change or a
80
+ * bundle toggle remounted the preset next to it. Everything is read through
81
+ * caller-supplied ctx getters (never a static runtime import).
82
+ * @param {string} sessionId
83
+ * @param {{ agents?: () => any, agentPresets?: () => any }} [runtime]
84
+ */
85
+ function sessionManager(sessionId, runtime) {
86
+ try {
87
+ const agent = runtime?.agents?.()?.get?.(sessionId)
88
+ // A cold or unknown session has no live Agent and hence no retained mount.
89
+ if (!agent?.ctx || agent.id !== sessionId) return undefined
90
+ const registry = runtime?.agentPresets?.()
91
+ if (typeof registry?.serviceFor !== 'function') return undefined
92
+ return registry.serviceFor(agent, 'weirMcpManager') ?? undefined
93
+ } catch { return undefined }
94
+ }
95
+
96
+ /**
97
+ * Resolve the feed owning ONE session's read/mutation.
98
+ *
99
+ * A single feed is the fast path (no agent lookup). Several feeds for one
100
+ * preset are legitimate — two retained revisions of the same preset stay
101
+ * mounted while old sessions still use the old one — so the owner is the
102
+ * unique feed whose `faces.mcpManager()` IS the manager the session's own
103
+ * revision provides. Every other outcome fails closed with `owner-ambiguous`:
104
+ * no runtime seam, a cold/unknown agent, a revision without a manager, no
105
+ * matching feed, or several feeds sharing that manager (overlapping
106
+ * generations of one revision). Never newest-wins.
107
+ * @param {string} presetId
108
+ * @param {string} sessionId
109
+ * @param {{ agents?: () => any, agentPresets?: () => any }} [runtime]
110
+ */
111
+ function sessionFeed(presetId, sessionId, runtime) {
75
112
  const feeds = bridgeFeeds.get(presetId)
76
113
  if (!feeds?.size) throw new CapabilityReadError('bridge-absent', `capability read: no feed for preset ${presetId}`)
77
- if (feeds.size !== 1) throw new CapabilityReadError('owner-ambiguous', `capability read: overlapping feeds for preset ${presetId}`)
78
- return [...feeds][0]
114
+ if (feeds.size === 1) return [...feeds][0]
115
+ const ambiguous = reason => new CapabilityReadError('owner-ambiguous', `capability read: overlapping feeds for preset ${presetId}: ${reason}`)
116
+ const manager = sessionManager(sessionId, runtime)
117
+ if (manager === undefined) throw ambiguous(`session ${JSON.stringify(sessionId)} has no live agent with a preset-owned MCP manager`)
118
+ const matches = [...feeds].filter(feed => {
119
+ try { return feed.faces?.mcpManager?.() === manager } catch { return false }
120
+ })
121
+ if (matches.length === 0) throw ambiguous(`no feed belongs to the revision of session ${JSON.stringify(sessionId)}`)
122
+ if (matches.length > 1) throw ambiguous(`${matches.length} feeds share the manager of session ${JSON.stringify(sessionId)}`)
123
+ return matches[0]
124
+ }
125
+
126
+ /**
127
+ * Exact feed token used by mutation ownership snapshots and their rechecks.
128
+ * @param {string} presetId
129
+ * @param {string} sessionId
130
+ * @param {{ agents?: () => any, agentPresets?: () => any }} [runtime] ctx getters for the host `agents` and `agentPresets` services
131
+ */
132
+ export function capabilityReadFeed(presetId, sessionId, runtime = undefined) { return sessionFeed(presetId, sessionId, runtime) }
133
+
134
+ /** @param {{ bridge?: Function, agents?: () => any, agentPresets?: () => any }} dependencies */
135
+ function feedResolver(dependencies) {
136
+ if (dependencies.bridge) return dependencies.bridge
137
+ const runtime = { agents: dependencies.agents, agentPresets: dependencies.agentPresets }
138
+ return (presetId, sessionId) => sessionFeed(presetId, sessionId, runtime)
79
139
  }
80
140
 
81
141
  /** Extract the current projected owner and its last committed selection generation. */
82
- function ownership(observation, sessionId) {
142
+ export function capabilityOwnership(observation, sessionId) {
83
143
  if (!observation || observation.header?.id !== sessionId) {
84
144
  throw new CapabilityReadError('unknown-session', `capability read: unknown session ${JSON.stringify(sessionId)}`)
85
145
  }
@@ -95,7 +155,7 @@ function ownership(observation, sessionId) {
95
155
 
96
156
  /** Create the host read service. Observations are caller-owned leases, always disposed. */
97
157
  export function createCapabilityReadService(dependencies = {}) {
98
- const bridgeFor = dependencies.bridge ?? soleFeed
158
+ const bridgeFor = feedResolver(dependencies)
99
159
  const storeFor = dependencies.store ?? (profileContext => openCapabilityStore({ profileContext }))
100
160
  const ensure = dependencies.ensureRegistered ?? (() => true)
101
161
  const onDegraded = dependencies.onDegraded ?? (() => {})
@@ -105,7 +165,7 @@ export function createCapabilityReadService(dependencies = {}) {
105
165
  try {
106
166
  lease = await observeSession(sessionId)
107
167
  if (!lease || typeof lease[Symbol.dispose] !== 'function') throw new CapabilityReadError('owner-unavailable', 'capability read: session observation is unavailable')
108
- return { lease, owner: ownership(lease, sessionId) }
168
+ return { lease, owner: capabilityOwnership(lease, sessionId) }
109
169
  } catch (cause) {
110
170
  lease?.[Symbol.dispose]?.()
111
171
  if (cause instanceof CapabilityReadError) throw cause
@@ -117,7 +177,7 @@ export function createCapabilityReadService(dependencies = {}) {
117
177
  const first = await observe(sessionId)
118
178
  try {
119
179
  const { presetId, cwd, selectionSeq } = first.owner
120
- const feed = bridgeFor(presetId)
180
+ const feed = bridgeFor(presetId, sessionId)
121
181
  if (!feed) throw new CapabilityReadError('bridge-absent', `capability read: no feed for preset ${presetId}`)
122
182
  const faces = feed.faces ?? feed
123
183
  const options = { cwd, scope: { session: { id: sessionId } } }
@@ -149,7 +209,7 @@ export function createCapabilityReadService(dependencies = {}) {
149
209
  }
150
210
  const second = await observe(sessionId)
151
211
  try {
152
- if (second.owner.presetId !== presetId || second.owner.cwd !== cwd || second.owner.selectionSeq !== selectionSeq || bridgeFor(presetId) !== feed) {
212
+ if (second.owner.presetId !== presetId || second.owner.cwd !== cwd || second.owner.selectionSeq !== selectionSeq || bridgeFor(presetId, sessionId) !== feed) {
153
213
  throw new CapabilityReadError('owner-changed', 'capability read: owner or feed changed during read')
154
214
  }
155
215
  return payload
@@ -185,27 +245,143 @@ export function createCapabilityReadService(dependencies = {}) {
185
245
  }
186
246
  }
187
247
 
248
+ export const CAPABILITY_MUTATION_SERVICE_KEY = 'weirCapabilityMutation'
249
+ export const CAPABILITY_MUTATION_NAMESPACE = 'weirCapabilityMutations'
250
+ export const CAPABILITY_MUTATION_METHODS = ['mutate', 'batch']
251
+
252
+ export function sameMutationOwner(first, second) {
253
+ const profile = value => value ? `${value.home ?? ''}\0${value.name ?? ''}` : ''
254
+ return first.owner.presetId === second.owner.presetId
255
+ && first.owner.cwd === second.owner.cwd
256
+ && first.feed === second.feed
257
+ && profile(first.profile) === profile(second.profile)
258
+ }
259
+
260
+ /** Session-preset/profile/feed ownership for mutations; selectionSeq is not compared. */
261
+ export function createCapabilityMutationService(dependencies = {}) {
262
+ const bridgeFor = feedResolver(dependencies)
263
+ const ensure = dependencies.ensureRegistered ?? (() => true)
264
+ const observeSession = dependencies.observeSession ?? (sessionId => dependencies.sessionQuery?.()?.observeSession(sessionId))
265
+ const observe = async sessionId => {
266
+ let lease
267
+ try {
268
+ lease = await observeSession(sessionId)
269
+ if (!lease || typeof lease[Symbol.dispose] !== 'function') throw new CapabilityReadError('owner-unavailable', 'capability mutation: session observation unavailable')
270
+ const owner = capabilityOwnership(lease, sessionId)
271
+ const feed = bridgeFor(owner.presetId, sessionId)
272
+ if (!feed) throw new CapabilityReadError('bridge-absent', `capability mutation: no feed for preset ${owner.presetId}`)
273
+ const faces = feed.faces ?? feed
274
+ return { lease, owner, feed, faces, profile: faces.profileContext?.() }
275
+ } catch (cause) {
276
+ lease?.[Symbol.dispose]?.()
277
+ if (cause instanceof CapabilityReadError) throw cause
278
+ throw new CapabilityReadError('unknown-session', `capability mutation: cannot observe session ${JSON.stringify(sessionId)}`, { cause })
279
+ }
280
+ }
281
+ async function mutate(sessionId, request, batch = false) {
282
+ if (!ensure()) throw new CapabilityReadError('channel-unavailable', 'capability mutation channel is not registered')
283
+ const first = await observe(sessionId)
284
+ 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
+ }
291
+ if (typeof first.faces.mcpOperations !== 'function') throw new CapabilityReadError('bridge-absent', 'capability mutation: owning preset has no MCP operations')
292
+ const operations = first.faces.mcpOperations({ id: sessionId, header: { cwd: first.owner.cwd } }, { assertOwner })
293
+ let result
294
+ try {
295
+ result = batch ? await operations.executeBatch(request) : request?.action === 'preview' ? await operations.preview(request) : await operations.execute(request)
296
+ } catch (cause) {
297
+ result = { status: cause?.code === 'owner-changed' ? 'owner-changed' : 'validation-failed', saved: false, error: mcpDiagnostic(cause, { code: cause?.code ?? 'validation-failed' }) }
298
+ }
299
+ // A response may be stale by the time it is serialized. Never claim a
300
+ // rollback; return the operation's actual saved facts with owner-changed.
301
+ try {
302
+ await assertOwner()
303
+ } catch (cause) {
304
+ if (result.status !== 'owner-changed') {
305
+ return sanitizeMcpPayload({
306
+ ...result,
307
+ status: 'owner-changed',
308
+ ownerChanged: true,
309
+ actual: {
310
+ status: result.status,
311
+ saved: result.saved === true,
312
+ registryRevision: result.registryRevision ?? null,
313
+ generation: result.generation ?? null,
314
+ incarnation: result.incarnation ?? null,
315
+ },
316
+ error: mcpDiagnostic(cause, { code: 'owner-changed' }),
317
+ })
318
+ }
319
+ }
320
+ return sanitizeMcpPayload(result)
321
+ } finally {
322
+ first.lease[Symbol.dispose]()
323
+ }
324
+ }
325
+ return {
326
+ mutate: (sessionId, request) => mutate(sessionId, request, false),
327
+ batch: (sessionId, request) => mutate(sessionId, request, true),
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Every endpoint the gateway resolves through this package's ONE host-face
333
+ * contribution, in contribution order. A live read says nothing about write
334
+ * availability, so liveness is always checked across this whole set.
335
+ */
336
+ export const CAPABILITY_ENDPOINTS = [
337
+ ...CAPABILITY_READ_METHODS.map(method => `${CAPABILITY_READ_NAMESPACE}/${method}`),
338
+ ...CAPABILITY_MUTATION_METHODS.map(method => `${CAPABILITY_MUTATION_NAMESPACE}/${method}`),
339
+ ]
340
+
188
341
  /**
189
342
  * The hand-written typert contribution (S27: empty schemas/model, src-json
190
343
  * codecs, no zod, no generated artifacts). Parameters and results are plain
191
344
  * JSON; the envelope validates the argument name only.
345
+ *
346
+ * ALL five read and two mutation invocations live in ONE contribution, because
347
+ * the registry keys exactly one record per package+face and rejects any second
348
+ * registration while that record is live: two separate `weir-harness` host-face
349
+ * contributions could never overlap generations, and the face the mutation half
350
+ * used to declare (`host-mutations`) is not a face the registry accepts at all —
351
+ * register() threw and apply() swallowed it into a warning, so the write channel
352
+ * was silently dead. One atomic batch also means recovery restores every
353
+ * endpoint or none.
192
354
  */
193
- export function capabilityReadContribution() {
355
+ export function capabilityContribution() {
194
356
  return {
195
- // The registry's validatePackage requires both (missing = silently dead registration): it validates the package name and keys the package record by face.
357
+ // The registry's validatePackage requires both (missing = silently dead registration): it validates the package name, rejects any face except host/client, and keys the package record by face.
196
358
  package: 'weir-harness',
197
359
  face: 'host',
198
360
  schemas: [],
199
361
  model: { services: [], events: [], objects: [] },
200
- invocations: CAPABILITY_READ_METHODS.map(method => ({
201
- id: `weir-harness.${CAPABILITY_READ_NAMESPACE}.${method}`,
202
- service: CAPABILITY_READ_SERVICE_KEY,
203
- namespace: CAPABILITY_READ_NAMESPACE,
204
- method,
205
- invocation: { kind: 'direct' },
206
- parameters: [{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'src-json' } }],
207
- result: { mode: 'src-json' },
208
- })),
362
+ invocations: [
363
+ ...CAPABILITY_READ_METHODS.map(method => ({
364
+ id: `weir-harness.${CAPABILITY_READ_NAMESPACE}.${method}`,
365
+ service: CAPABILITY_READ_SERVICE_KEY,
366
+ namespace: CAPABILITY_READ_NAMESPACE,
367
+ method,
368
+ invocation: { kind: 'direct' },
369
+ parameters: [{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'src-json' } }],
370
+ result: { mode: 'src-json' },
371
+ })),
372
+ ...CAPABILITY_MUTATION_METHODS.map(method => ({
373
+ id: `weir-harness.${CAPABILITY_MUTATION_NAMESPACE}.${method}`,
374
+ service: CAPABILITY_MUTATION_SERVICE_KEY,
375
+ namespace: CAPABILITY_MUTATION_NAMESPACE,
376
+ method,
377
+ invocation: { kind: 'direct' },
378
+ parameters: [
379
+ { name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'src-json' } },
380
+ { name: 'request', wire: 'request', source: 'json', codec: { mode: 'src-json' } },
381
+ ],
382
+ result: { mode: 'src-json' },
383
+ })),
384
+ ],
209
385
  }
210
386
  }
211
387
 
@@ -222,6 +398,20 @@ export function capabilityReadContribution() {
222
398
  * gateway dispatch resolves through it as well, so the loader must order this
223
399
  * row after the typert provider — including a composition where a user-layer
224
400
  * patch injects typert after the bundle rows (S27).
401
+ *
402
+ * Registration lifecycle (S27.5, gateway/definition-unavailable): a fiber
403
+ * reload runs apply() again while the previous incarnation's effects are still
404
+ * unwinding, so BOTH the service provision and the contribution registration can
405
+ * lose that race, and the old incarnation then withdraws what the new one
406
+ * skipped. Because the gateway refuses a withdrawn strict definition before it
407
+ * ever reaches a service method (hasSeen + no live definition), the recovery
408
+ * must live in this plugin's own fiber: it subscribes to `typert.local` changes
409
+ * and re-attempts provision+registration on a coalesced microtask while the
410
+ * endpoints are incomplete. Every one of those facts is per-fiber state: a
411
+ * pending retry, the disposal latch and the warning latch belong to THIS
412
+ * generation, so an old generation can never consume the survivor's retry, and
413
+ * a disposed fiber cannot resurrect the endpoints (Cordis rejects its effect
414
+ * creation with INACTIVE_EFFECT — the final barrier, never a retry loop).
225
415
  */
226
416
  export function apply(ctx) {
227
417
  const warn = text => ctx.logger?.warn?.(text)
@@ -236,46 +426,107 @@ export function apply(ctx) {
236
426
  warn('weir capability remote: typert service is unavailable in this composition; capability reads stay unavailable on the client')
237
427
  return
238
428
  }
239
- // Idempotent, self-healing registration. The registry rejects a duplicate
240
- // package contribution, so a re-mount that races the previous fiber's
241
- // disposal must not treat "already registered" as fatal when the endpoints
242
- // are actually live — and a later withdrawal (that previous fiber finally
243
- // disposing) must be repaired on the next read rather than left dead.
244
- const endpointLive = () => {
245
- try { return Boolean(typert.local?.get?.(`${CAPABILITY_READ_NAMESPACE}/receipt`)) } catch { return false }
429
+ // Per-fiber lifecycle state. NEVER module-global: the previous incarnation's
430
+ // listener and this one can both observe the same withdrawal.
431
+ let disposed = false
432
+ let retryQueued = false
433
+ let registrationWarned = false
434
+ const endpointLive = endpoint => {
435
+ try { return Boolean(typert.local?.get?.(endpoint)) } catch { return false }
246
436
  }
247
- const ensureRegistered = () => {
248
- if (endpointLive()) return true
437
+ const endpointsComplete = () => CAPABILITY_ENDPOINTS.every(endpointLive)
438
+ let readService
439
+ let mutationService
440
+ // Provide one service unless another incarnation still holds the host-root
441
+ // key. A host-root provide lives on the ROOT fiber (S27: the gateway resolves
442
+ // ctx.get from the host root), so the first incarnation's receiver survives
443
+ // every reload; replacing it is neither possible nor needed. A lost race is
444
+ // not fatal — the retry below keeps every endpoint self-healing.
445
+ const ensureService = (key, service) => {
249
446
  try {
250
- typert.register(capabilityReadContribution())
447
+ const current = typeof ctx.get === 'function' ? ctx.get(key) : undefined
448
+ if (current === service) return true
449
+ if (current !== undefined) return false
450
+ ctx.root.reflect.provide(key, service)
251
451
  return true
452
+ } catch { return false }
453
+ }
454
+ const ensureServices = () => {
455
+ const read = ensureService(CAPABILITY_READ_SERVICE_KEY, readService)
456
+ const mutation = ensureService(CAPABILITY_MUTATION_SERVICE_KEY, mutationService)
457
+ return read && mutation
458
+ }
459
+ // Exactly ONE register attempt per call: the registry rejects the whole batch
460
+ // or commits all of it, so looping could only repeat the same rejection.
461
+ const ensureContribution = () => {
462
+ if (endpointsComplete()) return true
463
+ try {
464
+ typert.register(capabilityContribution())
252
465
  } catch (cause) {
253
- // Duplicate-package race: a previous generation's registration still
254
- // serves the endpoints — that is success, not a warning.
255
- if (endpointLive()) return true
256
- warn(`weir capability remote: registration failed (${cause instanceof Error ? cause.message : String(cause)}); capability reads stay unavailable on the client`)
257
- return false
466
+ if (!endpointsComplete()) {
467
+ // A disposed fiber's rejection is terminal (the survivor owns the retry).
468
+ if (cause?.code === 'INACTIVE_EFFECT') {
469
+ disposed = true
470
+ return false
471
+ }
472
+ if (!registrationWarned) {
473
+ registrationWarned = true
474
+ warn(`weir capability remote: registration failed (${cause instanceof Error ? cause.message : String(cause)}); capability endpoints stay unavailable on the client`)
475
+ }
476
+ return false
477
+ }
258
478
  }
479
+ return endpointsComplete()
259
480
  }
481
+ const ensureRegistered = () => ensureContribution()
482
+ // Coalesce a burst of registry changes into one microtask. Waiting a turn also
483
+ // lets the disposing incarnation finish synchronously releasing its service
484
+ // keys, which a synchronous retry inside the change callback would still see.
485
+ const queueRetry = () => {
486
+ if (disposed || retryQueued) return
487
+ retryQueued = true
488
+ queueMicrotask(() => {
489
+ retryQueued = false
490
+ if (disposed || endpointsComplete()) return
491
+ ensureServices()
492
+ ensureContribution()
493
+ })
494
+ }
495
+ // Latch disposal as early as the fiber allows, before a queued retry can run
496
+ // against a fiber Cordis has already detached. A fiber that is already gone
497
+ // needs no latch: its next register() is rejected with INACTIVE_EFFECT.
498
+ try {
499
+ if (typeof ctx.effect === 'function') ctx.effect(() => () => { disposed = true })
500
+ } catch { /* detached fiber: the effect rejection is the barrier */ }
260
501
  try {
261
502
  const audit = createAudit(ctx)
262
503
  const onDegraded = facts => {
263
504
  warn(`weir capability read degraded: method=${facts.method} session=${facts.sessionId} preset=${facts.presetId} header=${facts.headerPreset} source=${facts.source} warnings=${facts.warnings.join(';')} provider=${facts.providerReason ?? '-'}: ${facts.providerError ?? '-'}`)
264
505
  audit({ id: facts.sessionId, header: { cwd: facts.cwd } }, 'capability-read', facts)
265
506
  }
266
- const service = createCapabilityReadService({ ensureRegistered, sessionQuery: () => ctx.get?.('sessionQuery'), onDegraded })
267
- // The exact frozen binding validateBinding requires (S27): { service,
268
- // serviceKey, namespace } — built by hand, no protocol import.
269
- service.typertRemote = Object.freeze({
270
- service,
271
- serviceKey: CAPABILITY_READ_SERVICE_KEY,
272
- namespace: CAPABILITY_READ_NAMESPACE,
273
- })
274
- Object.freeze(service)
275
- ctx.root.reflect.provide(CAPABILITY_READ_SERVICE_KEY, service)
276
- ensureRegistered()
507
+ // Per-session owner resolution reads the host agents registry and the
508
+ // preset registry lazily (both host-root services; no mount-time inject).
509
+ const runtime = { agents: () => ctx.get?.('agents'), agentPresets: () => ctx.get?.('agentPresets') }
510
+ readService = createCapabilityReadService({ ensureRegistered, sessionQuery: () => ctx.get?.('sessionQuery'), onDegraded, ...runtime })
511
+ readService.typertRemote = Object.freeze({ service: readService, serviceKey: CAPABILITY_READ_SERVICE_KEY, namespace: CAPABILITY_READ_NAMESPACE })
512
+ Object.freeze(readService)
513
+ mutationService = createCapabilityMutationService({ ensureRegistered, sessionQuery: () => ctx.get?.('sessionQuery'), ...runtime })
514
+ mutationService.typertRemote = Object.freeze({ service: mutationService, serviceKey: CAPABILITY_MUTATION_SERVICE_KEY, namespace: CAPABILITY_MUTATION_NAMESPACE })
515
+ Object.freeze(mutationService)
277
516
  } catch (cause) {
278
517
  warn(`weir capability remote: setup failed (${cause instanceof Error ? cause.message : String(cause)}); capability reads stay unavailable on the client`)
518
+ return
519
+ }
520
+ // Provision BEFORE registering: the gateway resolves the service key of a
521
+ // live definition, so an endpoint must never outlive its receiver.
522
+ ensureServices()
523
+ ensureContribution()
524
+ // Independent per-fiber subscription: hosts without it keep the older
525
+ // self-heal-on-call behaviour (each service call still checks the endpoints).
526
+ try {
527
+ typert.local?.subscribe?.(() => { if (!endpointsComplete()) queueRetry() })
528
+ } catch (cause) {
529
+ warn(`weir capability remote: registration watch failed (${cause instanceof Error ? cause.message : String(cause)}); the capability channel only self-heals on the next call`)
279
530
  }
280
531
  }
281
532
 
@@ -154,7 +154,7 @@ export function resolveSkillNamesForSave(names, candidates, appliedSkills) {
154
154
  return { identities, missing, ambiguous }
155
155
  }
156
156
 
157
- export function resolveInitialSelection({ defaultsRecord, builtinIdentities = [], enabledMcpIdentities = [] }) {
157
+ export function resolveInitialSelection({ defaultsRecord, builtinIdentities = [], enabledMcpIdentities = [], mcpIncarnations }) {
158
158
  // A cleared marker is an explicit removal (9.4): it behaves as ABSENT —
159
159
  // clearing is never an explicit empty set, which stays a savable choice.
160
160
  const cleared = defaultsRecord && defaultsRecord.kind === 'ok'
@@ -169,13 +169,14 @@ export function resolveInitialSelection({ defaultsRecord, builtinIdentities = []
169
169
  selection: {
170
170
  skills: Array.isArray(payload.skills) ? structuredClone(payload.skills) : [],
171
171
  mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers).slice() : [],
172
+ ...((mcpIncarnations ?? payload.mcpIncarnations) ? { mcpIncarnations: structuredClone(mcpIncarnations ?? payload.mcpIncarnations) } : {}),
172
173
  },
173
174
  missing: Array.isArray(payload.unresolvedRefs) ? payload.unresolvedRefs.slice() : [],
174
175
  }
175
176
  }
176
177
  return {
177
178
  source: 'builtin-baseline',
178
- selection: { skills: builtinIdentities.slice(), mcpServers: enabledMcpIdentities.slice() },
179
+ selection: { skills: builtinIdentities.slice(), mcpServers: enabledMcpIdentities.slice(), ...(mcpIncarnations ? { mcpIncarnations: structuredClone(mcpIncarnations) } : {}) },
179
180
  missing: [],
180
181
  }
181
182
  }