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
@@ -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
  }
@@ -1,11 +1,19 @@
1
- // Task 6.1 preload (design D6): at plugin apply, load the accepted
2
- // selections of KNOWN sessions into the lifecycle memory snapshot, so the
3
- // agent/created listener's synchronous read hits memory instead of disk.
4
- // This module is the async boundary — it runs at mount time, NEVER inside
5
- // the agent/created listener. A slow or failed preload costs one blocking
6
- // synchronous disk read per agent creation (the listener's miss path),
7
- // never correctness. Records that do not decode are pre-marked BLOCKED, so
8
- // the fail-closed state is ready before the first agent is created.
1
+ // Task 6.1 preload (design D6): at plugin apply, load the EFFECTIVE selections
2
+ // of KNOWN sessions into the lifecycle memory snapshot, so the agent/created
3
+ // listener's synchronous read hits memory instead of disk. This module is the
4
+ // async boundary — it runs at mount time, NEVER inside the agent/created
5
+ // listener. A slow or failed preload costs one blocking synchronous disk read
6
+ // per agent creation (the listener's miss path), never correctness. Records
7
+ // that do not decode are pre-marked BLOCKED, so the fail-closed state is ready
8
+ // before the first agent is created.
9
+ //
10
+ // "Effective" is the same priority the synchronous read path applies: the
11
+ // accepted selection when one exists, otherwise the durable inherited snapshot
12
+ // (a fork of a fork, or a fork whose source only resolved an initial set). Both
13
+ // are loaded here, because a cold session read (Badge/manager receipt, MCP
14
+ // gate, Skill catalog) must report the inherited set rather than an empty one
15
+ // until the fork's agent is recreated. A corrupt record of EITHER unit blocks
16
+ // the session — never a fall-through to the other one.
9
17
  import { readdir } from 'node:fs/promises'
10
18
  import { join } from 'node:path'
11
19
  import { isSegment } from './store/paths.js'
@@ -14,10 +22,17 @@ import { openCapabilityStore } from './store/store.js'
14
22
  /** @param {unknown} error */
15
23
  const message = error => (error instanceof Error ? error.message : String(error))
16
24
 
25
+ /** @param {unknown} payload @returns {number} capturedAt used by the inherited restore */
26
+ const capturedAtOf = payload => {
27
+ const value = /** @type {Record<string, unknown>} */ (payload ?? {}).capturedAt
28
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
29
+ }
30
+
17
31
  /**
18
32
  * @param {{
19
33
  * lifecycle: {
20
- * publish(sessionId: string, next: { revision: number, skills?: unknown[], mcpServers?: string[] }): unknown,
34
+ * publish(sessionId: string, next: { revision: number, skills?: unknown[], mcpServers?: string[],
35
+ * captured?: { parentSessionId: string, parentRevision: number | null, capturedAt: number } | null }): unknown,
21
36
  * markBlocked(sessionId: string, reason: 'corrupt'|'unknown-schema'|'torn'|'unreadable'): unknown,
22
37
  * peek(sessionId: string): unknown,
23
38
  * },
@@ -45,20 +60,63 @@ export async function preloadLifecycleSnapshots({ lifecycle, profileContext, pla
45
60
  if (!isSegment(name)) continue
46
61
  // A fresher in-memory publication (e.g. an Apply accepted while this
47
62
  // preload was in flight) always wins over the disk record it came from.
48
- if (lifecycle.peek(name)) continue
63
+ // Capture the exact observed object so EVERY post-await publication or
64
+ // blocked marker can be gated on the same freshness proof.
65
+ const observed = lifecycle.peek(name) ?? null
66
+ if (observed) continue
49
67
  try {
50
68
  const record = await active.read({ kind: 'selection', sessionId: name })
69
+ // The first await may have allowed an accepted Apply (or another
70
+ // publisher) to replace the memory snapshot. Never publish/mark stale.
71
+ if (lifecycle.peek(name) !== observed) continue
51
72
  if (record.kind === 'ok') {
52
73
  const payload = record.payload ?? {}
53
74
  lifecycle.publish(name, {
54
75
  revision: record.revision,
55
76
  skills: Array.isArray(payload.skills) ? payload.skills : [],
56
77
  mcpServers: Array.isArray(payload.mcpServers) ? payload.mcpServers : [],
78
+ mcpIncarnations: payload.mcpIncarnations,
57
79
  })
58
80
  loaded++
59
- } else if (record.kind === 'corrupt' || record.kind === 'unknown-schema' || record.kind === 'torn') {
81
+ continue
82
+ }
83
+ if (record.kind !== 'absent') {
60
84
  lifecycle.markBlocked(name, record.kind)
61
85
  blocked++
86
+ continue
87
+ }
88
+ // No accepted record: the effective set may be the durable inherited
89
+ // snapshot. Same fail-closed treatment for a record that exists but does
90
+ // not decode — the session must not look uninitialized (and be handed a
91
+ // fresh initial baseline) merely because its inheritance is damaged.
92
+ const inherited = await active.read({ kind: 'inherited', sessionId: name })
93
+ // A second independent read creates another interleaving window: an
94
+ // accepted Apply here MUST outrank the older inherited unit.
95
+ if (lifecycle.peek(name) !== observed) continue
96
+ if (inherited.kind === 'ok') {
97
+ const payload = /** @type {Record<string, unknown>} */ (inherited.payload ?? {})
98
+ const parent = payload.parent && typeof payload.parent === 'object'
99
+ ? /** @type {Record<string, unknown>} */ (payload.parent)
100
+ : null
101
+ lifecycle.publish(name, {
102
+ revision: inherited.revision,
103
+ skills: Array.isArray(payload.skills) ? payload.skills : [],
104
+ mcpServers: Array.isArray(payload.mcpServers) ? payload.mcpServers : [],
105
+ mcpIncarnations: payload.mcpIncarnations,
106
+ captured: parent && typeof parent.sessionId === 'string' && parent.sessionId.length > 0
107
+ ? {
108
+ parentSessionId: parent.sessionId,
109
+ parentRevision: Number.isSafeInteger(parent.revision) ? /** @type {number} */ (parent.revision) : null,
110
+ capturedAt: capturedAtOf(payload),
111
+ }
112
+ : null,
113
+ })
114
+ loaded++
115
+ continue
116
+ }
117
+ if (inherited.kind !== 'absent') {
118
+ lifecycle.markBlocked(name, inherited.kind)
119
+ blocked++
62
120
  }
63
121
  } catch (error) {
64
122
  warn(`lifecycle snapshot preload skipped session "${name}": ${message(error)}`)