weir-harness 2.0.0 → 2.1.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.
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 +3 -3
  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 +368 -142
  24. package/src/capabilities/mcp-mount.js +219 -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 +161 -56
  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
@@ -15,6 +15,7 @@ import { createLifecycleSnapshots } from './lifecycle-snapshot.js'
15
15
  import { preloadLifecycleSnapshots } from './lifecycle-preload.js'
16
16
  import { classifySelectionFailure } from './selection-status.js'
17
17
  import { resolveInitialSelection, baselineSkillIdentities, bindDefaultSkillNames } from './initial-selection.js'
18
+ import { partitionUnresolvedSkills } from './skill-applicability.js'
18
19
  import { workspaceKeyOf, createPresetVerbs } from './preset-library.js'
19
20
  import { createDefaultsVerbs } from './defaults-transaction.js'
20
21
  import { buildPresetsPayload, buildDefaultGetPayload } from './read-payloads.js'
@@ -23,8 +24,11 @@ import { createApplyEngine } from './apply-engine.js'
23
24
  import { createSessionDrainAdapter } from './mcp-drain.js'
24
25
  import { createSelectionNotifier } from './selection-notify.js'
25
26
  import { createMcpRegistry } from './mcp-registry.js'
27
+ import { createMcpOperations } from './mcp-operations.js'
28
+ import { mcpDiagnostic } from './mcp-diagnostics.js'
26
29
  import { buildReceiptPayload, buildListPayload, buildConditionsPayload } from './read-payloads.js'
27
- import { feedCapabilityReadBridge } from './capability-remote.js'
30
+ import { feedCapabilityReadBridge, capabilityReadFeed, capabilityOwnership, createCapabilityMutationService, sameMutationOwner } from './capability-remote.js'
31
+ import { createMcpInstallTool } from './mcp-install-tool.js'
28
32
  import { parseFrontmatter } from './frontmatter.js'
29
33
  import { createAudit } from '../shared/audit.js'
30
34
 
@@ -84,8 +88,14 @@ export function createSkillSelectionPlugin(dependencies = {}) {
84
88
  // between the host's session publication and our capture, or a
85
89
  // hand-built session) is REFUSED — never granted the root
86
90
  // baseline (6.4 subagent rule; explicit resume recaptures).
91
+ //
92
+ // Only a TRUE delegated child (depth > 0) is refused: a depth-0
93
+ // fork also carries a parentSession header, and when its source
94
+ // never established any effective snapshot the creation-time
95
+ // capture is skipped on purpose so that this read performs the
96
+ // ordinary root initialization for the fork itself.
87
97
  const header = options.scope?.session?.header ?? {}
88
- const isSubagent = (header.delegationDepth ?? 0) > 0 || typeof header.parentSession === 'string'
98
+ const isDelegatedChild = (header.delegationDepth ?? 0) > 0
89
99
  let inherited
90
100
  try {
91
101
  inherited = await store.read({ kind: 'inherited', sessionId })
@@ -94,7 +104,12 @@ export function createSkillSelectionPlugin(dependencies = {}) {
94
104
  }
95
105
  if (inherited.kind === 'ok') return inherited.payload?.skills
96
106
  if (inherited.kind !== 'absent') throw new Error(`Inherited skill snapshot is ${inherited.kind}`)
97
- if (isSubagent) throw new Error('Inherited skill snapshot is absent')
107
+ if (isDelegatedChild) throw new Error('Inherited skill snapshot is absent')
108
+ // Root initialization is only for a PROVEN absent source. A depth-0
109
+ // fork whose capture or existing policy already became BLOCKED must
110
+ // keep the inherited-snapshot failure, never bypass it with a fresh
111
+ // baseline; an explicit accepted Apply remains the recovery path.
112
+ if (lifecycle?.snapshotFor?.(sessionId)?.state === 'blocked') throw new Error('Inherited skill snapshot is absent')
98
113
  return initialSelection(options)
99
114
  }
100
115
  if (record.kind !== 'ok') throw new Error(`Skill selection policy is ${record.kind}`)
@@ -151,18 +166,26 @@ export function createSkillSelectionPlugin(dependencies = {}) {
151
166
  }
152
167
  let builtinIdentities = []
153
168
  let enabledMcpIdentities = []
169
+ const registry = await createMcpRegistry({ store }).read()
170
+ if (registry.kind !== 'ok' && registry.kind !== 'absent') throw new Error(`MCP registry is ${registry.kind}`)
171
+ const registryServers = registry.kind === 'ok' ? registry.servers : {}
172
+ // Only a FULLY formed initial result may become a lifecycle snapshot:
173
+ // a baseline or a name binding derived from an incomplete inventory is
174
+ // a best-effort view, never an inheritable set (D2).
175
+ let complete = true
154
176
  if (defaultsRecord.kind === 'absent') {
155
177
  const snapshot = await inventory(options)
178
+ complete = snapshot?.complete !== false
156
179
  // Baseline scopes are row-configurable (default builtin-only);
157
180
  // the weir-creative preset adds 'custom' for its fused skills.
158
181
  builtinIdentities = baselineSkillIdentities(snapshot?.candidates, config.baselineScopes)
159
- try {
160
- const registry = await store.read({ kind: 'mcp-registry' })
161
- const payload = registry.kind === 'ok' ? registry.payload ?? {} : {}
162
- enabledMcpIdentities = Array.isArray(/** @type {Record<string, unknown>} */ (payload).enabled) ? /** @type {string[]} */ (/** @type {Record<string, unknown>} */ (payload).enabled) : []
163
- } catch { /* an unreadable registry enables no managed identity by default */ }
182
+ enabledMcpIdentities = registry.kind === 'ok' ? registry.enabled : []
164
183
  }
165
184
  const resolved = resolveInitialSelection({ defaultsRecord, builtinIdentities, enabledMcpIdentities })
185
+ for (const identity of resolved.selection.mcpServers) {
186
+ if (!registryServers[identity]) resolved.missing.push({ kind: 'mcp', ref: identity, reason: 'configured default is not registered' })
187
+ }
188
+ resolved.selection.mcpIncarnations = Object.fromEntries(resolved.selection.mcpServers.filter(identity => registryServers[identity]).map(identity => [identity, registryServers[identity].incarnation ?? null]))
166
189
  // Name-string skill entries in a workspace default (the client
167
190
  // draft's wire shape, written by default-save from:'draft') bind
168
191
  // against the live inventory by unique name; unbindable entries
@@ -171,6 +194,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
171
194
  // entirely — it still only runs on the builtin-baseline path.
172
195
  if (resolved.source === 'workspace-default' && resolved.selection.skills.some(entry => typeof entry === 'string')) {
173
196
  const snapshot = await inventory(options)
197
+ complete = complete && snapshot?.complete !== false
174
198
  const bound = bindDefaultSkillNames(resolved.selection.skills, snapshot?.candidates)
175
199
  resolved.selection.skills = bound.skills
176
200
  resolved.missing = [...resolved.missing, ...bound.missing]
@@ -178,6 +202,23 @@ export function createSkillSelectionPlugin(dependencies = {}) {
178
202
  const sessionId = options.scope?.session?.id
179
203
  if (typeof sessionId === 'string' && sessionId.length) {
180
204
  initialReports.set(sessionId, { source: resolved.source, missing: resolved.missing, mcpServers: resolved.selection.mcpServers })
205
+ // Effective-set publication for Fork inheritance: a depth-0 fork of a
206
+ // session whose initial set was already resolved must inherit THAT
207
+ // set, not an empty one. The result is published as the session's
208
+ // revision-0 lifecycle snapshot — never persisted, never an accepted
209
+ // record. A snapshot that appeared while this async resolution was
210
+ // in flight (an accepted Apply, a captured inheritance) wins, and a
211
+ // partially resolved/incomplete result is not published at all.
212
+ if (complete && lifecycle && typeof lifecycle.peek === 'function' && typeof lifecycle.publish === 'function'
213
+ && lifecycle.peek(sessionId) === null) {
214
+ lifecycle.publish(sessionId, {
215
+ revision: 0,
216
+ skills: resolved.selection.skills,
217
+ mcpServers: resolved.selection.mcpServers,
218
+ mcpIncarnations: resolved.selection.mcpIncarnations,
219
+ captured: null,
220
+ })
221
+ }
181
222
  }
182
223
  return resolved.selection.skills
183
224
  })
@@ -262,6 +303,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
262
303
  locateSession: session => session,
263
304
  inventory: (options) => inventory(options),
264
305
  provider,
306
+ mcpRegistry: createMcpRegistry({ store: storeFor() }),
265
307
  ...applyEngineHooks({ ctx, lifecycle, notifier, audit }),
266
308
  })
267
309
  return sharedEngine
@@ -403,6 +445,11 @@ export function createSkillSelectionPlugin(dependencies = {}) {
403
445
  matches.push(candidate)
404
446
  parsedByName.set(candidate.name, matches)
405
447
  }
448
+ // The unresolved draft entries keep their ORIGINAL shape: a legacy
449
+ // name string is not an identity and cannot be classified by
450
+ // workspace, while an identity object carries the scope/root the
451
+ // cross-workspace partition needs. Collapsing both to a name here
452
+ // is what made a foreign project reference unactionable.
406
453
  const missing = []
407
454
  const identities = []
408
455
  const admitted = new Set()
@@ -415,7 +462,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
415
462
  } else {
416
463
  const identity = createSkillIdentity(entry)
417
464
  candidate = byKey.get(skillIdentityKey(identity))
418
- if (!candidate) missing.push(identity.name)
465
+ if (!candidate) missing.push(identity)
419
466
  }
420
467
  if (!candidate) continue
421
468
  const key = skillIdentityKey(candidate.identity)
@@ -423,19 +470,26 @@ export function createSkillSelectionPlugin(dependencies = {}) {
423
470
  admitted.add(key)
424
471
  identities.push(candidate.identity)
425
472
  }
426
- // preset-skill-applicability (D2): partition the missing names —
427
- // KNOWN-BUT-INAPPLICABLE (resolvable in a profile-supplemental
428
- // directory, e.g. creative-preset skills carried by a workspace
429
- // default) are SKIPPED with a per-name reason while the
430
- // resolvable remainder commits; genuinely unknown names keep the
431
- // hard-failure contract. Records are never rewritten for
432
- // applicability, so a preset round-trip stays lossless.
433
- const unknown = []
434
- const skipped = []
435
- for (const name of missing) {
436
- if (supplementalSkillKnown(config.supplementalSkillDirs, name)) skipped.push({ name, reason: 'not applicable in this agent preset' })
437
- else unknown.push(name)
438
- }
473
+ // preset applicability partition (D2 + cross-workspace D4):
474
+ // - KNOWN-BUT-INAPPLICABLE names (resolvable in a profile-supplemental
475
+ // directory, e.g. creative-preset skills carried by a workspace
476
+ // default) are SKIPPED with a per-name reason;
477
+ // - a project identity PROVEN to belong to another workspace (its
478
+ // canonical root is outside every current project Skill root of a
479
+ // COMPLETE inventory) is SKIPPED as not applicable in this
480
+ // workspace — the preset contract, not a same-name conflict, and
481
+ // never a local same-name substitute;
482
+ // - everything else keeps the hard-failure contract with the draft
483
+ // preserved (a local identity the inventory no longer resolves, a
484
+ // project identity of THIS workspace, an incomplete inventory, or
485
+ // an unresolvable current-workspace root set authorizes no skip).
486
+ // Records are never rewritten for applicability, so a preset
487
+ // round-trip stays lossless.
488
+ const { skipped, unknown } = partitionUnresolvedSkills(missing, {
489
+ roots: inventoryResult?.roots,
490
+ complete: inventoryResult?.complete,
491
+ knownSupplemental: name => supplementalSkillKnown(config.supplementalSkillDirs, name),
492
+ })
439
493
  if (unknown.length > 0) return { kind: 'error', text: JSON.stringify({ status: 'missing', missing: unknown, ...(skipped.length > 0 ? { skipped } : {}) }) }
440
494
  const response = await face.applySelection(
441
495
  { sessionId: agent.id, cwd: agent.session?.header?.cwd, presetId: 'weir' },
@@ -448,41 +502,36 @@ export function createSkillSelectionPlugin(dependencies = {}) {
448
502
  }
449
503
  }
450
504
 
451
- /**
452
- * mcp-add: register a new managed MCP server and mount it
453
- * immediately (8.1 registry + manager start). Payload:
454
- * {identity, label, command, args?: [], env?: {}, serverName?}
455
- */
456
- const mcpAddVerb = async (spec) => {
505
+ const mcpVerb = (action) => async (spec = {}, { agent }) => {
457
506
  try {
458
- if (typeof spec.identity !== 'string' || typeof spec.label !== 'string' || typeof spec.command !== 'string' || spec.command.length === 0) {
507
+ if (action === 'mcp-add' && (typeof spec.identity !== 'string' || typeof spec.label !== 'string' || typeof spec.command !== 'string' || spec.command.length === 0)) {
459
508
  return { kind: 'error', text: 'mcp-add needs identity, label and command strings' }
460
509
  }
461
- const store = openStore()
462
- const registry = createMcpRegistry({ store })
463
- const current = await registry.read()
464
- const expectedRevision = current.kind === 'ok' ? current.revision : 0
465
- const entry = {
466
- identity: spec.identity,
467
- label: spec.label,
468
- owner: { kind: 'global', key: 'installation' },
469
- transport: { kind: 'stdio', ref: `cmd:${spec.command}` },
470
- client: {
471
- transport: 'stdio',
472
- serverName: typeof spec.serverName === 'string' && spec.serverName.length ? spec.serverName : spec.identity,
473
- command: spec.command,
474
- args: Array.isArray(spec.args) ? spec.args.map(String) : [],
475
- env: spec.env && typeof spec.env === 'object' && !Array.isArray(spec.env) ? spec.env : {},
476
- reconnect: { enabled: true },
477
- },
510
+ const { expectedRevision, confirmNamespaceChange, targetFingerprint, attemptId, batch, mode, refsOnly, surface: _ignoredSurface, ...fields } = spec
511
+ const operation = action === 'mcp-default' ? 'setDefault' : action === 'mcp-preview' ? 'preview' : action === 'mcp-batch' ? 'batch' : action.slice(4)
512
+ const request = {
513
+ ...spec,
514
+ action: operation === 'batch' ? 'add' : operation,
515
+ refsOnly: action === 'mcp-add' ? false : true,
516
+ // The command channel is refs-only (legacy mcp-add aside). A
517
+ // caller-supplied `surface` is ignored here: only the Host UI
518
+ // mutation channel may accept transient literal secrets.
519
+ surface: action === 'mcp-add' ? 'legacy' : 'refs',
478
520
  }
479
- const committed = await registry.register(entry, expectedRevision)
480
- if (committed.status !== 'committed') return { kind: 'error', text: JSON.stringify({ status: committed.status }) }
481
- const manager = ctx.get?.('weirMcpManager')
482
- if (manager?.start) await manager.start()
483
- return { kind: 'success', text: JSON.stringify({ status: 'registered', identity: entry.identity, generation: 1, mounted: Boolean(manager?.start) }) }
521
+ if (!spec.config && !spec.document && !['remove', 'restart', 'setDefault', 'recover'].includes(operation)) {
522
+ if (operation === 'batch' || fields.mcpServers) request.document = fields
523
+ else request.config = fields
524
+ }
525
+ const operations = face.mcpOperations(agent.session)
526
+ const result = operation === 'preview'
527
+ ? await operations.preview(request)
528
+ : operation === 'batch'
529
+ ? await operations.executeBatch(request)
530
+ : await operations.execute(request)
531
+ const successful = new Set(['preview', 'mounted', 'saved', 'removed', 'recovered'])
532
+ return { kind: successful.has(result.status) ? 'success' : 'error', text: JSON.stringify(result) }
484
533
  } catch (cause) {
485
- return { kind: 'error', text: `capabilities mcp-add failed: ${cause instanceof Error ? cause.message : String(cause)}` }
534
+ return { kind: 'error', text: JSON.stringify({ status: 'validation-failed', saved: false, error: mcpDiagnostic(cause, { code: cause?.code ?? 'validation-failed' }) }) }
486
535
  }
487
536
  }
488
537
 
@@ -498,7 +547,15 @@ export function createSkillSelectionPlugin(dependencies = {}) {
498
547
  receipt: { payload: 'none', run: receiptVerb },
499
548
  list: { payload: 'none', run: listVerb },
500
549
  apply: { payload: 'required', usage: 'Usage: /capabilities apply <json selection>', run: applyVerb },
501
- 'mcp-add': { payload: 'required', usage: 'Usage: /capabilities mcp-add <json {identity,label,command,args?,env?,serverName?}>', run: mcpAddVerb },
550
+ 'mcp-add': { payload: 'required', usage: 'Usage: /capabilities mcp-add <json {identity,label,command,args?,env?,serverName?}>', run: mcpVerb('mcp-add') },
551
+ 'mcp-preview': { payload: 'required', usage: 'Usage: /capabilities mcp-preview <json server|{mcpServers:{...}}>', run: mcpVerb('mcp-preview') },
552
+ 'mcp-batch': { payload: 'required', usage: 'Usage: /capabilities mcp-batch <json {mcpServers:{...},mode?:"add"|"edit"}>', run: mcpVerb('mcp-batch') },
553
+ 'mcp-edit': { payload: 'required', usage: 'Usage: /capabilities mcp-edit <json {identity,expectedRevision,...config}>', run: mcpVerb('mcp-edit') },
554
+ 'mcp-remove': { payload: 'required', usage: 'Usage: /capabilities mcp-remove <json {identity,expectedRevision}>', run: mcpVerb('mcp-remove') },
555
+ 'mcp-restart': { payload: 'required', usage: 'Usage: /capabilities mcp-restart <json {identity,expectedRevision}>', run: mcpVerb('mcp-restart') },
556
+ 'mcp-test': { payload: 'required', usage: 'Usage: /capabilities mcp-test <json config>', run: mcpVerb('mcp-test') },
557
+ 'mcp-default': { payload: 'required', usage: 'Usage: /capabilities mcp-default <json {identity,enabled,expectedRevision?}>', run: mcpVerb('mcp-default') },
558
+ 'mcp-recover': { payload: 'optional', usage: 'Usage: /capabilities mcp-recover [json {identity?}]', run: mcpVerb('mcp-recover') },
502
559
  presets: { payload: 'none', run: presetVerbs.presets },
503
560
  'preset-save': { payload: 'required', usage: 'Usage: /capabilities preset-save <json>', run: presetVerbs['preset-save'] },
504
561
  'preset-rename': { payload: 'required', usage: 'Usage: /capabilities preset-rename <json>', run: presetVerbs['preset-rename'] },
@@ -515,7 +572,7 @@ export function createSkillSelectionPlugin(dependencies = {}) {
515
572
 
516
573
  ctx.effect(() => commands.register({
517
574
  name: 'capabilities',
518
- description: 'Session capability surface for the Weir Badge and manager (receipt | list | conditions | presets | preset-save | preset-rename | preset-resave | preset-load | preset-delete | preset-export | preset-import | default-get | default-save | default-clear).',
575
+ description: 'Session capability surface (receipt | list | apply | mcp-preview | mcp-add | mcp-batch | mcp-edit | mcp-remove | mcp-restart | mcp-test | mcp-default | mcp-recover | presets and workspace defaults).',
519
576
  handler: async (invocation) => {
520
577
  const agent = invocation?.agent
521
578
  if (!agent) return { kind: 'error', text: 'capabilities: requires an owning agent session' }
@@ -528,8 +585,11 @@ export function createSkillSelectionPlugin(dependencies = {}) {
528
585
  const head = verb.split(' ', 1)[0]
529
586
  const entry = verbTable[head]
530
587
  const hasArgs = verb.length > head.length
531
- if (!entry || (entry.payload === 'none' && hasArgs) || (entry.payload === 'required' && !hasArgs)) {
532
- return { kind: 'error', text: 'Usage: /capabilities receipt|list|conditions|presets|preset-save|preset-load|preset-delete|preset-export|preset-import|default-get|default-save|default-clear' }
588
+ if (!entry || (entry.payload === 'none' && hasArgs)) {
589
+ return { kind: 'error', text: 'Usage: /capabilities receipt|list|apply|mcp-preview|mcp-add|mcp-batch|mcp-edit|mcp-remove|mcp-restart|mcp-test|mcp-default|mcp-recover|presets|default-get|default-save|default-clear' }
590
+ }
591
+ if (entry.payload === 'required' && !hasArgs) {
592
+ return { kind: 'error', text: entry.usage ?? 'Usage: /capabilities <verb> <json>' }
533
593
  }
534
594
  let spec
535
595
  if (entry.payload !== 'none' && hasArgs) {
@@ -572,6 +632,14 @@ export function createSkillSelectionPlugin(dependencies = {}) {
572
632
  clearFailure: options => provider?.clearFailure(options),
573
633
  /** Shared Apply entry (group 12 manager UI commits here). */
574
634
  applySelection: (session, request, options) => engineFor().apply(session, request, options),
635
+ mcpOperations: (session, options = {}) => createMcpOperations({
636
+ registry: createMcpRegistry({ store: storeFor() }),
637
+ manager: ctx.get?.('weirMcpManager'),
638
+ credentials: ctx.get?.('credentials'),
639
+ audit,
640
+ session,
641
+ assertOwner: options.assertOwner,
642
+ }),
575
643
  /** 12.4 diagnostics: whether a removal notice is queued for a session. */
576
644
  hasPendingNotification: sessionId => notifier.has(sessionId),
577
645
  }
@@ -587,11 +655,48 @@ export function createSkillSelectionPlugin(dependencies = {}) {
587
655
  inventory: (options, previous) => inventory(options, previous),
588
656
  lifecycle,
589
657
  mcpManager: () => ctx.get?.('weirMcpManager'),
658
+ mcpOperations: face.mcpOperations,
590
659
  profileContext: () => ctx.get?.('profileContext'),
591
660
  }), 'weir-capability-read-bridge')
592
661
  } catch (cause) {
593
662
  ctx.logger?.warn?.(`capability read bridge feed failed: ${cause instanceof Error ? cause.message : String(cause)}`)
594
663
  }
664
+ // Primary-Orchestrator AI installation rides the same selection row. All
665
+ // Host services are resolved lazily; missing confirmation/ownership support
666
+ // disables install without affecting inert preview or manual management.
667
+ try {
668
+ const tools = ctx.get?.('tools')
669
+ if (tools?.register) {
670
+ const sessionQuery = () => ctx.get?.('sessionQuery')
671
+ // Two retained revisions of this preset may feed the bridge at once;
672
+ // the owner is resolved per session through the agent's own revision.
673
+ const runtime = { agents: () => ctx.get?.('agents'), agentPresets: () => ctx.get?.('agentPresets') }
674
+ const mutations = createCapabilityMutationService({ sessionQuery, ...runtime })
675
+ const captureOwnership = async sessionId => {
676
+ const lease = await sessionQuery()?.observeSession?.(sessionId)
677
+ if (!lease || typeof lease[Symbol.dispose] !== 'function') throw Object.assign(new Error('MCP install session observation is unavailable'), { code: 'owner-unavailable' })
678
+ try {
679
+ const owner = capabilityOwnership(lease, sessionId)
680
+ const feed = capabilityReadFeed(owner.presetId, sessionId, runtime)
681
+ const faces = feed.faces ?? feed
682
+ return { owner, feed, profile: faces.profileContext?.() }
683
+ } finally { lease[Symbol.dispose]() }
684
+ }
685
+ const tool = createMcpInstallTool({
686
+ operationsFor: session => face.mcpOperations(session),
687
+ mutate: (sessionId, request) => mutations.mutate(sessionId, request),
688
+ captureOwnership,
689
+ sameOwnership: sameMutationOwner,
690
+ describeExecution: entry => ctx.get?.('weirMcpManager')?.describeExecution?.(entry),
691
+ userQuestions: () => ctx.get?.('userQuestions'),
692
+ isLiveRoot: agent => Boolean(agent && ctx.get?.('agents')?.get?.(agent.id) === agent),
693
+ })
694
+ if (typeof ctx.effect === 'function') ctx.effect(() => tools.register(tool), 'weir-mcp-install-tool')
695
+ else tools.register(tool)
696
+ }
697
+ } catch (cause) {
698
+ ctx.logger?.warn?.(`MCP install tool registration failed: ${cause instanceof Error ? cause.message : String(cause)}`)
699
+ }
595
700
  }
596
701
  }
597
702