weir-harness 1.2.3 → 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 (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. package/src/worktree/authority.js +0 -56
@@ -1,28 +1,22 @@
1
- // Task 8.2/8.4/8.5/8.8/8.9 of the session-capability-manager change: the MCP
2
- // manager row. It owns the registry-backed runtime mounts, the shared
3
- // (agent, server) drain, the realm-visible gate the facades consume, the
4
- // creation-time schema hiding, the managed/unmanaged listing and the
5
- // explicit adopt flow. Host-configured, ACP-mounted and other presets'
6
- // servers are UNMANAGED: listed, never given a close switch, never counted
7
- // as closed (8.8).
1
+ // Registry-backed managed MCP runtime. Startup and targeted add/edit/restart/
2
+ // remove/probe share reconcileOne(); siblings are never used as replacement
3
+ // collateral. Status joins only prove registry/runtime/mount/readback facts.
8
4
  import { openCapabilityStore } from './store/store.js'
9
5
  import { createMcpRegistry } from './mcp-registry.js'
10
6
  import { createMcpGate } from './mcp-gate.js'
11
7
  import { createMcpDrain } from './mcp-drain.js'
12
- import { createMcpMount, mcpGroupOptions, isMcpResidueId, detectMcpNameAmbiguity, MCP_GROUP_PREFIX } from './mcp-mount.js'
8
+ import { createMcpMount, mcpGroupOptions, isMcpResidueId, detectMcpNameAmbiguity, MCP_GROUP_PREFIX, resolveEphemeralMcpTree } from './mcp-mount.js'
13
9
  import { registerMcpFacadeBridge } from './mcp-facade-plugin.js'
14
10
  import { skillSelectionFor } from './skill-selection-plugin.js'
11
+ import { createMcpRuntime, createMcpIdentityLock } from './mcp-runtime.js'
12
+ import { materializeMcpClientConfig } from './mcp-credentials.js'
13
+ import { hasMcpSecrets, redactMcpEntry, fingerprintMcpEntry } from './mcp-config.js'
14
+ import { createMcpProcessAdapter } from './mcp-process.js'
15
+ import { mcpDiagnostic, sanitizeMcpText } from './mcp-diagnostics.js'
15
16
 
16
17
  const message = error => (error instanceof Error ? error.message : String(error))
18
+ const now = () => Date.now()
17
19
 
18
- /**
19
- * Managed vs unmanaged rows in a flat entry listing (8.8). A row is managed
20
- * when its group id carries our prefix; stock dsh-mcp-client rows anywhere
21
- * else are unmanaged.
22
- * @param {Array<{ id?: string, name?: string, config?: { serverName?: string } }>} rows
23
- * @param {Map<string, { groupId: string, generation: number }>} mounted
24
- * @param {Record<string, { label?: string, generation: number }>} registryServers
25
- */
26
20
  export function partitionMcpRows(rows, mounted, registryServers) {
27
21
  const managed = []
28
22
  const unmanaged = []
@@ -32,21 +26,12 @@ export function partitionMcpRows(rows, mounted, registryServers) {
32
26
  const registry = registryServers[identity]
33
27
  managed.push({ identity, groupId: row.id, label: registry?.label ?? identity, generation: mounted.get(identity)?.generation ?? registry?.generation ?? null, state: mounted.has(identity) ? 'mounted' : 'registered' })
34
28
  } else if (row?.name === '@deepseek-ai/dsh-mcp-client' && !(typeof row.id === 'string' && row.id.startsWith(MCP_GROUP_PREFIX))) {
35
- // Stock client rows OUTSIDE our groups: host-configured, ACP-mounted
36
- // or other presets' servers — listed, never closable (8.8).
37
- unmanaged.push({ serverName: row.config?.serverName ?? row.id ?? 'unknown', state: 'unmanaged' })
29
+ unmanaged.push({ serverName: row.config?.serverName ?? row.id ?? 'unknown', state: 'unmanaged', readOnly: true })
38
30
  }
39
31
  }
40
32
  return { managed, unmanaged }
41
33
  }
42
34
 
43
- /**
44
- * The adopt-conflict decision (8.9): a same-named host row holds the
45
- * first-come serverName reservation, so mounting is ineffective until the
46
- * user disables it (1.14 S5a/S5b) — surface a conflict, never a success.
47
- * @param {Array<{ id?: string, name?: string, config?: { serverName?: string } }>} rows
48
- * @param {string} serverName
49
- */
50
35
  export function hostRowHoldingReservation(rows, serverName) {
51
36
  return rows.find(row => row?.name === '@deepseek-ai/dsh-mcp-client' && row.config?.serverName === serverName
52
37
  && !(typeof row.id === 'string' && row.id.startsWith(MCP_GROUP_PREFIX))) ?? null
@@ -57,103 +42,236 @@ export function createMcpManager(dependencies = {}) {
57
42
  const profileContext = ctx.get?.('profileContext')
58
43
  const store = dependencies.store ?? openCapabilityStore({ profileContext })
59
44
  const registry = dependencies.registry ?? createMcpRegistry({ store })
60
- // The selection face is resolved LAZILY on every gate verdict: mount
61
- // order inside one apply batch is not contractual (sibling rows import
62
- // concurrently), so a face captured here can be a permanently-null
63
- // fallback that refuses every managed call. Late arrival must converge
64
- // exactly like the intent-gate route override (rc.2 lesson).
65
45
  const lifecycle = dependencies.lifecycle ?? {
66
46
  snapshotFor: sessionId => skillSelectionFor(ctx)?.lifecycle?.snapshotFor(sessionId) ?? null,
67
47
  }
68
- const gate = dependencies.gate ?? createMcpGate({ registry, lifecycle })
48
+ const runtime = dependencies.runtime ?? createMcpRuntime()
49
+ // Every runtime.begin path runs inside this per-identity queue (design D6).
50
+ const serialize = createMcpIdentityLock()
69
51
  const drain = dependencies.drain ?? createMcpDrain({ timeoutMs: config.drainTimeoutMs ?? 30_000 })
70
- // The loader store is the flat id → entry map of every activated row
71
- // (CITED 1.14 manager usage); options carry each row's name/config.
72
- // Loader extends EntryTree (CITED): entries() walks static rows AND
73
- // nested subtrees; the store holds runtime-created entries only.
74
- const defaultListRows = () => [...(loader?.entries?.() ?? [])].map(entry => ({
75
- id: entry?.options?.id ?? null,
76
- name: entry?.options?.name ?? null,
77
- config: entry?.options?.config ?? {},
78
- }))
52
+ const gate = dependencies.gate ?? createMcpGate({ registry, lifecycle, runtime, registrationFor: runtime.registrationFor })
53
+ const loader = dependencies.loader ?? ctx.get?.('loader')
54
+ const defaultListRows = () => [...(loader?.entries?.() ?? [])].map(entry => ({ id: entry?.options?.id ?? entry?.id ?? null, name: entry?.options?.name ?? null, config: entry?.options?.config ?? {} }))
79
55
  const listEntryIds = dependencies.listEntryIds ?? (() => defaultListRows().map(row => row.id).filter(Boolean))
80
56
  const listRows = dependencies.listRows ?? defaultListRows
81
- const onError = (reason, cause) => ctx.logger?.warn?.(`${reason}${cause ? `: ${message(cause)}` : ''}`)
82
-
83
- const loader = dependencies.loader ?? ctx.get?.('loader')
84
- // Readiness by the child client's fiber after loader.await() (1.14
85
- // must-fix): a group child resolves as '<group>-client' in the tree.
57
+ const startErrors = []
58
+ /** A strict mount of this identity supersedes its earlier start/mount failures. */
59
+ const clearStartErrors = identity => {
60
+ for (let index = startErrors.length - 1; index >= 0; index -= 1) if (startErrors[index].identity === identity) startErrors.splice(index, 1)
61
+ }
62
+ const onError = (reason, cause) => ctx.logger?.warn?.(sanitizeMcpText(`${reason}${cause ? `: ${message(cause)}` : ''}`))
86
63
  const defaultFiberOf = async groupId => {
87
- // Readiness the 1.14 way: resolve the client entry, then AWAIT its own
88
- // fiber lifecycle — a group child whose connection never comes up or
89
- // whose activation errored surfaces HERE, not as a blind success.
90
64
  for (const candidate of [`${groupId}:${groupId}-client`, `${groupId}-client`]) {
91
65
  let entry = null
92
- try {
93
- entry = loader?.resolve?.(candidate)
94
- } catch { /* try the next shape */ }
66
+ try { entry = loader?.resolve?.(candidate) } catch { /* next shape */ }
95
67
  if (entry?.fiber) {
96
- try {
97
- if (typeof entry.fiber.await === 'function') await entry.fiber.await()
98
- return { state: 'running' }
99
- } catch (cause) {
100
- startErrors.push({ identity: null, reason: `fiber-await-failed:${groupId}:${message(cause)}` })
101
- return null
102
- }
68
+ await entry.fiber.await()
69
+ return { state: 'running' }
103
70
  }
104
71
  }
105
72
  return null
106
73
  }
107
- const mount = dependencies.mount ?? createMcpMount({ loader, onError })
74
+ const mount = dependencies.mount ?? createMcpMount({ loader, onError, startupTimeoutMs: config.startupTimeoutMs ?? 15_000, cleanupTimeoutMs: config.cleanupTimeoutMs ?? 15_000 })
108
75
  const fiberOf = dependencies.fiberOf ?? defaultFiberOf
109
- /** publicName → identity, fed by facade registrations (creation-time schema hiding + managed listing). */
110
- const publicNames = new Map()
111
-
76
+ const processAdapter = dependencies.processAdapter ?? createMcpProcessAdapter({ subprocess: dependencies.subprocess ?? ctx.get?.('subprocess') })
77
+ const credentialsFor = () => dependencies.credentials ?? ctx.get?.('credentials')
78
+ const support = () => {
79
+ const credentials = credentialsFor()
80
+ return { credentials: typeof credentials?.set === 'function' && typeof credentials?.resolve === 'function', ephemeral: resolveEphemeralMcpTree(loader).kind === 'ok' }
81
+ }
82
+ const publicNames = new Map() // publicName → { identity, generation }
83
+ const bridges = new Map() // groupId → { identity, generation, release }
84
+ const receipts = { mount: new Map(), test: new Map() }
112
85
  const host = {
113
86
  tools: dependencies.hostTools ?? ctx.get?.('tools'),
114
87
  systemPrompt: dependencies.hostSystemPrompt ?? ctx.get?.('systemPrompt'),
115
88
  mcpResources: dependencies.hostMcpResources ?? ctx.get?.('mcpResources'),
116
89
  }
117
90
 
118
- /** The realm-visible gate face (8.2): facades consume it from inside their groups. */
119
- const gateFace = {
120
- admit: (agent, identity, generation) => gate.admit(agent, identity, generation),
121
- enabledFor: (agent, identity) => gate.enabledFor(agent, identity),
122
- drain,
91
+ function releaseBridge(groupId) {
92
+ const bridge = bridges.get(groupId)
93
+ if (!bridge) return
94
+ bridges.delete(groupId)
95
+ for (const [name, owner] of publicNames) {
96
+ if (owner.identity === bridge.identity && owner.generation === bridge.generation) publicNames.delete(name)
97
+ }
98
+ bridge.release()
123
99
  }
124
- try { ctx.reflect?.provide?.('weirMcpGate', gateFace) } catch { /* an older row already provides it */ }
125
-
126
- async function mountOne(identity, entry) {
127
- const generation = entry.generation
128
- const groupId = `${MCP_GROUP_PREFIX}${identity}`
100
+ function registerBridge(groupId, identity, generation) {
129
101
  const release = registerMcpFacadeBridge(groupId, {
130
- host,
131
- identity,
132
- generation,
133
- admit: agent => gate.admit(agent, identity, generation),
102
+ host, identity, generation,
103
+ admit: agent => gate.permit(agent, identity, generation),
104
+ assertPermit: gate.assertPermit,
134
105
  isEnabledFor: agent => gate.enabledFor(agent, identity),
135
106
  drain,
136
- onRegister: name => publicNames.set(name, identity),
107
+ onRegister: name => publicNames.set(name, { identity, generation }),
137
108
  onError,
138
109
  })
110
+ bridges.set(groupId, { identity, generation, release })
111
+ }
112
+ function discoveryFor(identity, generation) {
113
+ const names = [...publicNames.entries()].filter(([, owner]) => owner.identity === identity && owner.generation === generation).map(([name]) => name).sort()
114
+ return { toolCount: names.length, toolNamesPreview: names.slice(0, 20), truncated: names.length > 20 }
115
+ }
116
+
117
+ function beginOperation(identity, phase = 'replacing', details = {}) {
118
+ const token = runtime.begin(identity, { phase, ...details })
119
+ drain.closeAll(identity)
120
+ return token
121
+ }
122
+ function abortOperation(token) {
123
+ const previousPhase = token.previous?.phase
124
+ const restored = runtime.restore(token)
125
+ if (restored && previousPhase === 'mounted') drain.reopenAll(token.identity)
126
+ return restored
127
+ }
128
+ async function waitForDrain(identity) {
129
+ const result = await drain.drainAll(identity)
130
+ if (!result.drained) {
131
+ const error = new Error(`MCP server "${identity}" still has ${result.inFlight} admitted call(s); replacement cannot prove quiescence`)
132
+ error.code = 'cleanup-unknown'
133
+ throw error
134
+ }
135
+ }
136
+
137
+ async function prepare(entry) {
138
+ const credentials = credentialsFor()
139
+ const secretBearing = hasMcpSecrets(entry)
140
+ if (Object.keys(entry?.secretRefs ?? {}).length > 0 && typeof credentials?.resolve !== 'function') {
141
+ const error = new Error('secret-bearing MCP materialization requires DSH credentials')
142
+ error.code = 'unsupported'
143
+ throw error
144
+ }
145
+ const materialized = await materializeMcpClientConfig(entry, credentials)
146
+ const prepared = await processAdapter.prepare(materialized.client)
147
+ return { ...prepared, secrets: materialized.resolvedSecrets, secretBearing }
148
+ }
149
+
150
+ /** Resolve only non-secret execution metadata for an exact confirmation card. */
151
+ async function describeExecution(entry) {
152
+ if (entry?.client?.transport !== 'stdio') return null
153
+ const secretArgs = new Set(Object.keys(entry?.secretRefs?.args ?? {}).map(Number))
154
+ const client = {
155
+ ...entry.client,
156
+ args: (entry.client.args ?? []).map((value, index) => secretArgs.has(index) ? '' : value),
157
+ env: {},
158
+ }
159
+ return (await processAdapter.prepare(client)).execution
160
+ }
161
+
162
+ async function reconcileOne(identity, entry, suppliedToken = null) {
163
+ const token = suppliedToken ?? beginOperation(identity)
139
164
  try {
140
- await mount.mount({ identity, generation, client: entry.client ?? entry.transport, fiberOf })
141
- // A fiber is not health: the stock client may carry an activation
142
- // error (bad import, failed connect). Surface it like the 1.14
143
- // entryState check instead of reporting a blind success.
144
- const clientEntry = loader?.store?.[`${groupId}-client`]
145
- if (clientEntry?.error) {
146
- throw new Error(`mcp client entry error for "${identity}": ${message(clientEntry.error)}`)
165
+ const current = await registry.read()
166
+ const registered = current.kind === 'ok' ? current.servers[identity] : null
167
+ if (!registered || registered.generation !== entry.generation || (registered.incarnation ?? null) !== (entry.incarnation ?? null)) {
168
+ const error = new Error(`MCP registry/mount readback rejected ${identity} generation ${entry.generation}`)
169
+ error.code = 'generation-readback-failed'
170
+ throw error
171
+ }
172
+ await waitForDrain(identity)
173
+ if (mount.mounted.has(identity)) {
174
+ const removed = await mount.unmount(identity)
175
+ if (removed.status !== 'cleaned') {
176
+ runtime.finish(token, removed.status, { generation: entry.generation, incarnation: entry.incarnation ?? null })
177
+ return { status: removed.status, identity, cleanup: removed, saved: true }
178
+ }
179
+ releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
147
180
  }
181
+ const prepared = await prepare(entry)
182
+ const groupId = `${MCP_GROUP_PREFIX}${identity}`
183
+ const result = await mount.mount({
184
+ identity,
185
+ generation: entry.generation,
186
+ client: prepared.client,
187
+ secrets: prepared.secrets,
188
+ secretBearing: prepared.secretBearing,
189
+ execution: prepared.execution,
190
+ fiberOf,
191
+ beforeCreate: () => registerBridge(groupId, identity, entry.generation),
192
+ afterCleanup: () => releaseBridge(groupId),
193
+ discovery: () => discoveryFor(identity, entry.generation),
194
+ readback: async () => {
195
+ const readback = await registry.read()
196
+ const actual = readback.kind === 'ok' ? readback.servers[identity] : null
197
+ if (!actual || actual.generation !== entry.generation || (actual.incarnation ?? null) !== (entry.incarnation ?? null)) throw new Error('registry generation/incarnation changed during mount')
198
+ },
199
+ })
200
+ const observed = mount.mounted.get(identity)
201
+ if (!observed || observed.generation !== entry.generation) throw new Error('mounted generation readback failed')
202
+ // Only the fence owner may report mounted and reopen admission: a
203
+ // superseded token means another operation now owns this identity.
204
+ if (!runtime.finish(token, 'mounted', { generation: entry.generation, incarnation: entry.incarnation ?? null })) {
205
+ const diagnostic = mcpDiagnostic(new Error(`MCP operation on ${identity} was superseded before it could report mounted`), { code: 'operation-superseded', action: 'reread-registry' })
206
+ receipts.mount.set(identity, { time: now(), status: 'indeterminate', diagnostic })
207
+ return { status: 'indeterminate', identity, generation: entry.generation, incarnation: entry.incarnation ?? null, saved: true, error: diagnostic, mount: { state: 'superseded', observedGeneration: observed.generation }, next: { action: 'reread-registry' } }
208
+ }
209
+ drain.reopenAll(identity)
210
+ clearStartErrors(identity)
211
+ const receipt = { time: now(), status: 'mounted', generation: entry.generation, discovery: result.discovery }
212
+ receipts.mount.set(identity, receipt)
213
+ return { status: 'mounted', identity, generation: entry.generation, incarnation: entry.incarnation ?? null, mount: { state: 'mounted', observedGeneration: observed.generation }, discovery: result.discovery, execution: result.execution ?? null }
214
+ } catch (cause) {
215
+ const cleanupStatus = ['cleanup-failed', 'cleanup-unknown'].includes(cause?.status) ? cause.status : null
216
+ const status = cleanupStatus ?? (cause?.code === 'unsupported' ? 'unsupported' : 'saved-degraded')
217
+ runtime.finish(token, cleanupStatus ?? 'saved-degraded', { generation: entry.generation, incarnation: entry.incarnation ?? null })
218
+ const diagnostic = mcpDiagnostic(cause, { code: cause?.code, action: cleanupStatus ? 'recover-residue' : 'retry' })
219
+ receipts.mount.set(identity, { time: now(), status, diagnostic })
220
+ startErrors.push({ identity, reason: diagnostic.message })
221
+ onError(`mcp-mount-${status}:${identity}`, cause)
222
+ return { status, identity, generation: entry.generation, incarnation: entry.incarnation ?? null, saved: true, error: diagnostic, mount: { state: cleanupStatus ?? 'degraded' }, next: { action: cleanupStatus ? 'recover-residue' : 'retry-or-edit' } }
223
+ }
224
+ }
225
+
226
+ async function removeRuntime(identity, suppliedToken = null) {
227
+ const token = suppliedToken ?? beginOperation(identity, 'removing')
228
+ try {
229
+ await waitForDrain(identity)
230
+ const cleanup = await mount.unmount(identity)
231
+ if (cleanup.status !== 'cleaned') {
232
+ runtime.finish(token, cleanup.status)
233
+ return { status: cleanup.status, identity, cleanup, error: cleanup.diagnostic, next: { action: 'recover-residue' } }
234
+ }
235
+ releaseBridge(`${MCP_GROUP_PREFIX}${identity}`)
236
+ runtime.finish(token, 'unknown', { generation: null, incarnation: null })
237
+ return { status: 'removed', identity, removed: true }
238
+ } catch (cause) {
239
+ const status = cause?.code === 'cleanup-timeout' ? 'cleanup-unknown' : 'cleanup-failed'
240
+ runtime.finish(token, status)
241
+ return { status, identity, error: mcpDiagnostic(cause, { code: status, action: 'recover-residue' }), next: { action: 'recover-residue' } }
242
+ }
243
+ }
244
+
245
+ async function probe(entry, { targetIdentity = entry.identity } = {}) {
246
+ try {
247
+ const prepared = await prepare(entry)
248
+ const result = await mount.probe({
249
+ identity: undefined,
250
+ generation: 0,
251
+ client: prepared.client,
252
+ secretBearing: prepared.secretBearing,
253
+ secrets: prepared.secrets,
254
+ fiberOf,
255
+ beforeCreate: ({ groupId, identity, generation }) => registerBridge(groupId, identity, generation),
256
+ afterCleanup: ({ groupId }) => releaseBridge(groupId),
257
+ discovery: ({ identity, generation }) => discoveryFor(identity, generation),
258
+ })
259
+ const outcome = { status: result.status, identity: targetIdentity, discovery: result.discovery, cleanup: result.cleanup }
260
+ // Receipts share one shape with lastMount: the row reads `diagnostic`.
261
+ receipts.test.set(targetIdentity, { time: now(), status: outcome.status, discovery: outcome.discovery, ...(result.diagnostic ? { diagnostic: result.diagnostic } : {}) })
262
+ return outcome
148
263
  } catch (cause) {
149
- release()
150
- throw cause
264
+ const status = ['cleanup-failed', 'cleanup-unknown'].includes(cause?.status) ? cause.status : 'probe-failed'
265
+ const outcome = { status, identity: targetIdentity, error: mcpDiagnostic(cause, { code: cause?.code ?? status }), cleanup: cause?.cleanup }
266
+ receipts.test.set(targetIdentity, { time: now(), status, diagnostic: outcome.error })
267
+ return outcome
151
268
  }
152
- return { groupId, release }
153
269
  }
154
270
 
155
- /** Startup: sweep residue, then mount every registered server. */
271
+ let started = false
156
272
  async function start() {
273
+ if (started) return { mounted: mount.mounted.size, alreadyStarted: true }
274
+ started = true
157
275
  const swept = await mount.sweepResidue(listEntryIds())
158
276
  const current = await registry.read()
159
277
  if (current.kind !== 'ok') {
@@ -167,83 +285,191 @@ export function createMcpManager(dependencies = {}) {
167
285
  }
168
286
  let mountedCount = 0
169
287
  for (const entry of Object.values(current.servers)) {
170
- try {
171
- await mountOne(entry.identity, entry)
172
- mountedCount += 1
173
- } catch (cause) {
174
- startErrors.push({ identity: entry.identity, reason: message(cause) })
175
- onError(`mcp-mount-failed:${entry.identity}`, cause)
176
- }
288
+ const result = await serialize(entry.identity, () => reconcileOne(entry.identity, entry))
289
+ if (result.status === 'mounted') mountedCount += 1
290
+ else if (result.status !== 'saved-degraded') startErrors.push({ identity: entry.identity, reason: result.status })
177
291
  }
178
292
  return { mounted: mountedCount }
293
+
294
+ }
295
+ const healthy = identity => runtime.snapshot(identity).phase === 'mounted' && mount.mounted.has(identity)
296
+ && (mount.mounted.get(identity)?.state ?? 'mounted') === 'mounted'
297
+
298
+ /** Recover one identity inside its queue slot: sweep untracked residue, then strictly remount. */
299
+ function recoverOne(identity, assertOwner) {
300
+ return serialize(identity, async () => {
301
+ // Re-read inside the slot: an earlier queued operation may have
302
+ // remounted, edited or removed this identity meanwhile.
303
+ const current = await registry.read()
304
+ if (assertOwner) await assertOwner('recover-cleanup')
305
+ if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unavailable', identity, skipped: current.kind }
306
+ const entry = current.kind === 'ok' ? current.servers[identity] ?? null : null
307
+ if (entry && healthy(identity)) return { status: 'mounted', identity, generation: entry.generation, alreadyHealthy: true }
308
+ const groupId = `${MCP_GROUP_PREFIX}${identity}`
309
+ const residuePresent = listEntryIds().includes(groupId)
310
+ if (!entry) {
311
+ // Orphan residue of an identity that is no longer registered.
312
+ const swept = residuePresent ? await mount.sweepResidue([groupId]) : []
313
+ const cleaned = !residuePresent || swept.includes(groupId)
314
+ if (cleaned) releaseBridge(groupId)
315
+ return cleaned
316
+ ? { status: 'removed', identity, swept: residuePresent }
317
+ : { status: 'cleanup-failed', identity, error: mcpDiagnostic(new Error(`residue Loader row ${groupId} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' }), next: { action: 'recover-residue' } }
318
+ }
319
+ const token = beginOperation(identity)
320
+ // A tracked failed mount record is retried by reconcileOne's unmount; a
321
+ // Loader row the mount map no longer tracks is swept first, or the
322
+ // same-name replacement could never be created.
323
+ if (!mount.mounted.has(identity) && residuePresent) {
324
+ const swept = await mount.sweepResidue([groupId])
325
+ if (!swept.includes(groupId)) {
326
+ runtime.finish(token, 'cleanup-failed', { generation: entry.generation, incarnation: entry.incarnation ?? null })
327
+ const diagnostic = mcpDiagnostic(new Error(`residue Loader row ${groupId} could not be removed`), { code: 'cleanup-failed', action: 'recover-residue' })
328
+ receipts.mount.set(identity, { time: now(), status: 'cleanup-failed', diagnostic })
329
+ return { status: 'cleanup-failed', identity, generation: entry.generation, saved: true, error: diagnostic, next: { action: 'recover-residue' } }
330
+ }
331
+ releaseBridge(groupId)
332
+ }
333
+ if (assertOwner) {
334
+ try { await assertOwner('mount') } catch (cause) {
335
+ // Cleanup may already have removed the old mount. Keep admission
336
+ // closed without stranding the queue fence in `replacing`.
337
+ runtime.finish(token, 'unknown', { generation: entry.generation, incarnation: entry.incarnation ?? null })
338
+ throw cause
339
+ }
340
+ }
341
+ return reconcileOne(identity, entry, token)
342
+ })
179
343
  }
180
344
 
181
345
  /**
182
- * Explicit adopt flow (8.9): never an automatic takeover. The identity is
183
- * registered first; a same-named host row still holding the reservation
184
- * makes the outcome a visible conflict (the user disables the host row,
185
- * then the managed client mounts), never a claimed success.
346
+ * Residue recovery (user-triggered via the capabilities command verb
347
+ * mcp-recover, the mcp mutation action 'recover' and the panel's Recover
348
+ * row action). Healthy mounted identities are never touched; every other
349
+ * registered identity is strictly remounted and every orphan residue row is
350
+ * removed. `identity` scopes the work to one server. The optional owner
351
+ * callback runs inside every queue slot before cleanup and before remount.
186
352
  */
187
- async function adopt(entry) {
353
+ async function recoverResidue({ identity = null, assertOwner = null } = {}) {
188
354
  const current = await registry.read()
189
- const revision = current.kind === 'ok' ? current.revision : 0
190
- const registered = await registry.register(entry, revision)
191
- if (registered.status !== 'committed') return { status: 'registry-failed', result: registered.status }
192
- const holder = hostRowHoldingReservation(listRows(), entry.transport?.serverName ?? entry.identity)
193
- if (holder) {
194
- return {
195
- status: 'conflict',
196
- reason: `host row "${holder.id ?? entry.identity}" still holds the serverName reservation — disable it in the host configuration; the managed client mounts afterwards. Until then the server stays unmanaged.`,
355
+ if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unavailable', skipped: current.kind, results: [] }
356
+ const servers = current.kind === 'ok' ? current.servers : {}
357
+ const orphans = listEntryIds().filter(isMcpResidueId).map(id => id.slice(MCP_GROUP_PREFIX.length)).filter(id => !servers[id])
358
+ let candidates = [...new Set([...Object.keys(servers).filter(id => !healthy(id)), ...orphans])]
359
+ if (identity !== null) candidates = candidates.filter(id => id === identity)
360
+ const results = []
361
+ for (const id of candidates) {
362
+ try { results.push(await recoverOne(id, assertOwner)) } catch (cause) {
363
+ if (!assertOwner || cause?.code !== 'owner-changed') throw cause
364
+ const recovered = results.filter(result => result.status === 'mounted' || result.status === 'removed').length
365
+ return {
366
+ status: 'owner-changed', recovered, results,
367
+ error: mcpDiagnostic(cause, { code: 'owner-changed', action: 'inspect-actual-state' }),
368
+ next: { action: 'inspect-actual-state' },
369
+ }
197
370
  }
198
371
  }
199
- try {
200
- await mountOne(entry.identity, (await registry.read()).servers[entry.identity])
201
- return { status: 'mounted' }
202
- } catch (cause) {
203
- return { status: 'mount-failed', reason: message(cause) }
204
- }
372
+ const recovered = results.filter(result => result.status === 'mounted' || result.status === 'removed').length
373
+ return { status: recovered === results.length ? 'recovered' : 'recovery-incomplete', recovered, results }
374
+ }
375
+
376
+ async function adopt(entry) {
377
+ const holder = hostRowHoldingReservation(listRows(), entry.client?.serverName ?? entry.transport?.serverName ?? entry.identity)
378
+ if (holder) return { status: 'conflict', reason: `host row "${holder.id ?? entry.identity}" still holds the serverName reservation — disable it in the host configuration; the managed client mounts afterwards. Until then the server stays unmanaged.` }
379
+ return serialize(entry.identity, async () => {
380
+ const current = await registry.read()
381
+ const revision = current.kind === 'ok' ? current.revision : 0
382
+ const token = beginOperation(entry.identity)
383
+ const registered = await registry.register(entry, revision)
384
+ if (registered.status !== 'committed') { abortOperation(token); return { status: 'registry-failed', result: registered.status } }
385
+ const latest = await registry.read()
386
+ return reconcileOne(entry.identity, latest.servers[entry.identity], token)
387
+ })
205
388
  }
206
389
 
207
- /** Creation-time schema hiding (8.4③): per-agent deny of public names whose server is not accepted. */
208
390
  function agentCreated(payload) {
209
391
  const agent = payload?.agent
210
392
  if (publicNames.size === 0) return
211
- const deny = [...publicNames.entries()].filter(([, identity]) => !gate.enabledFor(agent, identity)).map(([name]) => name)
393
+ const deny = [...publicNames.entries()].filter(([, owner]) => !gate.enabledFor(agent, typeof owner === 'string' ? owner : owner.identity)).map(([name]) => name)
212
394
  if (deny.length > 0) {
213
395
  try { agent?.ctx?.tools?.restrict?.({ deny }) } catch (cause) { onError('mcp-schema-restrict-failed', cause) }
214
396
  }
215
397
  }
216
398
  ctx.on?.('agent/created', payload => { agentCreated(payload) })
217
399
 
218
- /** Visible startup/mount errors (manager UI + probes). */
219
- const startErrors = []
220
- const face = {
221
- start,
222
- startErrors,
223
- adopt,
224
- gate: gateFace,
400
+ async function list() {
401
+ const current = await registry.read()
402
+ const rows = listRows()
403
+ const partitioned = partitionMcpRows(rows, mount.mounted, current.kind === 'ok' ? current.servers : {})
404
+ if (current.kind === 'absent') return { managed: [], unmanaged: partitioned.unmanaged, warnings: [], registryRevision: 0, capabilities: support() }
405
+ if (current.kind !== 'ok') return { managed: [], unmanaged: partitioned.unmanaged, unavailable: { status: current.kind }, registryRevision: current.revision ?? 0, capabilities: support() }
406
+ const defaults = new Set(current.enabled)
407
+ const managed = Object.values(current.servers).map(entry => {
408
+ const phase = runtime.snapshot(entry.identity)
409
+ const mountedRecord = mount.mounted.get(entry.identity)
410
+ const mounted = phase.phase === 'mounted' && phase.generation === entry.generation && mountedRecord?.generation === entry.generation
411
+ const holder = hostRowHoldingReservation(rows, entry.client?.serverName ?? entry.identity)
412
+ const state = holder ? 'conflict' : mounted ? 'mounted' : phase.phase === 'saved-degraded' ? 'degraded' : ['cleanup-failed', 'cleanup-unknown'].includes(phase.phase) ? phase.phase : 'registered'
413
+ const discovery = discoveryFor(entry.identity, entry.generation)
414
+ return {
415
+ identity: entry.identity,
416
+ label: entry.label,
417
+ serverName: entry.client?.serverName ?? entry.identity,
418
+ transport: entry.transport?.kind ?? entry.client?.transport ?? 'unknown',
419
+ incarnation: entry.incarnation ?? null,
420
+ generation: entry.generation,
421
+ defaultEnabled: defaults.has(entry.identity),
422
+ state,
423
+ statusSource: mounted ? 'registry-runtime-mount-readback' : holder ? 'serverName-reservation' : 'registry-runtime',
424
+ runtimePhase: phase.phase,
425
+ discovery: mounted ? discovery : { toolCount: null, toolNamesPreview: [], truncated: false },
426
+ lastMount: receipts.mount.get(entry.identity) ?? null,
427
+ lastTest: receipts.test.get(entry.identity) ?? null,
428
+ startErrors: startErrors.filter(error => error.identity === entry.identity).map(error => ({ reason: error.reason })),
429
+ liveReconnect: 'unknown',
430
+ processExit: 'unknown',
431
+ stderr: 'unknown',
432
+ readOnly: false,
433
+ config: redactMcpEntry(entry),
434
+ fingerprint: fingerprintMcpEntry(entry),
435
+ }
436
+ })
437
+ return { managed, unmanaged: partitioned.unmanaged, warnings: current.warnings, registryRevision: current.revision, capabilities: support() }
438
+ }
439
+
440
+ const gateFace = {
441
+ admit: (agent, identity, generation) => gate.admit(agent, identity, generation),
442
+ permit: (agent, identity, generation) => gate.permit(agent, identity, generation),
443
+ assertPermit: gate.assertPermit,
444
+ enabledFor: (agent, identity) => gate.enabledFor(agent, identity),
225
445
  drain,
226
- registry,
227
- list: () => partitionMcpRows(listRows(), mount.mounted, {}),
446
+ }
447
+ try { ctx.reflect?.provide?.('weirMcpGate', gateFace) } catch { /* older row */ }
448
+ const face = {
449
+ start, startErrors, adopt, gate: gateFace, drain, registry, runtime,
450
+ recoverResidue,
451
+ list,
228
452
  publicNames,
229
- remove: identity => mount.unmount(identity, dependencies.unmountGuards),
453
+ mounted: mount.mounted,
454
+ beginOperation, abortOperation, reconcileOne, removeRuntime, probe,
455
+ remove: identity => serialize(identity, () => removeRuntime(identity)),
456
+ /** Run one same-identity operation after every earlier one settled. */
457
+ serialize,
458
+ support,
459
+ serverNameConflict: (serverName, identity) => {
460
+ const holder = hostRowHoldingReservation(listRows(), serverName)
461
+ return holder ? `host row "${holder.id ?? identity}" already reserves serverName "${serverName}"` : null
462
+ },
463
+ prepare, describeExecution,
464
+ mcpGroupOptions,
230
465
  }
231
466
  try { ctx.reflect?.provide?.('weirMcpManager', face) } catch { /* older row */ }
232
-
233
- // Startup mounting is fire-and-forget (like the 6.1 preload): a slow or
234
- // failed start only costs one visible error per server, never a broken
235
- // preset row.
236
- void Promise.resolve()
237
- .then(() => face.start())
238
- .catch(cause => {
239
- startErrors.push({ identity: null, reason: `start-failed:${message(cause)}` })
240
- onError('mcp-manager-start-failed', cause)
241
- })
242
-
243
- // Fail closed on the dispose/reload window (8.5): managed calls are
244
- // refused because the gate has no initialized authority anymore.
467
+ void Promise.resolve().then(() => face.start()).catch(cause => {
468
+ startErrors.push({ identity: null, reason: `start-failed:${message(cause)}` })
469
+ onError('mcp-manager-start-failed', cause)
470
+ })
245
471
  return () => {
246
- try { ctx.reflect?.provide?.('weirMcpGate', { admit: async () => false, enabledFor: () => false, drain }) } catch { /* best effort */ }
472
+ try { ctx.reflect?.provide?.('weirMcpGate', { admit: async () => false, permit: async () => null, assertPermit: () => false, enabledFor: () => false, drain }) } catch { /* best effort */ }
247
473
  }
248
474
  }
249
475
  }
@@ -251,4 +477,4 @@ export function createMcpManager(dependencies = {}) {
251
477
  export const name = 'weir-mcp-manager'
252
478
  export const inject = ['loader', 'tools', 'systemPrompt', 'mcpResources']
253
479
  export const apply = createMcpManager()
254
- export const __message = message // test seam
480
+ export const __message = message