weir-harness 1.2.0 → 1.2.2

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 (99) hide show
  1. package/cordis.patch.yml +0 -2
  2. package/lib/client.blackboard-model.js +172 -56
  3. package/lib/client.capability-manager.js +0 -1
  4. package/lib/client.capability-model.js +378 -31
  5. package/lib/client.chain-model.js +80 -0
  6. package/lib/client.disabled-categories-editor.js +22 -0
  7. package/lib/client.edit-lock-maintenance.js +239 -246
  8. package/lib/client.edit-lock-panel.js +110 -0
  9. package/lib/client.hash-edit-model.js +48 -0
  10. package/lib/client.js +267 -1549
  11. package/lib/client.lsp-model.js +92 -0
  12. package/lib/client.lsp-panel.js +11 -16
  13. package/lib/client.lsp-toggle.js +18 -0
  14. package/lib/client.notify-permissions.js +112 -20
  15. package/lib/client.notify-web.js +14 -11
  16. package/lib/client.robash-model.js +26 -0
  17. package/lib/client.settings-page.js +287 -17
  18. package/lib/client.worktree-model.js +225 -0
  19. package/package.json +5 -2
  20. package/src/blackboard/durable.js +12 -0
  21. package/src/blackboard/index.js +2 -2
  22. package/src/blackboard/kernel.js +23 -6
  23. package/src/blackboard/notify.js +10 -3
  24. package/src/blackboard/remote.js +31 -13
  25. package/src/blackboard/storage.js +13 -5
  26. package/src/capabilities/apply-engine.js +189 -101
  27. package/src/capabilities/defaults-transaction.js +136 -1
  28. package/src/capabilities/lifecycle-snapshot.js +83 -204
  29. package/src/capabilities/mcp-admission.js +1 -23
  30. package/src/capabilities/mcp-drain.js +30 -19
  31. package/src/capabilities/mcp-facade.js +23 -3
  32. package/src/capabilities/preset-library.js +247 -0
  33. package/src/capabilities/preset-package.js +212 -1
  34. package/src/capabilities/same-name-policy.js +3 -41
  35. package/src/capabilities/selection-notify.js +324 -21
  36. package/src/capabilities/skill-office-adapter.js +7 -1
  37. package/src/capabilities/skill-selection-plugin.js +315 -729
  38. package/src/capabilities/store/checked-contract.d.ts +5 -0
  39. package/src/capabilities/store/fs-adapter.js +52 -0
  40. package/src/capabilities/store/lock.js +86 -0
  41. package/src/capabilities/store/paths.js +5 -0
  42. package/src/capabilities/store/store.js +105 -5
  43. package/src/creative-guide/guide.js +1 -1
  44. package/src/delegate/child-composition.js +258 -0
  45. package/src/delegate/group-coordinator.js +25 -27
  46. package/src/delegate/index.js +21 -2
  47. package/src/delegate/rehydrate.js +24 -4
  48. package/src/delegate/robash-guard-core.js +2 -2
  49. package/src/delegate/spawn-adapter.js +214 -100
  50. package/src/delegate/status-tool.js +9 -6
  51. package/src/delegate/supervision-mount.js +35 -6
  52. package/src/delegate/tool.js +29 -4
  53. package/src/{shared → delegate}/whitelist-defaults.js +2 -2
  54. package/src/edit-lock/admin-ledger.js +8 -11
  55. package/src/edit-lock/admin-recovery.js +2 -1
  56. package/src/edit-lock/admission.js +12 -2
  57. package/src/edit-lock/binding-guidance.js +28 -10
  58. package/src/edit-lock/binding-machine.js +312 -0
  59. package/src/edit-lock/derived-facts.js +67 -0
  60. package/src/edit-lock/index.js +44 -244
  61. package/src/edit-lock/inspect.js +113 -1
  62. package/src/edit-lock/lifecycle.js +8 -5
  63. package/src/edit-lock/limits.js +61 -0
  64. package/src/edit-lock/maintenance.js +5 -2
  65. package/src/edit-lock/manager.js +6 -2
  66. package/src/edit-lock/operation-history.js +1 -1
  67. package/src/edit-lock/peer-client.js +9 -2
  68. package/src/edit-lock/peer-transport.js +7 -1
  69. package/src/edit-lock/remote.js +11 -4
  70. package/src/edit-lock/state.js +2 -4
  71. package/src/edit-lock/view.js +10 -11
  72. package/src/lsp/admin.js +2 -24
  73. package/src/lsp/index.js +1 -1
  74. package/src/lsp/manager.js +1 -1
  75. package/src/notify/permissions-admin.js +1 -1
  76. package/src/{lsp/child-process.js → process/index.js} +23 -6
  77. package/src/settings/env-admin.js +1 -1
  78. package/src/settings/index.js +1 -1
  79. package/src/settings/sections.js +0 -54
  80. package/src/shared/audit.js +12 -0
  81. package/src/shared/child-scope.js +3 -1
  82. package/src/shared/http-json.js +28 -0
  83. package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
  84. package/src/worktree/errors.js +3 -0
  85. package/src/worktree/index.js +1 -2
  86. package/src/worktree/lane-core.js +641 -0
  87. package/src/worktree/lane-views.js +147 -0
  88. package/src/worktree/lane-workflows.js +853 -0
  89. package/src/worktree/lanes.js +24 -1513
  90. package/src/worktree/runner.js +5 -4
  91. package/src/capabilities/content-pin.js +0 -88
  92. package/src/capabilities/content-refresh.js +0 -331
  93. package/src/capabilities/distribution-manifest.js +0 -78
  94. package/src/capabilities/isolated-exec.js +0 -106
  95. package/src/capabilities/publisher.js +0 -129
  96. package/src/capabilities/skill-admission.js +0 -181
  97. package/src/capabilities/update-lifecycle.js +0 -106
  98. package/src/edit-lock/remote-service.js +0 -28
  99. package/src/worktree/contract-guidance.test.js +0 -100
@@ -18,13 +18,14 @@
18
18
  // the host storageDomain — a promise interface, unusable here, CITED).
19
19
  //
20
20
  // 6.2 — synchronous durable capture: a subagent's inherited snapshot is
21
- // written inside the listener with writeFileSync + fsyncSync through the
22
- // group-2 unit layout (sessions/<sessionId>/inherited.json, own lock and
23
- // revision) and the group-2 lock convention (private candidate + fsync +
24
- // link publication, OwnerDoc schema, remove-only-if-ours release). A lock
25
- // conflict fails the capture — the synchronous listener cannot run the
26
- // async reclamation protocol, so contention fails closed instead of
27
- // blocking the event loop on backoff sleeps.
21
+ // written inside the listener through the capability store's SYNC commit path
22
+ // (store/store.js commitSync — the group-2 unit layout, record schema, fsync
23
+ // order and the nominal-lease sync lock of store/lock.js, all shared with the
24
+ // async path; capabilities-deepening D4 retired this module's private
25
+ // reimplementation of exactly those conventions). A lock conflict fails the
26
+ // capture — the synchronous listener cannot run the async reclamation
27
+ // protocol, so contention fails closed instead of blocking the event loop on
28
+ // backoff sleeps.
28
29
  //
29
30
  // The capture is NOT ATOMIC with the host's session publication: the host
30
31
  // writing the child session header and this plugin writing the snapshot are
@@ -51,20 +52,12 @@
51
52
  // throwing is the INTENTIONAL bail that refuses the subagent while the
52
53
  // parent session continues and learns the reason through the delegation
53
54
  // result. Never an unrestricted pass.
54
- import { hostname } from 'node:os'
55
- import { join } from 'node:path'
56
- import { randomBytes } from 'node:crypto'
57
- import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
58
- import { isSegment, resolveStoreRoot, unitLayout } from './store/paths.js'
59
- import { decodeRecord, encodeRecord } from './store/record.js'
60
- import { LOCK_SCHEMA_VERSION, parseOwnerDoc } from './store/lock.js'
55
+ import { isSegment } from './store/paths.js'
56
+ import { openCapabilityStore } from './store/store.js'
61
57
  import { skillIdentityKey } from './skill-identity.js'
62
- import { SUPPORTED_PLATFORMS } from './store/store.js'
63
58
 
64
59
  /** origin marker of a captured inherited snapshot payload. */
65
60
  export const INHERITED_ORIGIN = 'inherited'
66
- /** Nominal lease of the synchronously acquired capture lock (released immediately). */
67
- export const SYNC_LOCK_LEASE_MS = 10_000
68
61
 
69
62
  /**
70
63
  * @typedef {{ state: 'ready', sessionId: string, revision: number, skills: unknown[], mcpServers: string[],
@@ -75,39 +68,15 @@ export const SYNC_LOCK_LEASE_MS = 10_000
75
68
  * captured?: { parentSessionId: string, parentRevision: number | null, capturedAt: number } | null }} SnapshotInput
76
69
  */
77
70
 
78
- /** @param {unknown} error @param {...string} codes */
79
- const hasCode = (error, ...codes) => codes.includes(/** @type {{ code?: string }} */ (error)?.code ?? '')
80
-
81
71
  /** @param {unknown} error */
82
72
  const message = error => (error instanceof Error ? error.message : String(error))
83
73
 
84
- /** fsync one file (mirrors the group-2 fs-adapter fsyncFile step). @param {string} path */
85
- function fsyncPath(path) {
86
- const fd = openSync(path, 'r+')
87
- try {
88
- fsyncSync(fd)
89
- } finally {
90
- closeSync(fd)
91
- }
92
- }
93
-
94
- /** fsync one directory entry change (mirrors the fs-adapter fsyncDir step). @param {string} dir */
95
- function fsyncDirectory(dir) {
96
- // Directory fsync is POSIX-only (EPERM on win32 — same gate as fs-adapter.js).
97
- if (process.platform !== 'darwin' && process.platform !== 'linux') return
98
- const fd = openSync(dir, 'r')
99
- try {
100
- fsyncSync(fd)
101
- } finally {
102
- closeSync(fd)
103
- }
104
- }
105
-
106
74
  /**
107
75
  * In-memory lifecycle snapshots plus the synchronous read/capture paths.
108
- * Resolution mirrors the group-2 store exactly: root from `profileContext`
109
- * (or an explicit test root), the same platform gate, the same unit layout
110
- * and record schema — never a parallel layout.
76
+ * All durable I/O delegates to the capability store's sync path: the same
77
+ * root resolution (from `profileContext` or an explicit test root), the same
78
+ * platform gate, the same unit layout and record schema — never a parallel
79
+ * layout.
111
80
  * @param {{
112
81
  * profileContext?: unknown,
113
82
  * root?: string,
@@ -119,25 +88,16 @@ function fsyncDirectory(dir) {
119
88
  export function createLifecycleSnapshots(options = {}) {
120
89
  const warn = options.warn ?? (() => {})
121
90
  const now = options.now ?? Date.now
122
- const platform = options.platform ?? process.platform
123
- const located = typeof options.root === 'string' ? { supported: true, root: options.root } : resolveStoreRoot(options.profileContext)
124
- /** @type {{ supported: true, root: string } | { supported: false, reason: string }} */
125
- const support = !SUPPORTED_PLATFORMS.includes(platform)
126
- ? { supported: false, reason: `platform-unsupported:${platform}` }
127
- : /** @type {any} */ (located)
91
+ const store = openCapabilityStore({
92
+ root: options.root,
93
+ platform: options.platform,
94
+ profileContext: options.profileContext,
95
+ now,
96
+ })
97
+ const support = store.support
128
98
  /** @type {Map<string, LifecycleSnapshot>} */
129
99
  const snapshots = new Map()
130
100
 
131
- /**
132
- * @param {'selection'|'inherited'} kind @param {string} sessionId
133
- * @returns {{ dir: string, record: string, name: string }}
134
- */
135
- function locate(kind, sessionId) {
136
- const layout = unitLayout(/** @type {import('./store/paths.js').Unit} */ ({ kind, sessionId }))
137
- const dir = join(/** @type {{ root: string }} */ (support).root, ...layout.segments)
138
- return { dir, record: join(dir, `${layout.name}.json`), name: layout.name }
139
- }
140
-
141
101
  /**
142
102
  * Publish an accepted selection into the memory snapshot. Called at
143
103
  * plugin apply (preload), after every Apply acceptance (the engine's
@@ -187,9 +147,10 @@ export function createLifecycleSnapshots(options = {}) {
187
147
  /**
188
148
  * THE synchronous read path (6.1): memory hit returns immediately; a
189
149
  * miss performs ONE blocking synchronous read of the durable record —
190
- * never a promise, never a yield. An absent record returns null (no
191
- * accepted selection; initialization priority for brand-new root
192
- * sessions is task 6.5). An undecodable record becomes BLOCKED.
150
+ * the store's sync read, never a promise, never a yield. An absent record
151
+ * returns null (no accepted selection; initialization priority for
152
+ * brand-new root sessions is task 6.5). An undecodable record becomes
153
+ * BLOCKED.
193
154
  * @param {string} sessionId
194
155
  * @returns {LifecycleSnapshot | null}
195
156
  */
@@ -197,82 +158,27 @@ export function createLifecycleSnapshots(options = {}) {
197
158
  const memory = snapshots.get(sessionId)
198
159
  if (memory) return memory
199
160
  if (!support.supported) return null
200
- const { record } = locate('selection', sessionId)
201
- let text
161
+ let record
202
162
  try {
203
- text = readFileSync(record, 'utf8')
204
- } catch (error) {
205
- // No accepted record yet — a legitimate cold state, not a failure.
206
- if (hasCode(error, 'ENOENT')) return null
163
+ record = store.readSync({ kind: 'selection', sessionId })
164
+ } catch {
207
165
  // EACCES and friends: the record exists but cannot be read — fail
208
166
  // closed BLOCKED (6.4), never a guessed full authorization.
209
167
  return markBlocked(sessionId, 'unreadable')
210
168
  }
211
- const decoded = decodeRecord(text)
212
- if (decoded.kind === 'ok') {
213
- const payload = /** @type {Record<string, unknown>} */ (decoded.payload ?? {})
169
+ // No accepted record yet — a legitimate cold state, not a failure.
170
+ if (record.kind === 'absent') return null
171
+ if (record.kind === 'ok') {
172
+ const payload = /** @type {Record<string, unknown>} */ (record.payload ?? {})
214
173
  return publish(sessionId, {
215
- revision: decoded.revision,
174
+ revision: record.revision,
216
175
  skills: Array.isArray(payload.skills) ? payload.skills : [],
217
176
  mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers) : [],
218
177
  })
219
178
  }
220
- // corrupt / unknown-schema / torn: fail closed BLOCKED (6.4 root rule).
221
- // The original file is never rewritten from this path.
222
- return markBlocked(sessionId, decoded.kind)
223
- }
224
-
225
- /**
226
- * Synchronous acquisition of `<unit>.lock` through the group-2 lock
227
- * convention: private candidate + fsync + link publication (never
228
- * wx-then-write, which strands 0-byte locks on crash), OwnerDoc schema,
229
- * remove-only-if-ours release. A conflict fails the capture immediately:
230
- * the listener cannot run the async reclamation protocol without
231
- * yielding, so contention fails closed.
232
- * @param {string} dir @param {string} name @returns {string} owner token
233
- */
234
- function acquireLockSync(dir, name) {
235
- const token = randomBytes(12).toString('hex')
236
- const lockFile = join(dir, `${name}.lock`)
237
- const candidate = `${lockFile}.${token}.cand`
238
- const at = now()
239
- const doc = {
240
- schemaVersion: LOCK_SCHEMA_VERSION,
241
- ownerToken: token,
242
- pid: process.pid,
243
- host: hostname(),
244
- startIdentity: { osStart: null, bootNonce: 'sync-capture' },
245
- acquiredAt: at,
246
- leaseUntil: at + SYNC_LOCK_LEASE_MS,
247
- }
248
- try {
249
- writeFileSync(candidate, JSON.stringify(doc), { mode: 0o600 })
250
- fsyncPath(candidate)
251
- linkSync(candidate, lockFile)
252
- } catch (error) {
253
- if (hasCode(error, 'EEXIST')) throw new Error(`inherited snapshot capture lost the lock race for unit "${name}"`, { cause: error })
254
- if (hasCode(error, 'ENOENT')) throw new Error(`inherited snapshot capture lock candidate vanished for unit "${name}"`, { cause: error })
255
- throw error
256
- } finally {
257
- try {
258
- unlinkSync(candidate)
259
- } catch (error) {
260
- if (!hasCode(error, 'ENOENT')) warn(`inherited snapshot capture candidate cleanup failed: ${message(error)}`)
261
- }
262
- }
263
- return token
264
- }
265
-
266
- /** Remove `<unit>.lock` only while it still names our token (mirrors releaseOwned). @param {string} dir @param {string} name @param {string} token */
267
- function releaseLockSync(dir, name, token) {
268
- const lockFile = join(dir, `${name}.lock`)
269
- try {
270
- const doc = parseOwnerDoc(readFileSync(lockFile, 'utf8'))
271
- if (doc.kind === 'ok' && doc.value.ownerToken !== token) return
272
- unlinkSync(lockFile)
273
- } catch (error) {
274
- if (!hasCode(error, 'ENOENT')) warn(`inherited snapshot capture lock release failed: ${message(error)}`)
275
- }
179
+ // corrupt / unknown-schema / torn / unsupported: fail closed BLOCKED
180
+ // (6.4 root rule). The original file is never rewritten from this path.
181
+ return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (record.kind))
276
182
  }
277
183
 
278
184
  /**
@@ -306,7 +212,7 @@ export function createLifecycleSnapshots(options = {}) {
306
212
  })
307
213
  }
308
214
 
309
- /** String-set counterpart for managed MCP identities. @param {unknown} values @param {unknown} allowed @returns {string[]} */
215
+ /** String-set counterpart for managed MCP identities. @param {unknown[]} values @param {unknown[]} allowed @returns {string[]} */
310
216
  function intersectStrings(values, allowed) {
311
217
  const keep = new Set(Array.isArray(allowed) ? allowed : [])
312
218
  return (Array.isArray(values) ? values : []).filter(value => typeof value === 'string' && keep.has(value))
@@ -339,80 +245,53 @@ export function createLifecycleSnapshots(options = {}) {
339
245
  if (!support.supported) {
340
246
  throw new Error(`inherited snapshot capture unsupported for subagent "${childSessionId}": ${support.reason}`)
341
247
  }
342
- const { dir, record, name } = locate('inherited', childSessionId)
343
- mkdirSync(dir, { recursive: true, mode: 0o700 })
344
- const token = acquireLockSync(dir, name)
345
- try {
346
- // Same-unit CAS discipline, synchronously: decode the current record
347
- // under the lock and write the NEXT revision. A record that cannot be
348
- // decoded fails closed — it is never overwritten from this path.
349
- let current
350
- try {
351
- current = decodeRecord(readFileSync(record, 'utf8'))
352
- } catch (error) {
353
- if (!hasCode(error, 'ENOENT')) throw error
354
- current = /** @type {import('./store/record.js').RecordRead} */ ({ kind: 'absent', revision: 0 })
355
- }
356
- if (current.kind !== 'absent' && current.kind !== 'ok') {
357
- throw new Error(`existing inherited snapshot of "${childSessionId}" is ${current.kind}; refusing to overwrite it`)
358
- }
359
- const revision = current.revision + 1
360
- const capturedAt = now()
361
- // 6.3: an existing record means an explicit resume/escalation — the
362
- // child's previous snapshot intersected with the CURRENT parent set
363
- // (removed capabilities never return, added ones never arrive). A fresh
364
- // capture starts from the parent verbatim. Constraints narrow further.
365
- const previousSkills = current.kind === 'ok'
366
- ? (Array.isArray(/** @type {Record<string, unknown>} */ (current.payload)?.skills) ? /** @type {unknown[]} */ (/** @type {Record<string, unknown>} */ (current.payload).skills) : [])
367
- : null
368
- const parentSkills = parent ? parent.skills : []
369
- const skills = deriveInheritedSkills(parentSkills, previousSkills, constraints)
370
- const parentMcp = parent ? parent.mcpServers : []
371
- const previousMcp = current.kind === 'ok' && Array.isArray(/** @type {Record<string, unknown>} */ (current.payload)?.mcpServers)
372
- ? /** @type {string[]} */ (/** @type {Record<string, unknown>} */ (current.payload).mcpServers)
373
- : null
374
- let mcpServers = previousMcp == null ? parentMcp.slice() : intersectStrings(previousMcp, parentMcp)
375
- if (Array.isArray(constraints.allowMcpServers)) mcpServers = intersectStrings(mcpServers, constraints.allowMcpServers)
376
- const payload = {
377
- skills: structuredClone(skills),
378
- mcpServers,
379
- origin: INHERITED_ORIGIN,
380
- parent: { sessionId: parentSessionId, revision: parent ? parent.revision : null },
381
- capturedAt,
382
- }
383
- // Atomic commit through the group-2 record schema: temp + fsync +
384
- // rename + directory fsync. A rename failure is INDETERMINATE — the
385
- // record may or may not have landed — so the capture fails and the
386
- // subagent creation is refused; a later explicit resume recaptures.
387
- const next = encodeRecord(revision, payload, [])
388
- const temp = join(dir, `${name}.${token}.tmp`)
389
- try {
390
- writeFileSync(temp, JSON.stringify(next), { mode: 0o600 })
391
- fsyncPath(temp)
392
- } catch (error) {
393
- try {
394
- unlinkSync(temp)
395
- } catch { /* best effort */ }
396
- throw new Error(`inherited snapshot capture write failed for subagent "${childSessionId}": ${message(error)}`, { cause: error })
397
- }
398
- try {
399
- renameSync(temp, record)
400
- fsyncDirectory(dir)
401
- } catch (error) {
402
- try {
403
- unlinkSync(temp)
404
- } catch { /* best effort */ }
405
- throw new Error(`inherited snapshot capture commit failed for subagent "${childSessionId}": ${message(error)}`, { cause: error })
406
- }
407
- return publish(childSessionId, {
408
- revision,
409
- skills: payload.skills,
410
- mcpServers: payload.mcpServers,
411
- captured: { parentSessionId, parentRevision: payload.parent.revision, capturedAt },
412
- })
413
- } finally {
414
- releaseLockSync(dir, name, token)
248
+ // Same-unit CAS discipline, synchronously through the store's sync commit
249
+ // path: the record decodes under the store's sync lock and the next
250
+ // revision commits only over the exact expected one. A record that cannot
251
+ // be decoded fails closed — it is never overwritten from this path.
252
+ const unit = { kind: 'inherited', sessionId: childSessionId }
253
+ const current = store.readSync(unit)
254
+ if (current.kind !== 'absent' && current.kind !== 'ok') {
255
+ throw new Error(`existing inherited snapshot of "${childSessionId}" is ${current.kind}; refusing to overwrite it`)
256
+ }
257
+ const capturedAt = now()
258
+ // 6.3: an existing record means an explicit resume/escalation — the
259
+ // child's previous snapshot intersected with the CURRENT parent set
260
+ // (removed capabilities never return, added ones never arrive). A fresh
261
+ // capture starts from the parent verbatim. Constraints narrow further.
262
+ const previousSkills = current.kind === 'ok'
263
+ ? (Array.isArray(/** @type {Record<string, unknown>} */ (current.payload)?.skills) ? /** @type {unknown[]} */ (/** @type {Record<string, unknown>} */ (current.payload).skills) : [])
264
+ : null
265
+ const parentSkills = parent ? parent.skills : []
266
+ const skills = deriveInheritedSkills(parentSkills, previousSkills, constraints)
267
+ const parentMcp = parent ? parent.mcpServers : []
268
+ const previousMcp = current.kind === 'ok' && Array.isArray(/** @type {Record<string, unknown>} */ (current.payload)?.mcpServers)
269
+ ? /** @type {string[]} */ (/** @type {Record<string, unknown>} */ (current.payload).mcpServers)
270
+ : null
271
+ let mcpServers = previousMcp == null ? parentMcp.slice() : intersectStrings(previousMcp, parentMcp)
272
+ if (Array.isArray(constraints.allowMcpServers)) mcpServers = intersectStrings(mcpServers, constraints.allowMcpServers)
273
+ const payload = {
274
+ skills: structuredClone(skills),
275
+ mcpServers,
276
+ origin: INHERITED_ORIGIN,
277
+ parent: { sessionId: parentSessionId, revision: parent ? parent.revision : null },
278
+ capturedAt,
415
279
  }
280
+ // The store's sync commit performs the temp+fsync+rename+directory-fsync
281
+ // sequence under the sync lock. A non-committed outcome (lock contention,
282
+ // a CAS race, a failed write) fails the capture and refuses the subagent
283
+ // creation; a later explicit resume recaptures.
284
+ const result = store.commitSync(unit, current.kind === 'ok' ? current.revision : 0, () => payload)
285
+ if (result.status !== 'committed') {
286
+ const reason = /** @type {{ reason?: unknown }} */ (result).reason ?? result.status
287
+ throw new Error(`inherited snapshot capture failed for subagent "${childSessionId}": ${reason}`)
288
+ }
289
+ return publish(childSessionId, {
290
+ revision: result.revision,
291
+ skills: payload.skills,
292
+ mcpServers: payload.mcpServers,
293
+ captured: { parentSessionId, parentRevision: payload.parent.revision, capturedAt },
294
+ })
416
295
  }
417
296
 
418
297
  return {
@@ -52,27 +52,5 @@ export function createMcpToolWrapper({ identity, generation, admit, drain }) {
52
52
  }
53
53
  }
54
54
 
55
- /**
56
- * Wrap a resource provider: the three resource operations are gated by
57
- * the TARGET SERVER at dispatch time (8.4, shared resource tool schemas
58
- * stay visible; only execution is gated).
59
- * @param {{ list?: unknown, listTemplates?: unknown, read?: unknown }} provider
60
- */
61
- function wrapResourceProvider(provider) {
62
- const gate = fn => async (args, exec) => {
63
- const admitted = await admit(exec?.agent)
64
- if (!admitted) {
65
- const error = new Error(`MCP server "${identity}" resources are not enabled for this agent`)
66
- throw Object.assign(error, { code: 'mcp-admission-refused' })
67
- }
68
- return fn(args, exec)
69
- }
70
- const wrapped = { ...provider }
71
- for (const key of ['list', 'listTemplates', 'read']) {
72
- if (typeof provider?.[key] === 'function') wrapped[key] = gate(provider[key])
73
- }
74
- return wrapped
75
- }
76
-
77
- return { wrap, wrapResourceProvider }
55
+ return { wrap }
78
56
  }
@@ -81,28 +81,39 @@ export function createMcpDrain({ timeoutMs = 30_000, now = Date.now, sleep = ms
81
81
  }
82
82
 
83
83
  /**
84
- * The apply-engine drain seam (4.2) over this coordinator, keyed at the
85
- * engine's session: track/isClosed/status/begin exactly as the engine's
86
- * built-in coordinator, but with (agent, server) granularity so a removal
87
- * in session A never closes the gate of session B (8.5 A/B isolation).
88
- * @param {ReturnType<typeof createMcpDrain>} drain
89
- * @param {() => string} sessionIdOf
84
+ * The apply-engine drain seam (capabilities-deepening D3) over the manager's
85
+ * (agent, server)-keyed drain. The engine shares ONE instance across all
86
+ * sessions and passes the session id into begin(removed, sessionId) — so a
87
+ * removal in session A never closes the gate of session B (8.5 A/B
88
+ * isolation), and the returned handle's abort() reopens exactly the gates
89
+ * begin() closed (a failed Apply never permanently gates that session).
90
+ *
91
+ * The drain resolves LAZILY per begin: the manager row may mount after this
92
+ * plugin, and a composition without it has no managed servers to drain —
93
+ * the handle then reports the empty truth (settled, zero in flight) and
94
+ * abort() is a no-op. In-flight registration stays where it always was:
95
+ * mcp-admission.js's enter/exit at handoff, never this seam.
96
+ * @param {() => ReturnType<typeof createMcpDrain> | null | undefined} resolveDrain
90
97
  */
91
- export function createDrainEngineAdapter(drain, sessionIdOf) {
98
+ export function createSessionDrainAdapter(resolveDrain) {
92
99
  return {
93
- track(server, call) {
94
- const token = drain.enter(sessionIdOf(), server, 'call')
95
- if (token === null) return false
96
- Promise.resolve(call).catch(() => {}).finally(() => drain.exit(sessionIdOf(), server, token))
97
- return true
98
- },
99
- isClosed: server => drain.isClosed(sessionIdOf(), server),
100
- status: server => drain.snapshot(sessionIdOf(), server),
101
- begin(removed) {
102
- for (const server of removed) drain.close(sessionIdOf(), server)
100
+ /**
101
+ * Close the removed servers' gates for this session and report the live
102
+ * counts; abort() reopens exactly these (session, server) gates.
103
+ * @param {string[]} removed @param {string} sessionId
104
+ */
105
+ begin(removed, sessionId) {
106
+ const drain = resolveDrain()
107
+ if (!drain) {
108
+ return {
109
+ snapshot: () => removed.map(server => ({ server, state: 'settled', inFlight: 0 })),
110
+ abort: () => {},
111
+ }
112
+ }
113
+ for (const server of removed) drain.close(sessionId, server)
103
114
  return {
104
- snapshot: () => removed.map(server => drain.snapshot(sessionIdOf(), server)),
105
- abort: () => removed.forEach(server => drain.reopen(sessionIdOf(), server)),
115
+ snapshot: () => removed.map(server => drain.snapshot(sessionId, server)),
116
+ abort: () => { for (const server of removed) drain.reopen(sessionId, server) },
106
117
  }
107
118
  },
108
119
  }
@@ -14,7 +14,8 @@
14
14
  // PER AGENT at every prompt assembly — an agent without the server gets an
15
15
  // empty string (renderPrompt drops zero-length sections, CITED).
16
16
  // - mcpResources.register(provider): resource operations are gated by the
17
- // target server at dispatch time (8.4).
17
+ // target server at dispatch time (8.4) AND fenced by the Apply drain at
18
+ // handoff (8.6) when the manager wires one in.
18
19
  //
19
20
  // Fail closed: a host method the facade does not know (the host added a new
20
21
  // call to one of these services) makes THIS server fail visibly instead of
@@ -28,6 +29,7 @@ const message = error => (error instanceof Error ? error.message : String(error)
28
29
  * identity: string, generation: number,
29
30
  * admit: (agent: unknown) => Promise<boolean>,
30
31
  * isEnabledFor: (agent: unknown) => boolean,
32
+ * drain?: { enter(agentId: string, server: string, label?: string): number|null, exit(agentId: string, server: string, token: number|null): void },
31
33
  * onError?: (reason: string, cause?: unknown) => void }} config
32
34
  */
33
35
  export function createMcpFacade({ host, identity, generation, admit, isEnabledFor, drain, onRegister, onError = () => {} }) {
@@ -79,7 +81,10 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
79
81
  * preserved verbatim and the provider's request path is gated by THIS
80
82
  * facade's admission verdict (8.4①: resource operations are gated at
81
83
  * dispatch by the target server; shared resource tool schemas stay
82
- * visible, only execution is gated).
84
+ * visible, only execution is gated). With a drain attached the request
85
+ * also enters the (agent, server) in-flight fence at handoff (8.6), so a
86
+ * removal's commit window rejects late requests instead of letting the
87
+ * old provider serve them.
83
88
  */
84
89
  register(server, provider) {
85
90
  const request = provider?.request
@@ -90,7 +95,22 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
90
95
  const error = new Error(`MCP server "${identity}" resources are not enabled for this agent`)
91
96
  throw Object.assign(error, { code: 'mcp-admission-refused' })
92
97
  }
93
- return request(args, exec)
98
+ // The same drain fence the tool wrapper enters at handoff (8.6):
99
+ // between drain.begin and the post-commit snapshot publication the
100
+ // admit verdict above still reads the PRE-Apply snapshot, so a
101
+ // removed server's resource request must be rejected HERE or the
102
+ // old provider keeps serving it through the commit window.
103
+ const label = typeof args?.method === 'string' ? args.method : 'resource'
104
+ const token = drain ? drain.enter(exec?.agent?.id ?? exec?.agent?.session?.id, identity, label) : null
105
+ if (drain && token === null) {
106
+ const error = new Error(`MCP server "${identity}" is draining for this agent after an accepted removal (8.6)`)
107
+ throw Object.assign(error, { code: 'mcp-gate-closed' })
108
+ }
109
+ try {
110
+ return await request(args, exec)
111
+ } finally {
112
+ if (drain) drain.exit(exec?.agent?.id ?? exec?.agent?.session?.id, identity, token)
113
+ }
94
114
  } }
95
115
  : provider
96
116
  return /** @type {{ register(s: unknown, p: unknown): unknown }} */ (host.mcpResources).register(server, gated)