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
@@ -2,6 +2,7 @@
2
2
  // of publication outcome or old-writer quiescence. It lives in the atomic image.
3
3
  import { createHash } from 'node:crypto'
4
4
  import { canonical } from './snapshot.js'
5
+ import { revokedOwner } from './derived-facts.js'
5
6
  import { expectedRegistry, isRegistryUpgrade } from './incarnations.js'
6
7
 
7
8
  export const LATE_WRITER_RISK = 'Detached historic writers may still modify files after this override.'
@@ -12,17 +13,13 @@ export const ONLINE_RECOVERY_ACTOR = 'online-administrator'
12
13
  * settlement (design D3) are the only writers. */
13
14
  const ACTORS = ['authenticated-settings-administrator', AUTOMATIC_RECOVERY_ACTOR, ONLINE_RECOVERY_ACTOR]
14
15
  export const digest = value => createHash('sha256').update(canonical(value)).digest('hex')
15
- export const dispositionFor = (state, op) => state.adminRecoveries?.find(row => row.owner === op.sessionId && row.operations.some(item => item.operationId === op.operationId))
16
- /** Epoch-scoped revocation: a disposition revokes its owner's execution
17
- * authority exactly at the recorded revokedEpoch. The owner stays revoked
18
- * while its recorded epoch is within it; a trusted resume advances the epoch
19
- * past every revokedEpoch and ends the revoked state atomically, without any
20
- * ledger change (append-only: dispositions are never removed or altered). */
21
- export const revokedOwner = (state, owner) => {
22
- const session = state.sessions?.find(s => s.sessionId === owner)
23
- if (!session) return false
24
- return state.adminRecoveries?.some(row => row.owner === owner && session.executionEpoch <= row.revokedEpoch) === true
25
- }
16
+ // Epoch-scoped revocation (`revokedOwner`) and the disposition lookup
17
+ // (`dispositionFor`) live in derived-facts.js (change edit-lock-deepening,
18
+ // design D1) — one pure home shared by the manager status path, the cold view
19
+ // and the inspector; `revokedOwner` is imported above for this module's own
20
+ // validation. The re-export keeps existing external importers working (the
21
+ // integration scenario child imports dispositionFor from this module).
22
+ export { dispositionFor, revokedOwner } from './derived-facts.js'
26
23
 
27
24
  function requireThat(condition, message) { if (!condition) throw new Error(`invalid administrative recovery: ${message}`) }
28
25
  function keys(value, names) {
@@ -6,7 +6,8 @@ import { createHash } from 'node:crypto'
6
6
  import { reservePublisher } from './reservation.js'
7
7
  import { openEditLockStore } from './store.js'
8
8
  import { canonical, parseSnapshot, validateImage } from './snapshot.js'
9
- import { administrativeState, digest, dispositionFor, LATE_WRITER_RISK } from './admin-ledger.js'
9
+ import { administrativeState, digest, LATE_WRITER_RISK } from './admin-ledger.js'
10
+ import { dispositionFor } from './derived-facts.js'
10
11
 
11
12
  export const MAX_RECOVERY_BYTES = 16 * 1024 * 1024
12
13
  const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
@@ -2,8 +2,15 @@ import { posix, win32 } from 'node:path'
2
2
 
3
3
  /** Candidates describe effects, not merely explicit API arguments. Session-wide
4
4
  * operations must enumerate affected ownership at their FIFO position.
5
+ * Modes: 'normal' covers every mutating ingress; 'release' is pure removal and
6
+ * must retain the ownership unresolved updates require; 'revocation' is strictly
7
+ * subtractive and crosses fences. 'resume' is reserved for session-level
8
+ * recovery transactions (the trusted Continue): it publishes nothing, so an
9
+ * unresolved resource-level fence never denies it, while domain fences and
10
+ * every shape denial bind it exactly as they bind 'normal'. It must never be
11
+ * used for a transaction that publishes, confirms, acquires or releases.
5
12
  * @typedef {{kind: 'resources', resourceIds: string[]} | {kind: 'scope', ancestor: string} | {kind: 'none'}} Candidate
6
- * @typedef {'normal'|'release'|'revocation'} Mode
13
+ * @typedef {'normal'|'release'|'revocation'|'resume'} Mode
7
14
  */
8
15
 
9
16
  /** Shape check only, NOT filesystem proof. The trusted manager ingress already
@@ -59,7 +66,10 @@ export function admitMutation(operations, candidate, mode = 'normal') {
59
66
  for (const resourceId of candidate.resourceIds) {
60
67
  if (!canonicalResource(resourceId)) deny('identity-unproved')
61
68
  if (fence?.kind !== 'resource') deny('scope-continuity-unproved')
62
- if (fence?.kind === 'resource' && (!canonicalResource(fence.resourceId) || fence.resourceId === resourceId)) deny('resource-conflict')
69
+ // 'resume' publishes nothing: a resource-level fence never bars the
70
+ // session-level recovery transaction; it keeps barring the resource
71
+ // itself on every other mode until administrative settlement.
72
+ if (mode !== 'resume' && fence?.kind === 'resource' && (!canonicalResource(fence.resourceId) || fence.resourceId === resourceId)) deny('resource-conflict')
63
73
  }
64
74
  }
65
75
  }
@@ -8,14 +8,32 @@ export function bindingRefusalReason(pid, attestation) {
8
8
  return `session already bound: holder=${holder}${pid !== undefined ? `; pid=${pid}` : ''}`
9
9
  }
10
10
 
11
- /** Render only publisher-classified facts. Unrecognized/legacy reasons are unknown.
12
- * @param {string} sessionId @param {string} reason */
13
- export function bindingGuidance(sessionId, reason) {
14
- const facts = /^session already bound: holder=(serving-idle|serving|same-process-unattestable|cross-process-pid|unknown)(?:; pid=([1-9]\d*))?$/.exec(reason)
15
- const classification = facts?.[1]
16
- const pid = facts?.[2]
11
+ /** The wire/minted holder classes. */
12
+ const HOLDER_KINDS = Object.freeze(['revocation-failed', 'serving', 'transferable', 'cross-process', 'unknown'])
13
+
14
+ /** Validate a structured binding refusal — exactly the minted four-field
15
+ * shape crosses the wire or reaches a classifier; anything else degrades to
16
+ * the text channel (display only, never a decision input).
17
+ * @param {unknown} value @returns {{code: 'binding-duplicate', retryable: true, holderKind: string, holderPid: number | null} | undefined} */
18
+ export function asBindingFailure(value) {
19
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
20
+ if (Object.keys(value).sort().join(',') !== 'code,holderKind,holderPid,retryable') return undefined
21
+ const { code, retryable, holderKind, holderPid } = /** @type {any} */ (value)
22
+ if (code !== 'binding-duplicate' || retryable !== true || !HOLDER_KINDS.includes(holderKind)) return undefined
23
+ if (holderPid !== null && !(Number.isSafeInteger(holderPid) && holderPid > 0)) return undefined
24
+ return { code, retryable, holderKind, holderPid }
25
+ }
26
+
27
+ /** Render only publisher-classified facts — the STRUCTURED bindingFailure,
28
+ * never a re-parse of the display text (spec: presentation must not infer
29
+ * state by matching human-readable text; change edit-lock-deepening D2).
30
+ * Unrecognized or missing structure renders as unknown.
31
+ * @param {string} sessionId @param {any} failure */
32
+ export function bindingGuidance(sessionId, failure) {
33
+ const classification = failure?.code === 'binding-duplicate' ? failure?.holderKind : undefined
34
+ const pid = typeof failure?.holderPid === 'number' ? failure.holderPid : undefined
17
35
  let holder
18
- if (classification === 'serving-idle') {
36
+ if (classification === 'transferable') {
19
37
  // Transferable (change edit-lock-binding-transfer): an idle same-process
20
38
  // holder is taken over by the next driving ingress — calm copy, no pid,
21
39
  // no window hunt, and NEVER the publisher-reservation warning.
@@ -25,14 +43,14 @@ export function bindingGuidance(sessionId, reason) {
25
43
  // channel proxy): name what is happening and when to retry. No window
26
44
  // hunt (single-instance hosts exist) and no process management.
27
45
  holder = `session ${sessionId} is being edited in another attachment right now. Wait for that turn to finish, then use /edit-lock retry in this session; retry does not Continue editing.`
28
- } else if (classification === 'same-process-unattestable' && pid !== undefined) {
46
+ } else if (classification === 'unknown' && pid !== undefined) {
29
47
  holder = `session ${sessionId} has a binding in this same process (pid ${pid}) that cannot be safely reclaimed. A stale runtime incarnation may still hold its channel. Wait for the original holder to disconnect or for confirmed process shutdown.`
30
- } else if (classification === 'cross-process-pid' && pid !== undefined) {
48
+ } else if (classification === 'cross-process' && pid !== undefined) {
31
49
  holder = `session ${sessionId} has a binding held by process pid ${pid}. Quit that holder process or disconnect the original holder, and wait for confirmation.`
32
50
  } else {
33
51
  holder = `session ${sessionId} has a binding whose holder cannot be attested. Wait for the original holder to disconnect or for confirmed process shutdown.`
34
52
  }
35
- if (classification === 'serving-idle' || classification === 'serving') return holder
53
+ if (classification === 'transferable' || classification === 'serving') return holder
36
54
  return `${holder} Then use /edit-lock retry in the original session when available; retry does not Continue editing. Do NOT remove the publisher reservation (.weir/.edit-lock.publisher-reservation) while its process is alive — removing it would split the edit authority.`
37
55
  }
38
56
 
@@ -0,0 +1,312 @@
1
+ /** Per-agent binding state machine (change edit-lock-deepening, design D3):
2
+ * setupAgentOnce / setupAgent / retryBinding / disposeAgent / startupFailure /
3
+ * dispositionFor and the closure state they share, extracted from the
4
+ * composition (index.js), which now keeps pure wiring: tool registration, the
5
+ * pre-execute guard, HTTP endpoints, the /edit-lock command, drivers and event
6
+ * listeners. The move is mechanical — no logic changed; every container keeps
7
+ * exactly the lifetime it had in the mount closure.
8
+ *
9
+ * Lifetime table (itemized BEFORE the move; the move changed nothing in it):
10
+ *
11
+ * state lifetime home after the move
12
+ * ------------------ ------------- --------------------------------------
13
+ * settled cross-mount sharedDomainRegistryFor entry (existing
14
+ * precedent, with recoveryNotices); INJECTED, not owned
15
+ * writeScopes host-lifetime writeScopesFor(host) store, surviving a
16
+ * preset rebind; INJECTED — the preset-rebind survival
17
+ * semantics and the `ctx.root ?? ctx` host key are
18
+ * unchanged
19
+ * disposedAgents per-mount this module (WeakSet)
20
+ * startFailures per-mount this module (WeakMap)
21
+ * bindingFailures per-mount this module (WeakMap)
22
+ * setupInFlight per-mount this module (WeakMap)
23
+ * boundAgents per-mount this module (Map by session id)
24
+ * dispositions per-mount this module (Map by session id; the
25
+ * latest incarnation stays joinable after synchronous
26
+ * cleanup)
27
+ * agentDispositions per-mount this module (WeakMap)
28
+ * disposalTasks per-mount this module (WeakMap)
29
+ * pendingAutoResume per-mount composition turn wiring; INJECTED (a
30
+ * successful retry clears an armed auto-resume)
31
+ * delegateResumes per-mount composition delegate-lifecycle wiring;
32
+ * INJECTED (a fresh bind replays a pending resume)
33
+ * turnClassifications per-mount composition turn/end listener; INJECTED
34
+ * (dispose awaits the durable classification first)
35
+ * replyTools per-mount composition negotiation wiring;
36
+ * INJECTED (dispose retires the reply tool)
37
+ * expiry per-mount composition retention-timer wiring;
38
+ * INJECTED (dispose clears the timer)
39
+ *
40
+ * Cross-mount state rides the EXISTING sharedDomainRegistryFor entry — no new
41
+ * cross-mount container is introduced. Mount-generation flags arrive as
42
+ * getters (isClosed/platformFailure) so the machine reads the live value.
43
+ * @param {{
44
+ * ctx: any, sessionOf: (agent: any) => string | undefined, GUARDED_TOOLS: readonly string[],
45
+ * options: any, registry: any, settled: WeakMap<object, any>, writeScopes: WeakMap<object, import('./write-scopes.js').WriteScopeEntry>,
46
+ * domainOf: (agent: any) => Promise<any>, servingRuntime: (sessionId: string) => any,
47
+ * ownAgent: (agent: any) => boolean, installWriteScope: (agent: any) => import('./write-scopes.js').WriteScopeEntry,
48
+ * evidence: any, audit: (session: any, type: any, data: any, options?: any) => void, deliverLocal: (agent: any, text: string, wake?: boolean) => void,
49
+ * pendingAutoResume: WeakMap<object, boolean>, delegateResumes: Map<string, any>, turnClassifications: WeakMap<object, Promise<unknown>>,
50
+ * replyTools: WeakMap<object, {dispose: () => void, stale: boolean}>, expiry: WeakMap<object, {timer?: any}>, settle: any,
51
+ * isClosed: () => boolean, platformFailure: () => any,
52
+ * }} deps
53
+ */
54
+ import { AUDIT_TYPES } from '../shared/audit.js'
55
+ import { disposeWriteScope } from './write-scopes.js'
56
+
57
+ /** In-session binding-failure notice copy (change edit-lock-binding-transfer,
58
+ * design D4): the transferable class (same-process idle holder) is a calm
59
+ * self-heal announcement rather than an editing-disabled alarm and never
60
+ * carries the raw holder classification or the reservation warning; a mid-turn
61
+ * holder names the real state and the retry moment. Template layer stays
62
+ * English (AGENTS §3.7). @param {string} reason @param {any} failure */
63
+ function bindingFailureNotice(reason, failure) {
64
+ if (failure?.code === 'binding-duplicate' && failure.holderKind === 'transferable') {
65
+ return "Edit Lock: this session's editing authority is held by an inactive attachment of this same session; it transfers automatically when you send a message."
66
+ }
67
+ if (failure?.code === 'binding-duplicate' && failure.holderKind === 'serving') {
68
+ return "Edit Lock: this session is being edited in another attachment right now; managed file editing stays unavailable until that turn finishes. Do not retry the failed edit; use /edit-lock retry (or the panel's Retry connection) once it finishes — retry does not Continue editing."
69
+ }
70
+ return `Edit Lock could not start this session's edit domain (${reason}); managed file editing is temporarily unavailable. ${failure?.code === 'binding-duplicate' ? 'Do not retry the failed edit: the binding is retried automatically when the user next sends a message, after which editing works normally. Never remove a live publisher reservation.' : 'Inspect Edit Lock maintenance before continuing.'}`
71
+ }
72
+
73
+ export function createBindingMachine(deps) {
74
+ const {
75
+ ctx, sessionOf, GUARDED_TOOLS, options, registry, settled, writeScopes,
76
+ domainOf, servingRuntime, ownAgent, installWriteScope,
77
+ evidence, audit, deliverLocal,
78
+ pendingAutoResume, delegateResumes, turnClassifications, replyTools, expiry, settle,
79
+ isClosed, platformFailure,
80
+ } = deps
81
+ /** Agents disposed in this mount; their undispatched sweep work cancels.
82
+ * @type {WeakSet<object>} */
83
+ const disposedAgents = new WeakSet()
84
+ /** Why registration failed for an agent, for the panel. @type {WeakMap<object, string>} */
85
+ const startFailures = new WeakMap()
86
+ /** Publisher-classified failures, never inferred from text. @type {WeakMap<object, any>} */
87
+ const bindingFailures = new WeakMap()
88
+ /** In-flight setup per agent, shared by every entry point (creation,
89
+ * preset-switch, catch-up, lazy re-bind) so concurrent callers can never
90
+ * double-register. @type {WeakMap<object, Promise<void>>} */
91
+ const setupInFlight = new WeakMap()
92
+ const boundAgents = new Map()
93
+ // Keep the latest incarnation joinable even after synchronous binding cleanup.
94
+ // Exact-handle waiters retain their own record when a session activates again.
95
+ const dispositions = new Map()
96
+ const agentDispositions = new WeakMap()
97
+ const disposalTasks = new WeakMap()
98
+
99
+ function dispositionFor(agent) {
100
+ let record = agentDispositions.get(agent)
101
+ if (!record) {
102
+ record = Promise.withResolvers()
103
+ record.promise.catch(() => {}) // emit initiators have no awaiting caller
104
+ agentDispositions.set(agent, record)
105
+ dispositions.set(agent.id, record)
106
+ }
107
+ return record
108
+ }
109
+
110
+ /** Only this live Agent's diagnostic; no domain reads or startup side effects. @param {any} agent */
111
+ function startupFailure(agent) {
112
+ const platform = platformFailure()
113
+ if (platform) return { state: 'unavailable', reason: platform.message, bindingFailure: null }
114
+ if (!agent || settled.has(agent) || !startFailures.has(agent)) return null
115
+ let eligible = false
116
+ try {
117
+ const serving = servingRuntime(agent.id)
118
+ eligible = !isClosed() && !disposedAgents.has(agent) && ownAgent(agent)
119
+ && (serving === agent
120
+ // The registry names ANOTHER live local agent — the dual-agent defect
121
+ // this change heals (edit-lock-binding-transfer). The recorded
122
+ // duplicate-binding failure is still THIS agent's honest panel state:
123
+ // show it, with its Retry action, instead of hiding behind the ghost's
124
+ // healthy binding. An empty registry stays hidden (unattestable).
125
+ || (serving !== undefined && bindingFailures.get(agent)?.code === 'binding-duplicate'))
126
+ } catch { /* no trusted live identity */ }
127
+ if (!eligible) return null
128
+ const failure = bindingFailures.get(agent)
129
+ return { state: 'unavailable', reason: startFailures.get(agent),
130
+ bindingFailure: failure ? { ...failure, inFlight: setupInFlight.has(agent) } : null }
131
+ }
132
+
133
+ /** Per-agent Edit Lock setup, shared by the creation listener, the
134
+ * preset-switch re-bind, the late-mount catch-up and the guard/acquire lazy
135
+ * re-bind, so the paths can never diverge. Root selection and successful
136
+ * startup are separate: explicit binding retry reuses the existing scope
137
+ * and root, while ordinary entry points never repeat a failed startup.
138
+ * @param {any} agent @param {boolean} [retry] @param {boolean} [transfer]
139
+ * transfer (change edit-lock-binding-transfer) marks the driving-ingress
140
+ * transfer admission: the domain's transferStart re-adjudicates holder
141
+ * idleness at execution, and the post-bind registry-identity check is
142
+ * skipped because the registry legitimately still names the idle holder. */
143
+ async function setupAgentOnce(agent, retry = false, transfer = false) {
144
+ if (isClosed() || settled.has(agent)) return
145
+ // No writer scope, root resolution, directory, reservation or session
146
+ // authority may be installed when the mount preflight failed.
147
+ if (platformFailure()) return
148
+ if (!registry.rootOf(agent)) try {
149
+ // A previous generation's managed write survives in the agent's own
150
+ // scope layer (a preset rebind never disposes own-layer registrations);
151
+ // retire it first or the scope registry throws duplicate-registration
152
+ // and the re-bind dies here (design D3).
153
+ disposeWriteScope(writeScopes, agent)
154
+ writeScopes.set(agent, installWriteScope(agent))
155
+ registry.bind(agent, options.fixed?.root ?? agent?.session?.header?.cwd)
156
+ } catch (error) {
157
+ // The denial itself must never escape: restrict() throws on names the
158
+ // agent's composition does not know, and an escape from this setup path
159
+ // is fiber-fatal during a reload (2026-10-05 host crash). Deny only the
160
+ // guarded tools the agent actually has.
161
+ try {
162
+ const known = GUARDED_TOOLS.filter(name => ctx.tools?.get?.(name, agent) !== undefined)
163
+ if (known.length > 0) agent?.ctx?.tools?.restrict?.({ deny: [...known] })
164
+ } catch { /* the denial is best-effort; the records below are the visible account */ }
165
+ // Failure visibility (design D4): the same reason lands in the panel
166
+ // state, the maintenance evidence, an in-session notice and the durable
167
+ // audit; the warn stays as the host ring-buffer copy.
168
+ const reason = String(/** @type {any} */ (error)?.message ?? error)
169
+ startFailures.set(agent, reason)
170
+ try { evidence?.recordSessionFailure?.(sessionOf(agent), reason) } catch { /* reporting only */ }
171
+ audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, { kind: 'setup-failed', sessionId: sessionOf(agent) ?? null, reason })
172
+ try { deliverLocal(agent, `Edit Lock is unavailable for this session (${reason}); managed file editing is disabled and the failed edit must not be retried. This failure is not repaired automatically: the user can restart DeepSeek Harness or start a new conversation.`) } catch { /* the audit record is the durable account */ }
173
+ ctx.logger?.warn?.(`edit lock scope failed; edit tools denied: ${reason}`)
174
+ return
175
+ }
176
+ try {
177
+ const domain = await domainOf(agent)
178
+ if (transfer) await domain.transferStart(agent)
179
+ else if (retry) await domain.retryStart(agent)
180
+ else await domain.start(agent)
181
+ if (isClosed() || disposedAgents.has(agent) || (retry && (!ownAgent(agent) || (!transfer && servingRuntime(agent.id) !== agent)))) {
182
+ await domain.forget(agent)
183
+ throw new Error('Edit Lock mount changed during binding')
184
+ }
185
+ startFailures.delete(agent)
186
+ bindingFailures.delete(agent)
187
+ try { evidence?.clearSessionFailure?.(sessionOf(agent)) } catch { /* reporting cannot undo a successful bind */ }
188
+ // A genuine message from a turn that ran while binding was unavailable
189
+ // is not a Continue receipt for a later injected wake after connection retry.
190
+ if (retry) pendingAutoResume.delete(agent)
191
+ settled.set(agent, domain)
192
+ boundAgents.set(agent.id, agent)
193
+ dispositionFor(agent)
194
+ if (!retry) await delegateResumes.get(agent.id)?.resume(agent, domain)
195
+ } catch (error) {
196
+ // Kept so the panel can say WHY editing is unavailable instead of
197
+ // showing "starting" forever; the notice aligns this catch with the
198
+ // write-scope failure above.
199
+ const reason = String(/** @type {any} */ (error)?.message ?? error)
200
+ startFailures.set(agent, reason)
201
+ const failure = /** @type {any} */ (error)?.bindingFailure
202
+ if (failure?.code === 'binding-duplicate') bindingFailures.set(agent, failure)
203
+ else bindingFailures.delete(agent)
204
+ try { evidence?.recordSessionFailure?.(sessionOf(agent), reason) } catch { /* reporting only */ }
205
+ try { deliverLocal(agent, bindingFailureNotice(reason, failure)) } catch { /* the panel record remains */ }
206
+ ctx.logger?.warn?.(`edit lock registration failed for ${sessionOf(agent)}: ${reason}`)
207
+ }
208
+ }
209
+ /** Concurrent entry points (agent/created, preset-switch, lazy re-bind)
210
+ * share one in-flight setup per agent; a completed setup short-circuits on
211
+ * the root binding. @param {any} agent @returns {Promise<void>} */
212
+ function setupAgent(agent) {
213
+ const pending = setupInFlight.get(agent)
214
+ if (pending) return pending
215
+ if (isClosed() || settled.has(agent) || registry.rootOf(agent)) return Promise.resolve()
216
+ const inner = setupAgentOnce(agent)
217
+ const task = inner.finally(() => { if (setupInFlight.get(agent) === task) setupInFlight.delete(agent) })
218
+ setupInFlight.set(agent, task)
219
+ return task
220
+ }
221
+ /** Trusted driving ingresses only: the explicit /edit-lock retry (and panel
222
+ * action), the message-armed pre-step retry, and the write-guard lazy
223
+ * re-bind. Same Agent/root/mount; no writer reinstallation. Every attempt
224
+ * past the eligibility gate is audited — a silently swallowed refusal is a
225
+ * defect (change edit-lock-binding-transfer, design D3/D5).
226
+ * @param {any} agent @param {'command' | 'message' | 'write-guard'} [via] */
227
+ async function retryBinding(agent, via = 'command') {
228
+ if (isClosed() || disposedAgents.has(agent) || !ownAgent(agent)) throw new Error('Edit Lock retry requires the current live Agent of this mount')
229
+ if (!registry.rootOf(agent) || bindingFailures.get(agent)?.code !== 'binding-duplicate') throw new Error('Edit Lock startup failure is not eligible for binding retry')
230
+ const cause = bindingFailures.get(agent)
231
+ const sessionId = sessionOf(agent) ?? null
232
+ const root = registry.rootOf(agent) ?? undefined
233
+ /** @param {string} result @param {Record<string, unknown>} [extra] */
234
+ const record = (result, extra = {}) => audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, {
235
+ kind: 'binding-transfer', sessionId, code: cause?.code ?? 'binding-duplicate', result, ...extra,
236
+ }, { root })
237
+ // Attestation trichotomy (design D5): the registry naming THIS claimant
238
+ // keeps the existing superseded rebind; naming ANOTHER live local agent
239
+ // routes to the transfer admission (holder idleness is re-adjudicated at
240
+ // execution, publisher-side); an unavailable or empty registry stays
241
+ // fail-closed — the holder cannot be attested.
242
+ let serving
243
+ try { serving = servingRuntime(agent.id) } catch { serving = undefined }
244
+ if (serving === undefined) {
245
+ const error = new Error('Edit Lock binding retry refused: the agent registry is unavailable or names no live agent for this session, so the holder cannot be attested. The recorded cause stays; a later retry remains possible.')
246
+ record('refused', { reason: 'registry-unattestable', detail: error.message })
247
+ throw error
248
+ }
249
+ const transfer = serving !== agent
250
+ try {
251
+ let task = setupInFlight.get(agent)
252
+ if (!task) {
253
+ const inner = setupAgentOnce(agent, true, transfer)
254
+ task = inner.finally(() => { if (setupInFlight.get(agent) === task) setupInFlight.delete(agent) })
255
+ setupInFlight.set(agent, task)
256
+ }
257
+ await task
258
+ } catch (error) {
259
+ record('refused', { reason: /** @type {any} */ (error)?.bindingFailure?.holderKind ?? 'retry-refused', detail: String(/** @type {any} */ (error)?.message ?? error) })
260
+ throw error
261
+ }
262
+ const failure = startFailures.get(agent)
263
+ if (failure || !settled.has(agent)) {
264
+ // setupAgentOnce records instead of rethrowing: the refusal's holder
265
+ // class comes from the freshly re-recorded structured failure.
266
+ const error = new Error(failure ?? 'Edit Lock binding remains unavailable')
267
+ record('refused', { reason: bindingFailures.get(agent)?.holderKind ?? 'binding-unavailable', detail: error.message })
268
+ throw error
269
+ }
270
+ if (transfer) {
271
+ record('transferred', { holder: cause?.holderKind ?? null })
272
+ } else if (via === 'message') {
273
+ // The message-armed superseded success keeps its established audit shape
274
+ // (pinned by the editlock-binding-auto-retry integration scenario).
275
+ audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, {
276
+ kind: 'binding-auto-retry', sessionId, code: cause?.code ?? 'binding-duplicate', result: 'recovered',
277
+ }, { root })
278
+ }
279
+ return 'Edit Lock connection recovered. Editing remains interrupted; use Continue editing when ready. No failed edit was replayed.'
280
+ }
281
+ function disposeAgent(agent) {
282
+ if (disposedAgents.has(agent)) return disposalTasks.get(agent) ?? Promise.resolve()
283
+ if (boundAgents.get(agent.id) === agent) boundAgents.delete(agent.id)
284
+ // Undispatched sweep work armed by this agent cancels here (an in-flight
285
+ // authority transaction is never cancelled or reinterpreted).
286
+ disposedAgents.add(agent)
287
+ // The managed write lives on the agent's own scope layer, which dies with
288
+ // the agent: retire the host-lifetime registration here (design D3).
289
+ disposeWriteScope(writeScopes, agent)
290
+ replyTools.get(agent)?.dispose()
291
+ replyTools.delete(agent)
292
+ const timer = expiry.get(agent)?.timer
293
+ if (timer) clearTimeout(timer)
294
+ expiry.delete(agent)
295
+ settle.settled(agent)
296
+ const domain = settled.get(agent)
297
+ settled.delete(agent)
298
+ // The emit hook must cancel timers/scopes/sweeps synchronously. Only the
299
+ // final authority disposal waits for durable abnormal classification.
300
+ const record = dispositionFor(agent)
301
+ const task = Promise.resolve(turnClassifications.get(agent)).then(() => domain?.dispose(agent))
302
+ task.then(record.resolve, record.reject)
303
+ disposalTasks.set(agent, record.promise)
304
+ return record.promise
305
+ }
306
+
307
+ return {
308
+ disposedAgents, startFailures, bindingFailures, setupInFlight,
309
+ boundAgents, dispositions, agentDispositions,
310
+ setupAgent, retryBinding, disposeAgent, startupFailure,
311
+ }
312
+ }
@@ -0,0 +1,67 @@
1
+ /** Ledger derived facts (change edit-lock-deepening, design D1): the three
2
+ * derivation rules an authority image supports — epoch-scoped revocation
3
+ * (`revokedOwner`), read-time hold settlement (`settleHoldFacts`) and the
4
+ * administrative disposition lookup (`dispositionFor`) — in ONE pure home,
5
+ * shared by the manager status path, the kernel's settleHold, the cold view
6
+ * and the maintenance inspector. Pure: no IO, no clock, no ctx, no imports.
7
+ *
8
+ * Each rule takes a `partial` option for the cold read (buildColdView): the
9
+ * panel is deliberately more tolerant than the kernel — a partial image (a
10
+ * missing collection, a malformed row) degrades to "not revoked / no hold"
11
+ * instead of throwing on data the kernel would already have rejected at parse
12
+ * time. The strict default is byte-identical to the kernel/ledger rules the
13
+ * call sites previously inlined or imported. */
14
+
15
+ /**
16
+ * Epoch-scoped revocation: a disposition revokes its owner's execution
17
+ * authority exactly at the recorded revokedEpoch; the owner stays revoked
18
+ * while its recorded epoch is within it, and a trusted resume advancing the
19
+ * epoch past every revokedEpoch ends the revoked state without any ledger
20
+ * change (append-only: dispositions are never removed or altered). Extracted
21
+ * from admin-ledger.js; the cold view applied the same rule inline.
22
+ * @param {any} state the authority image (or the in-memory status state)
23
+ * @param {string} owner
24
+ * @param {{ partial?: boolean }} [options] partial tolerates a partial image:
25
+ * missing collections read as empty and malformed rows are skipped; the
26
+ * strict default trusts the validated image.
27
+ */
28
+ export function revokedOwner(state, owner, { partial = false } = {}) {
29
+ if (partial) {
30
+ const sessions = Array.isArray(state?.sessions) ? state.sessions : []
31
+ const session = sessions.find(row => row?.sessionId === owner) ?? null
32
+ if (session == null) return false
33
+ const ledger = Array.isArray(state?.adminRecoveries) ? state.adminRecoveries : []
34
+ return ledger.some(row => row?.owner === owner && Number.isSafeInteger(row?.revokedEpoch) && session.executionEpoch <= row.revokedEpoch)
35
+ }
36
+ const session = state.sessions?.find(s => s.sessionId === owner)
37
+ if (!session) return false
38
+ return state.adminRecoveries?.some(row => row.owner === owner && session.executionEpoch <= row.revokedEpoch) === true
39
+ }
40
+
41
+ /**
42
+ * Read-time retention settlement (design D5), byte-identical to the kernel's
43
+ * settleHold rule it was extracted from (state.js): a hold counts only while
44
+ * the session owns a lock, and the remaining budget is computed against the
45
+ * read instant, so a missed expiry timer can never leave stale ownership
46
+ * behind a status read. The caller keeps its container lookup and its empty
47
+ * case (the kernel's zero row vs the cold view's null): `held` is the
48
+ * session's PRESENT hold row, `owns` whether the session owns any lock, `now`
49
+ * the read instant.
50
+ * @param {string} sessionId @param {any} held @param {boolean} owns @param {number} now
51
+ * @param {{ partial?: boolean }} [options] partial keeps the cold path's
52
+ * defensive tolerance of a partial image: an undefined holdUntil reads as no
53
+ * expiry and only a literal `holding === true` counts.
54
+ */
55
+ export function settleHoldFacts(sessionId, held, owns, now, { partial = false } = {}) {
56
+ const until = held.holdUntil
57
+ const remainingMs = (partial ? until == null : until === null) ? 0 : Math.max(0, until - now)
58
+ const holding = (partial ? held.holding === true : held.holding) && owns
59
+ return { sessionId, held: holding, expired: holding && until !== null && remainingMs === 0,
60
+ holdUntil: until, remainingMs, holdCumulativeMs: held.holdCumulativeMs }
61
+ }
62
+
63
+ /**
64
+ * The administrative disposition covering one operation, if any. Extracted
65
+ * verbatim from admin-ledger.js; the maintenance inspector's inline copy was
66
+ * identical (inspect.js). @param {any} state @param {any} op */
67
+ export const dispositionFor = (state, op) => state.adminRecoveries?.find(row => row.owner === op.sessionId && row.operations.some(item => item.operationId === op.operationId))