weir-harness 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/lib/client.capability-badge.js +2 -0
  2. package/lib/client.capability-manager.js +46 -179
  3. package/lib/client.capability-model.js +428 -3
  4. package/lib/client.file-lease-tool-model.js +189 -0
  5. package/lib/client.file-lease-tool-view.js +434 -0
  6. package/lib/client.js +105 -7
  7. package/lib/client.mcp-manager.js +563 -0
  8. package/package.json +1 -1
  9. package/skills/mcp-server-setup/SKILL.md +124 -0
  10. package/src/capabilities/apply-engine.js +18 -4
  11. package/src/capabilities/capability-remote.js +296 -45
  12. package/src/capabilities/initial-selection.js +3 -2
  13. package/src/capabilities/lifecycle-preload.js +69 -11
  14. package/src/capabilities/lifecycle-snapshot.js +127 -39
  15. package/src/capabilities/mcp-admission.js +2 -2
  16. package/src/capabilities/mcp-config.js +344 -0
  17. package/src/capabilities/mcp-credentials.js +180 -0
  18. package/src/capabilities/mcp-diagnostics.js +169 -0
  19. package/src/capabilities/mcp-drain.js +18 -4
  20. package/src/capabilities/mcp-facade.js +40 -9
  21. package/src/capabilities/mcp-gate.js +51 -52
  22. package/src/capabilities/mcp-install-tool.js +276 -0
  23. package/src/capabilities/mcp-manager.js +575 -146
  24. package/src/capabilities/mcp-mount.js +220 -79
  25. package/src/capabilities/mcp-operations.js +359 -0
  26. package/src/capabilities/mcp-process.js +43 -0
  27. package/src/capabilities/mcp-registry.js +74 -60
  28. package/src/capabilities/mcp-runtime.js +103 -0
  29. package/src/capabilities/read-payloads.js +8 -3
  30. package/src/capabilities/skill-applicability.js +185 -0
  31. package/src/capabilities/skill-builtin-migration.js +2 -1
  32. package/src/capabilities/skill-selection-plugin.js +170 -57
  33. package/src/file-lease/client.js +268 -35
  34. package/src/file-lease/index.js +4 -0
  35. package/src/file-lease/plugin.js +239 -15
  36. package/src/file-lease/tools.js +426 -38
  37. package/src/file-lease/watch.js +473 -0
  38. package/src/intent-gate/index.js +29 -12
  39. package/src/intent-gate/matcher.js +36 -2
  40. package/src/lease-broker/protocol.js +3 -2
  41. package/src/lease-broker/server.js +6 -3
  42. package/src/lease-broker/state.js +68 -11
  43. package/src/process/index.js +4 -2
  44. package/src/shared/audit.js +3 -0
  45. package/src/shared/child-scope.js +2 -0
  46. package/src/worktree/rules.js +1 -1
@@ -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)}`)
@@ -9,13 +9,15 @@
9
9
  // never yields: no promise chains, no async helpers, only blocking
10
10
  // synchronous file I/O where durability is required.
11
11
  //
12
- // 6.1 — in-memory snapshot: accepted selections are loaded into memory at
13
- // plugin apply (lifecycle-preload.js) and after every Apply acceptance (the
14
- // apply-engine publishSnapshot hook, invoked inside the same non-async
15
- // segment as the internal snapshot swap). The listener reads the memory
16
- // snapshot synchronously (measured ≈0.2 ms in G4b); on a memory miss it
17
- // falls back to ONE blocking synchronous read of the session record (never
18
- // the host storageDomain — a promise interface, unusable here, CITED).
12
+ // 6.1 — in-memory snapshot: the session's EFFECTIVE capability set is loaded
13
+ // into memory at plugin apply (lifecycle-preload.js) and after every Apply
14
+ // acceptance (the apply-engine publishSnapshot hook, invoked inside the same
15
+ // non-async segment as the internal snapshot swap). The listener reads the
16
+ // memory snapshot synchronously (measured ≈0.2 ms in G4b); on a memory miss it
17
+ // falls back to blocking synchronous reads of the session records — the
18
+ // accepted selection FIRST, then the durable inherited snapshot when no
19
+ // accepted record exists (a fork of a fork, a resumed incarnation), never the
20
+ // host storageDomain (a promise interface, unusable here, CITED).
19
21
  //
20
22
  // 6.2 — synchronous durable capture: a subagent's inherited snapshot is
21
23
  // written inside the listener through the capability store's SYNC commit path
@@ -39,19 +41,29 @@
39
41
  // decoded does NOT throw — the session is created/resumed as usual and
40
42
  // the memory snapshot becomes BLOCKED (empty Skill view, all managed MCP
41
43
  // calls refused, recovery entry visible). Never a full-discovery
42
- // fallback; the original file is never rewritten.
44
+ // fallback; the original file is never rewritten. An inherited record is
45
+ // consulted ONLY when no accepted record exists, and an existing record
46
+ // that cannot be decoded blocks just as hard — a corrupt higher-priority
47
+ // record is never skipped in favour of a lower-priority one or of the
48
+ // initial baseline.
43
49
  // - resume/fork incarnation (depth 0 yet a parentSession header): the named
44
- // parent's accepted snapshot is captured best-effort under the SAME root
45
- // rule — a failed capture never throws and never rejects the session; the
46
- // session runs BLOCKED like an unreadable root record, with the manager
47
- // naming an explicit Apply as the recovery gesture.
48
- // - subagent (one-shot / background / supervised member / escalation /
49
- // fork child): a parent snapshot that cannot be read, or a capture that
50
- // cannot be written, makes the listener THROW. The host dispatches
51
- // agent/created serially and a listener failure rejects the creation, so
52
- // throwing is the INTENTIONAL bail that refuses the subagent while the
53
- // parent session continues and learns the reason through the delegation
54
- // result. Never an unrestricted pass.
50
+ // parent's EFFECTIVE snapshot (accepted, inherited, or the already-resolved
51
+ // initial one) is captured best-effort under the SAME root rule — a failed
52
+ // capture never throws and never rejects the session; the session runs
53
+ // BLOCKED like an unreadable root record, with the manager naming an
54
+ // explicit Apply as the recovery gesture. A parent with NO effective
55
+ // snapshot at all (proven absent on supported storage) is not an empty
56
+ // set: the capture is skipped and the fork resolves the ordinary root
57
+ // initialization policy for itself.
58
+ // - subagent (one-shot / background / supervised member / escalation; a
59
+ // depth-0 fork child is handled by the incarnation rule above): a parent
60
+ // snapshot that cannot be read, or a capture that cannot be written, makes
61
+ // the listener THROW. The host dispatches agent/created serially and a
62
+ // listener failure rejects the creation, so throwing is the INTENTIONAL
63
+ // bail that refuses the subagent while the parent session continues and
64
+ // learns the reason through the delegation result. Never an unrestricted
65
+ // pass, and never the root-initialization fallback (that is a depth-0 fork
66
+ // affordance only).
55
67
  import { isSegment } from './store/paths.js'
56
68
  import { openCapabilityStore } from './store/store.js'
57
69
  import { skillIdentityKey } from './skill-identity.js'
@@ -60,17 +72,28 @@ import { skillIdentityKey } from './skill-identity.js'
60
72
  export const INHERITED_ORIGIN = 'inherited'
61
73
 
62
74
  /**
63
- * @typedef {{ state: 'ready', sessionId: string, revision: number, skills: unknown[], mcpServers: string[],
75
+ * @typedef {{ state: 'ready', sessionId: string, revision: number, skills: unknown[], mcpServers: string[], mcpIncarnations?: Record<string, string|null>,
64
76
  * captured: { parentSessionId: string, parentRevision: number | null, capturedAt: number } | null }} ReadySnapshot
65
77
  * @typedef {{ state: 'blocked', sessionId: string, reason: 'corrupt'|'unknown-schema'|'torn'|'unreadable' }} BlockedSnapshot
66
78
  * @typedef {ReadySnapshot | BlockedSnapshot} LifecycleSnapshot
67
- * @typedef {{ revision: number, skills?: unknown[], mcpServers?: string[],
79
+ * @typedef {{ revision: number, skills?: unknown[], mcpServers?: string[], mcpIncarnations?: Record<string, string|null>,
68
80
  * captured?: { parentSessionId: string, parentRevision: number | null, capturedAt: number } | null }} SnapshotInput
69
81
  */
70
82
 
71
83
  /** @param {unknown} error */
72
84
  const message = error => (error instanceof Error ? error.message : String(error))
73
85
 
86
+ export function normalizeMcpIncarnations(value) {
87
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
88
+ return Object.fromEntries(Object.entries(value).filter(([, incarnation]) => incarnation === null || typeof incarnation === 'string'))
89
+ }
90
+
91
+ export function mcpIncarnationsFor(servers, mapping) {
92
+ const normalized = normalizeMcpIncarnations(mapping)
93
+ if (!normalized) return undefined
94
+ return Object.fromEntries(servers.filter(identity => Object.hasOwn(normalized, identity)).map(identity => [identity, normalized[identity]]))
95
+ }
96
+
74
97
  /**
75
98
  * In-memory lifecycle snapshots plus the synchronous read/capture paths.
76
99
  * All durable I/O delegates to the capability store's sync path: the same
@@ -118,6 +141,7 @@ export function createLifecycleSnapshots(options = {}) {
118
141
  revision: next.revision,
119
142
  skills: Object.freeze(structuredClone(Array.isArray(next.skills) ? next.skills : [])),
120
143
  mcpServers: Object.freeze((Array.isArray(next.mcpServers) ? next.mcpServers : []).slice()),
144
+ ...(normalizeMcpIncarnations(next.mcpIncarnations) ? { mcpIncarnations: Object.freeze(normalizeMcpIncarnations(next.mcpIncarnations)) } : {}),
121
145
  captured: next.captured ? Object.freeze({ ...next.captured }) : null,
122
146
  })
123
147
  snapshots.set(sessionId, snapshot)
@@ -146,11 +170,14 @@ export function createLifecycleSnapshots(options = {}) {
146
170
 
147
171
  /**
148
172
  * THE synchronous read path (6.1): memory hit returns immediately; a
149
- * miss performs ONE blocking synchronous read of the durable record —
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.
173
+ * miss performs blocking synchronous reads of the durable records — the
174
+ * store's sync read, never a promise, never a yield. The effective snapshot
175
+ * is the accepted selection when one exists, otherwise the durable inherited
176
+ * snapshot (a fork whose source only ever resolved an initial set, or a fork
177
+ * of a fork). An absent pair returns null (no effective snapshot; the fork's
178
+ * own root initialization is layered above this mechanism). Any record that
179
+ * exists and does not decode becomes BLOCKED — a corrupt higher-priority
180
+ * record never falls through to a lower-priority one.
154
181
  * @param {string} sessionId
155
182
  * @returns {LifecycleSnapshot | null}
156
183
  */
@@ -166,19 +193,62 @@ export function createLifecycleSnapshots(options = {}) {
166
193
  // closed BLOCKED (6.4), never a guessed full authorization.
167
194
  return markBlocked(sessionId, 'unreadable')
168
195
  }
169
- // No accepted record yet — a legitimate cold state, not a failure.
170
- if (record.kind === 'absent') return null
171
196
  if (record.kind === 'ok') {
172
197
  const payload = /** @type {Record<string, unknown>} */ (record.payload ?? {})
173
198
  return publish(sessionId, {
174
199
  revision: record.revision,
175
200
  skills: Array.isArray(payload.skills) ? payload.skills : [],
176
201
  mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers) : [],
202
+ mcpIncarnations: normalizeMcpIncarnations(payload.mcpIncarnations),
177
203
  })
178
204
  }
179
205
  // corrupt / unknown-schema / torn / unsupported: fail closed BLOCKED
180
206
  // (6.4 root rule). The original file is never rewritten from this path.
181
- return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (record.kind))
207
+ if (record.kind !== 'absent') return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (record.kind))
208
+ // No accepted record — a legitimate cold state. The EFFECTIVE set may still
209
+ // be the durable inherited snapshot this session was created with.
210
+ return snapshotFromInherited(sessionId)
211
+ }
212
+
213
+ /**
214
+ * The inherited fallback of {@link snapshotFor}: no accepted record exists,
215
+ * so the effective set is whatever durable inheritance the session was given
216
+ * at creation. The snapshot keeps the inherited revision and restores the
217
+ * parent/captured metadata — it is NEVER reported as an accepted revision
218
+ * (the receipt's CAS token stays accepted-only, 0 when absent). A record
219
+ * that exists but does not decode blocks exactly like a corrupt selection
220
+ * record: no fall-through to the initial baseline.
221
+ * @param {string} sessionId
222
+ * @returns {LifecycleSnapshot | null}
223
+ */
224
+ function snapshotFromInherited(sessionId) {
225
+ let inherited
226
+ try {
227
+ inherited = store.readSync({ kind: 'inherited', sessionId })
228
+ } catch {
229
+ return markBlocked(sessionId, 'unreadable')
230
+ }
231
+ if (inherited.kind !== 'ok') {
232
+ if (inherited.kind === 'absent') return null
233
+ return markBlocked(sessionId, /** @type {BlockedSnapshot['reason']} */ (inherited.kind))
234
+ }
235
+ const payload = /** @type {Record<string, unknown>} */ (inherited.payload ?? {})
236
+ const parent = /** @type {Record<string, unknown> | null} */ (
237
+ payload.parent && typeof payload.parent === 'object' ? payload.parent : null)
238
+ const capturedAt = typeof payload.capturedAt === 'number' && Number.isFinite(payload.capturedAt) ? payload.capturedAt : 0
239
+ return publish(sessionId, {
240
+ revision: inherited.revision,
241
+ skills: Array.isArray(payload.skills) ? payload.skills : [],
242
+ mcpServers: Array.isArray(payload.mcpServers) ? /** @type {string[]} */ (payload.mcpServers) : [],
243
+ mcpIncarnations: normalizeMcpIncarnations(payload.mcpIncarnations),
244
+ captured: parent && typeof parent.sessionId === 'string' && parent.sessionId.length > 0
245
+ ? {
246
+ parentSessionId: parent.sessionId,
247
+ parentRevision: Number.isSafeInteger(parent.revision) ? /** @type {number} */ (parent.revision) : null,
248
+ capturedAt,
249
+ }
250
+ : null,
251
+ })
182
252
  }
183
253
 
184
254
  /**
@@ -219,13 +289,15 @@ export function createLifecycleSnapshots(options = {}) {
219
289
  }
220
290
 
221
291
  /**
222
- * Durably capture a subagent's inherited snapshot (6.2), synchronously.
292
+ * Durably capture a session's inherited snapshot (6.2), synchronously.
223
293
  * The inherited sets follow creation-time inheritance (6.3): verbatim
224
294
  * parent at creation, child-previous ∩ current-parent on an explicit
225
295
  * resume/escalation, narrowed by delegation constraints — never widened.
226
- * A parent without any accepted record yields
227
- * an empty inherited set (the no-policy initialization priority of task
228
- * 6.5 is a root-session concern layered above this mechanism).
296
+ * The parent's EFFECTIVE set is its accepted selection when one exists,
297
+ * otherwise its own inherited snapshot (a fork of a fork). A parent with NO
298
+ * effective snapshot at all is not an empty inheritance: the depth-0 fork
299
+ * caller skips this capture entirely (see `agentCreated`) and lets that fork
300
+ * run the ordinary root initialization instead.
229
301
  *
230
302
  * NOT ATOMIC with the host's session publication (see the module header):
231
303
  * a crash between the two durable acts leaves a child session without a
@@ -270,9 +342,11 @@ export function createLifecycleSnapshots(options = {}) {
270
342
  : null
271
343
  let mcpServers = previousMcp == null ? parentMcp.slice() : intersectStrings(previousMcp, parentMcp)
272
344
  if (Array.isArray(constraints.allowMcpServers)) mcpServers = intersectStrings(mcpServers, constraints.allowMcpServers)
345
+ const mcpIncarnations = mcpIncarnationsFor(mcpServers, parent?.mcpIncarnations)
273
346
  const payload = {
274
347
  skills: structuredClone(skills),
275
348
  mcpServers,
349
+ ...(mcpIncarnations ? { mcpIncarnations } : {}),
276
350
  origin: INHERITED_ORIGIN,
277
351
  parent: { sessionId: parentSessionId, revision: parent ? parent.revision : null },
278
352
  capturedAt,
@@ -290,6 +364,7 @@ export function createLifecycleSnapshots(options = {}) {
290
364
  revision: result.revision,
291
365
  skills: payload.skills,
292
366
  mcpServers: payload.mcpServers,
367
+ mcpIncarnations: normalizeMcpIncarnations(payload.mcpIncarnations),
293
368
  captured: { parentSessionId, parentRevision: payload.parent.revision, capturedAt },
294
369
  })
295
370
  }
@@ -320,7 +395,7 @@ export function createLifecycleSnapshots(options = {}) {
320
395
  // record becomes BLOCKED inside snapshotFor — this path NEVER throws.
321
396
  const own = snapshotFor(sessionId)
322
397
  // Resume/fork incarnation (D1): depth zero yet a parentSession
323
- // header. The read side demands an inherited snapshot for such a
398
+ // header. The read side demands an effective snapshot for such a
324
399
  // session exactly when it carries NO accepted record of its own, so
325
400
  // the capture side aligns with that gate: an incarnation WITH its
326
401
  // own accepted record keeps it verbatim (an explicit user Apply is
@@ -331,12 +406,25 @@ export function createLifecycleSnapshots(options = {}) {
331
406
  // parentSession equal to the session id is no parent (defensive).
332
407
  const parentSessionId = session.header?.parentSession
333
408
  if (own === null && typeof parentSessionId === 'string' && parentSessionId.length > 0 && parentSessionId !== sessionId) {
334
- try {
335
- captureInherited(sessionId, parentSessionId)
336
- } catch (captureError) {
337
- warn(`resume incarnation capture failed for session "${sessionId}": ${message(captureError)}`)
338
- markBlocked(sessionId, 'unreadable')
409
+ // The fork inherits the source's EFFECTIVE set: accepted, else
410
+ // inherited, else the already-resolved initial snapshot. Only a
411
+ // parent snapshot PROVEN absent (null on supported storage) is not a
412
+ // set to copy — an unsupported store proves nothing, so that case
413
+ // keeps the old attempt-and-block shape instead of silently
414
+ // initializing the fork.
415
+ const parent = snapshotFor(parentSessionId)
416
+ const provenAbsent = parent === null && support.supported
417
+ if (!provenAbsent) {
418
+ try {
419
+ captureInherited(sessionId, parentSessionId)
420
+ } catch (captureError) {
421
+ warn(`resume incarnation capture failed for session "${sessionId}": ${message(captureError)}`)
422
+ markBlocked(sessionId, 'unreadable')
423
+ }
339
424
  }
425
+ // provenAbsent: no capture and no empty inherited record. The first
426
+ // readSelection then runs the ordinary root initialization for this
427
+ // depth-0 fork and publishes its own revision-0 snapshot.
340
428
  }
341
429
  return undefined
342
430
  }
@@ -13,7 +13,7 @@
13
13
  * admit: (agent: unknown) => Promise<boolean>,
14
14
  * drain?: { enter(agentId: string, server: string, label?: string): number|null, exit(agentId: string, server: string, token: number|null): void } }} config
15
15
  */
16
- export function createMcpToolWrapper({ identity, generation, admit, drain }) {
16
+ export function createMcpToolWrapper({ identity, generation, admit, drain, assertPermit }) {
17
17
  /**
18
18
  * Wrap one tool definition with the last-moment check.
19
19
  * @param {{ name?: unknown, execute?: unknown, [key: string]: unknown }} definition
@@ -33,7 +33,7 @@ export function createMcpToolWrapper({ identity, generation, admit, drain }) {
33
33
  */
34
34
  async execute(args, exec) {
35
35
  const admitted = await admit(exec?.agent)
36
- if (!admitted) {
36
+ if (!admitted || (assertPermit && !assertPermit(admitted))) {
37
37
  const error = new Error(`MCP server "${identity}" is not enabled for this agent (admission refused at generation ${generation})`)
38
38
  throw Object.assign(error, { code: 'mcp-admission-refused' })
39
39
  }