weir-harness 1.2.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. package/src/worktree/authority.js +0 -56
@@ -1,1303 +0,0 @@
1
- // Weir Edit Lock composition. Disabled (zero behavior, no service) unless
2
- // enabled by the row config or the Weir settings `editLock.enabled` key
3
- // (read at mount; a change applies after restart). Plain ESM, ctx-only.
4
- //
5
- // Domains: each agent binds at creation to the management root of its session
6
- // cwd (git top level, else the cwd; an enclosing existing authority wins). The
7
- // authority lives in <root>/.weir/edit-lock and is excluded from git through
8
- // .git/info/exclude. The first host to reserve a root publishes for it; every
9
- // other cooperating host becomes its client over a local socket.
10
- //
11
- // Order contract: this row MUST precede hashline-edit and lsp. A mis-ordered
12
- // or unmanaged editor is not trusted: the pre-execute guard denies every
13
- // write/edit/hash_edit/lsp_rename definition not claimed through the service.
14
- import { existsSync, mkdirSync, readdirSync, realpathSync } from 'node:fs'
15
- import { isAbsolute, join } from 'node:path'
16
- import { openReservedEditLockRuntime } from './reserved-runtime.js'
17
- import { reservationPathFor } from './reservation.js'
18
- import { createEditLockLifecycle } from './lifecycle.js'
19
- import { installEditLockWriteScope } from './tool-scope.js'
20
- import { writeScopesFor } from './write-scopes.js'
21
- import { createBindingMachine } from './binding-machine.js'
22
- import { createResourceIdentity } from './resource-identity.js'
23
- import { userTextMessage } from '../shared/user-message.js'
24
- import { isGenuineUserMessage } from '../shared/runtime-messages.js'
25
- import { createAudit, AUDIT_TYPES } from '../shared/audit.js'
26
- import { createStaleSweepScheduler } from './stale-sweep.js'
27
- import { createRemoteEditLockDomain, endpointFor, serveEditLockEndpoint } from './remote.js'
28
- import { registerBindingRuntime, createBindingAttester } from './binding-attestation.js'
29
- import { localDomain, remoteDomain } from './domain.js'
30
- import { AUTHORITY_DIR, createDomainRegistry, excludeFromGit, managementRootFor, sharedDomainRegistryFor } from './domains.js'
31
- import { createRecoveryDriver } from './recovery.js'
32
- import { createSettlementDriver } from './settle.js'
33
- import { editLockLimits } from './limits.js'
34
- import { buildColdView, buildView, unavailableView } from './view.js'
35
- import { parseSnapshot } from './snapshot.js'
36
- import { readSnapshotBytes, errorMessage } from './read-authority.js'
37
- import { registerDelegateEditLifecycle } from '../shared/delegate-edit-lifecycle.js'
38
-
39
- const name = 'weir-edit-lock'
40
- const inject = ['tools', 'fs', 'agents']
41
-
42
- /** Tool names whose definitions must be Edit Lock routed when enabled. */
43
- export const GUARDED_TOOLS = Object.freeze(['write', 'edit', 'hash_edit', 'lsp_rename', 'str_replace_editor'])
44
-
45
- /** Claimed managed-editor executions, shared by every mount of this bundle in
46
- * the process (change edit-lock-shared-domain-mounts, design D3): membership
47
- * is execute-object identity, so cross-host admission is impossible and an
48
- * execution claimed by a disposed mount is harmless — no live guard reads it.
49
- * Whichever mount's guard stage hears an execution, the verdict is the same.
50
- * @type {WeakSet<Function>} */
51
- const managed = new WeakSet()
52
-
53
- /** Host-lifetime live weirEditLock services (design D3): every mount registers
54
- * its service at apply and unregisters at dispose, so the endpoint currency
55
- * proof can accept any live sibling mount's service identity.
56
- * @type {WeakMap<object, Set<object>>} */
57
- const liveServicesByHost = new WeakMap()
58
- /** @param {object} host @returns {Set<object>} */
59
- function liveServicesFor(host) {
60
- let services = liveServicesByHost.get(host)
61
- if (!services) {
62
- services = new Set()
63
- liveServicesByHost.set(host, services)
64
- }
65
- return services
66
- }
67
-
68
- /** Row config wins over the settings section; `root` + `authorityDirectory`
69
- * pin one fixed domain (development compositions and probes).
70
- * @param {unknown} config @param {unknown} [settings] */
71
- export function editLockOptions(config, settings) {
72
- const value = { .../** @type {any} */ (settings ?? {}), .../** @type {any} */ (config ?? {}) }
73
- if (value.enabled !== true) return null
74
- if (value.root === undefined && value.authorityDirectory === undefined) return { fixed: null }
75
- for (const key of ['root', 'authorityDirectory']) {
76
- if (typeof value[key] !== 'string' || !isAbsolute(value[key])) throw new Error(`edit lock: ${key} must be an absolute path`)
77
- }
78
- return { fixed: { root: value.root, directory: value.authorityDirectory } }
79
- }
80
-
81
- /** Never initialize over unknown content: empty → create, committed snapshot → recover.
82
- * @param {string} directory @returns {'create'|'recover'} */
83
- export function storeMode(directory) {
84
- if (!existsSync(directory)) mkdirSync(directory, { recursive: true, mode: 0o700 })
85
- const entries = readdirSync(directory)
86
- if (entries.length === 0) return 'create'
87
- if (entries.includes('snapshot.json')) return 'recover'
88
- throw new Error(`edit lock: authority directory ${directory} has no committed snapshot and is not empty`)
89
- }
90
-
91
- /** @param {any} agent */
92
- function sessionOf(agent) {
93
- return typeof agent?.id === 'string' && agent.id ? agent.id : undefined
94
- }
95
- /** Confirm every pending-confirmation lock of one session. Each file goes
96
- * through the ordinary per-file check, so a file that does not qualify is simply
97
- * skipped; the bulk path can never widen authority. @param {any} domain
98
- * @param {any} agent @param {{resourceId: string}[]} pending */
99
- async function confirmAll(domain, agent, pending) {
100
- const confirmed = []
101
- for (const lock of pending) {
102
- try {
103
- await domain.confirm(agent, lock.resourceId)
104
- confirmed.push(lock.resourceId)
105
- } catch { /* not confirmable any more; the next status shows why */ }
106
- }
107
- return confirmed
108
- }
109
-
110
- /** Message-driven auto-resume procedure (design D2/D3/D4): the trusted resume
111
- * runs with a fresh server-minted requestId (never a commandId), then the
112
- * ordinary per-file confirmation replays over the retained locks — exactly the
113
- * panel's Continue editing sequence, so it never widens authority. A zero-lock
114
- * session confirms the empty set. Any failure (a revoked session, a lost race
115
- * with the manual Continue) only warns; edits stay denied and the panel keeps
116
- * showing the state. @param {any} domain @param {any} agent
117
- * @param {(message: string) => void} [warn] */
118
- async function autoResume(domain, agent, warn) {
119
- try {
120
- const status = await domain.resume(agent, `auto:user-message:${crypto.randomUUID()}`)
121
- const pending = status.locks.filter(/** @param {any} lock */ lock => lock.status === 'pending-confirmation')
122
- await confirmAll(domain, agent, pending)
123
- } catch (error) {
124
- warn?.(`edit lock auto-resume failed: ${/** @type {any} */ (error)?.message ?? error}`)
125
- }
126
- }
127
-
128
- /** @param {any} domain @param {any} agent @param {string} raw @param {string} commandId @param {{holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number}} limits */
129
- async function runCommand(domain, agent, raw, commandId, limits) {
130
- const [verb, ...rest] = raw.trim().split(/\s+/u)
131
- if (!verb || verb === 'status') return describe(await domain.status(agent))
132
- if (verb === 'stop') {
133
- const status = await domain.stop(agent)
134
- return `Edit authority durably revoked for this session.\n${describe(status)}`
135
- }
136
- if (verb === 'resume') {
137
- const status = await domain.resume(agent, `command:${commandId}`)
138
- const pending = status.locks.filter(/** @param {any} lock */ lock => lock.status === 'pending-confirmation')
139
- // Resume restores authority only; retained files still need an explicit
140
- // confirmation (the panel's Continue editing runs resume then confirm --all).
141
- return `Edit authority resumed with a new execution epoch.${pending.length ? ` ${pending.length} retained file(s) wait for confirmation: /edit-lock confirm --all, or /edit-lock confirm <path> for one.` : ''}\n${describe(status)}`
142
- }
143
- if (verb === 'hold') {
144
- const minutes = rest.length > 0 && rest[0] !== '' ? Number(rest[0]) : limits.holdDefaultMinutes
145
- if (!Number.isFinite(minutes) || minutes <= 0) throw new Error('Usage: /edit-lock hold [minutes]')
146
- const held = await domain.hold(agent, Math.round(minutes * 60_000), { singleMaxMs: limits.holdSingleMaxMinutes * 60_000, cumulativeMaxMs: limits.holdCumulativeMaxMinutes * 60_000 })
147
- return `Keeping ${held.lockCount} file(s) until ${new Date(held.holdUntil).toLocaleTimeString()} (${Math.round(held.holdCumulativeMs / 60_000)} of ${limits.holdCumulativeMaxMinutes} minutes used in this batch).\n${describe(await domain.status(agent))}`
148
- }
149
- if (verb === 'confirm') {
150
- // `--all` only replays the per-file confirmation over files that already
151
- // qualify; it never widens authority (spec: One-step confirmation).
152
- if (rest.length === 1 && rest[0] === '--all') {
153
- const status = await domain.status(agent)
154
- const pending = status.locks.filter(/** @param {any} lock */ lock => lock.status === 'pending-confirmation')
155
- const confirmed = await confirmAll(domain, agent, pending)
156
- return `Confirmed ${confirmed.length} of ${pending.length} retained file(s).\n${describe(await domain.status(agent))}`
157
- }
158
- const path = rest.join(' ')
159
- if (!path) throw new Error('Usage: /edit-lock confirm <path> | /edit-lock confirm --all')
160
- const cwd = agent?.session?.header?.cwd
161
- if (typeof cwd !== 'string') throw new Error('session cwd unavailable')
162
- const observation = createResourceIdentity().resolve(path, { cwd })
163
- if (observation.kind !== 'file') throw new Error('confirm requires an existing regular file')
164
- const status = await domain.confirm(agent, observation.resourceId)
165
- return `Confirmed ${observation.resourceId}.\n${describe(status)}`
166
- }
167
- if (verb === 'release') {
168
- // Human release of one of this session's own files. It only removes
169
- // authority, so it stays available while stopped or recovering.
170
- const path = rest.join(' ')
171
- if (!path) throw new Error('Usage: /edit-lock release <path>')
172
- const cwd = agent?.session?.header?.cwd
173
- if (typeof cwd !== 'string') throw new Error('session cwd unavailable')
174
- const own = (await domain.status(agent)).locks.find((/** @type {any} */ lock) => lock.resourceId === path)
175
- const result = await domain.service.release({ agent }, { filePath: own ? own.resourceId : path, cwd })
176
- return `Released ${result.resourceId}.\n${describe(await domain.status(agent))}`
177
- }
178
- if (verb === 'locks') {
179
- const locks = await domain.locks(agent)
180
- return locks.length ? locks.map((/** @type {any} */ lock) => `- ${lock.resourceId} owner=${lock.owner} [${lock.status}${lock.reason ? `: ${lock.reason}` : ''}] generation ${lock.generation}`).join('\n') : 'No Edit Lock ownership is held.'
181
- }
182
- if (verb === 'unlock') {
183
- const generation = Number(rest.at(-1))
184
- const path = rest.slice(0, -1).join(' ')
185
- if (!path || !Number.isSafeInteger(generation)) throw new Error('Usage: /edit-lock unlock <path> <generation> (see /edit-lock locks)')
186
- // An exact listed resource id also works for files that no longer exist.
187
- let resourceId = (await domain.locks(agent)).find((/** @type {any} */ lock) => lock.resourceId === path)?.resourceId
188
- if (!resourceId) {
189
- const cwd = agent?.session?.header?.cwd
190
- if (typeof cwd !== 'string') throw new Error('session cwd unavailable')
191
- const observation = createResourceIdentity().resolve(path, { cwd })
192
- if (observation.kind !== 'file') throw new Error('unlock requires an existing regular file or an exact locked resource id')
193
- resourceId = observation.resourceId
194
- }
195
- try {
196
- const result = await domain.unlock(agent, resourceId, generation)
197
- return `Unlocked ${result.resourceId} (generation ${result.generation}, owner ${result.owner}). Content was not validated.`
198
- } catch (error) {
199
- // Actionable guidance (recovery UX D4): a fence refusal must name the
200
- // resolution path, never leave a bare error code. The guidance is
201
- // template-layer copy and stays English per the text discipline.
202
- if (/** @type {any} */ (error)?.admissionRefusal === true) {
203
- throw Object.assign(new Error(`${/** @type {any} */ (error).message}. Settle it with the administrative recovery in the Edit Lock maintenance panel (Settings → Edit Lock maintenance): it clears the unresolved publication fence and releases the owner's locks in one step.`), { admissionRefusal: true })
204
- }
205
- throw error
206
- }
207
- }
208
- throw new Error('Usage: /edit-lock [status|locks|hold [minutes]|release <path>|stop|resume|confirm <path>|confirm --all|unlock <path> <generation>]')
209
- }
210
-
211
- /** Ordinary owner tools. Ownership derives from exec.agent, never arguments.
212
- * @param {any} ctx @param {any} service */
213
- function registerLockTools(ctx, service) {
214
- const filePath = { file_path: { type: 'string', description: 'Existing file path, resolved against the session working directory.' } }
215
- /** @param {any} args @param {any} exec */
216
- const request = (args, exec) => {
217
- if (typeof args?.file_path !== 'string' || !args.file_path.trim()) throw new Error('file_path must be a non-empty string')
218
- const cwd = exec.agent?.session?.header?.cwd
219
- if (typeof cwd !== 'string') throw new Error('session working directory unavailable')
220
- return { filePath: args.file_path, cwd }
221
- }
222
- const text = (/** @type {string} */ value) => ({ schema: { type: 'object' }, render: (/** @type {any} */ _args, /** @type {any} */ result) => [{ type: 'text', text: result[value] }] })
223
- return [
224
- ctx.tools.register({
225
- name: 'edit_lock_acquire',
226
- description: 'Acquire Edit Lock ownership of one existing file before editing it. Edits also acquire implicitly; call this explicitly to reserve a file, and for every file listed as pending-confirmation after a resume. Fails if another session owns the file.',
227
- parameters: { type: 'object', properties: filePath, required: ['file_path'] },
228
- output: text('message'),
229
- async execute(/** @type {any} */ args, /** @type {any} */ exec) {
230
- const result = await service.acquire(exec, request(args, exec))
231
- return { ...result, message: `Acquired ${result.resourceId} (generation ${result.generation}).` }
232
- },
233
- }),
234
- ctx.tools.register({
235
- name: 'edit_lock_release',
236
- description: 'Release this session\'s Edit Lock ownership of one file so other sessions can edit it. Release when you are done editing a file. Releasing does not validate the file content.',
237
- parameters: { type: 'object', properties: filePath, required: ['file_path'] },
238
- output: text('message'),
239
- async execute(/** @type {any} */ args, /** @type {any} */ exec) {
240
- const result = await service.release(exec, request(args, exec))
241
- return { ...result, message: `Released ${result.resourceId}.` }
242
- },
243
- }),
244
- ctx.tools.register({
245
- name: 'edit_lock_hold',
246
- description: 'Ask to keep this session\'s Edit Lock ownership for a bounded time counted from now, when you still need the files after this turn ends. The maximum per request and the budget for the whole batch are configurable; once the budget is used up you can only release. Keeping a file never grants permission you did not already have, and no file can be kept permanently.',
247
- parameters: { type: 'object', properties: {
248
- minutes: { type: 'number', description: 'How long to keep the files, counted from now. Omit to use the configured default.' },
249
- }, required: [] },
250
- output: text('message'),
251
- async execute(/** @type {any} */ args, /** @type {any} */ exec) {
252
- const minutes = args?.minutes === undefined ? undefined : Number(args.minutes)
253
- if (minutes !== undefined && (!Number.isFinite(minutes) || minutes <= 0)) throw new Error('minutes must be a positive number')
254
- const result = await service.hold(exec, minutes === undefined ? undefined : Math.round(minutes * 60_000))
255
- return { ...result, message: `Keeping ${result.lockCount} file(s) until ${new Date(result.holdUntil).toISOString()}; ${Math.round(result.holdCumulativeMs / 60_000)} of ${result.cumulativeMaxMinutes} minutes used in this batch.` }
256
- },
257
- }),
258
- ctx.tools.register({
259
- name: 'edit_lock_try_steal',
260
- description: 'Ask the session that owns a file to hand Edit Lock ownership over. Returns a pending request_id immediately; it never waits for the holder. The holder decides at a safe point; no reply within the negotiation window keeps their ownership. You are notified of the outcome; check edit_lock_status.',
261
- parameters: { type: 'object', properties: filePath, required: ['file_path'] },
262
- output: text('message'),
263
- async execute(/** @type {any} */ args, /** @type {any} */ exec) {
264
- const result = await service.trySteal(exec, request(args, exec))
265
- return { ...result, message: `Request ${result.requestId} is pending with holder ${result.holder} (${result.holderStatus}). Do not edit the file until ownership is granted.` }
266
- },
267
- }),
268
- ctx.tools.register({
269
- name: 'edit_lock_pause',
270
- description: 'Only during Edit Lock cleanup-only recovery: ask to keep your abnormal locks while an external problem clears (for example a provider outage). Up to 15 minutes per pause and 30 minutes in total; expiry re-checks the situation, it never releases or extends the locks.',
271
- parameters: { type: 'object', properties: { minutes: { type: 'number', description: 'Pause length in minutes (at most 15).' } }, required: ['minutes'] },
272
- output: text('message'),
273
- async execute(/** @type {any} */ args, /** @type {any} */ exec) {
274
- const result = await service.pause(exec, Number(args?.minutes))
275
- return { ...result, message: `Paused for ${Math.round(result.pausedMs / 60_000)} minute(s); ${Math.round(result.cumulativePauseMs / 60_000)} of 30 cumulative minutes used.` }
276
- },
277
- }),
278
- ctx.tools.register({
279
- name: 'edit_lock_status',
280
- description: 'List Edit Lock ownership in this work directory: file, owner session, status and reservation. Read-only; grants nothing.',
281
- parameters: { type: 'object', properties: {}, required: [] },
282
- output: text('message'),
283
- async execute(/** @type {any} */ _args, /** @type {any} */ exec) {
284
- const unavailable = service.startupFailure(exec.agent)
285
- if (unavailable) return { ...unavailable, message: `Edit Lock unavailable: ${unavailable.reason}. ${statusRetryHint(unavailable.bindingFailure)}` }
286
- const locks = await service.locks(exec)
287
- const lines = locks.map((/** @type {any} */ lock) => `- ${lock.resourceId} owner=${lock.mine ? 'this session' : lock.owner} status=${lock.status} generation=${lock.generation}${lock.reason ? ` reason=${lock.reason}` : ''}`)
288
- // The reservation is part of what the holder needs to know: without it the
289
- // answer looks identical before and after a successful keep, which invites
290
- // asking again instead of carrying on with the work.
291
- const retention = await service.retention(exec)
292
- if (retention?.held && retention.remainingMs > 0) {
293
- lines.push(`Reserved for ${Math.ceil(retention.remainingMs / 60_000)} more minute(s); ${Math.round(retention.holdCumulativeMs / 60_000)} minute(s) used in this batch.`)
294
- }
295
- return { locks, retention, message: lines.length ? lines.join('\n') : 'No Edit Lock ownership is held.' }
296
- },
297
- }),
298
- ]
299
- }
300
-
301
- /** @param {any} service */
302
- function replyToolDefinition(service) {
303
- return {
304
- name: 'edit_lock_reply',
305
- description: 'Answer a pending Edit Lock ownership request addressed to this session. decision "release" hands the file to the requester (your ownership ends); "keep" retains it. Only the current holder execution can answer; stale or expired requests are refused.',
306
- parameters: { type: 'object', properties: {
307
- request_id: { type: 'string', description: 'request_id from the ownership request notice.' },
308
- decision: { type: 'string', enum: ['release', 'keep'], description: 'release to hand over, keep to retain.' },
309
- }, required: ['request_id', 'decision'] },
310
- output: { schema: { type: 'object' }, render: (/** @type {any} */ _args, /** @type {any} */ result) => [{ type: 'text', text: `Request ${result.requestId}: ${result.state}.` }] },
311
- async execute(/** @type {any} */ args, /** @type {any} */ exec) {
312
- if (typeof args?.request_id !== 'string') throw new Error('request_id must be a string')
313
- return service.reply(exec, { requestId: args.request_id, decision: args.decision })
314
- },
315
- }
316
- }
317
-
318
- /** @param {any} status */
319
- function describe(status) {
320
- const lines = [`Edit Lock session ${status.sessionId}: ${status.state}${status.interrupted ? ' (interrupted)' : ''}, epoch ${status.executionEpoch ?? '-'}`]
321
- if (status.revoked) lines.push('Editing authority was revoked by an administrative recovery and its locks were released. Continue editing or send any message to restore editing in a new execution epoch; the revocation record is preserved.')
322
- for (const lock of status.locks) lines.push(`- ${lock.resourceId} [${lock.status}${lock.reason ? `: ${lock.reason}` : ''}] generation ${lock.generation}`)
323
- if (status.locks.length === 0) lines.push('- no locks held')
324
- const recovery = status.recovery
325
- if (recovery && (recovery.attempts || recovery.elapsedMs || recovery.pauseMs)) {
326
- lines.push(`Recovery: ${recovery.attempts}/3 attempts, ${Math.round(recovery.elapsedMs / 1000)}s of 300s, pause ${Math.round(recovery.pauseMs / 60_000)}/30 min used`)
327
- }
328
- // Retention is reported as the one thing the user needs from it: how long the
329
- // files stay reserved. The raw budget belongs to the structured view, not here.
330
- const retention = status.retention
331
- if (retention?.held && retention.remainingMs > 0) {
332
- lines.push(`Reserved for ${Math.ceil(retention.remainingMs / 60_000)} more minute(s); ${Math.round(retention.holdCumulativeMs / 60_000)} minute(s) used in this batch.`)
333
- }
334
- return lines.join('\n')
335
- }
336
-
337
- // The binding-failure notice copy moved with the binding state machine to
338
- // binding-machine.js (change edit-lock-deepening, design D3) — it renders only
339
- // inside setupAgentOnce's failure record.
340
-
341
- /** Read-only binding-unavailable guidance tail for the tool/service surfaces
342
- * (domainFor and edit_lock_status), split by the recorded holder class.
343
- * @param {string} failure @param {any} bindingFailure */
344
- function bindingUnavailableMessage(failure, bindingFailure) {
345
- const kind = bindingFailure?.holderKind
346
- if (kind === 'transferable') return `Edit Lock binding is unavailable (${failure}). The holder is an inactive attachment of this same session: the binding transfers automatically when the user next sends a message, and /edit-lock retry also takes it over; neither resumes editing by itself. Do not retry the failed edit.`
347
- if (kind === 'serving') return `Edit Lock binding is unavailable (${failure}). This session is being edited in another attachment right now; retry the connection once that turn finishes. Do not retry the failed edit.`
348
- return `Edit Lock binding is unavailable (${failure}). 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.`
349
- }
350
-
351
- /** edit_lock_status read-only guidance tail, split by the recorded holder
352
- * class (change edit-lock-binding-transfer). @param {any} bindingFailure */
353
- function statusRetryHint(bindingFailure) {
354
- if (bindingFailure?.retryable !== true) return 'Inspect Edit Lock maintenance before continuing.'
355
- if (bindingFailure.holderKind === 'transferable') return 'The holder is an inactive attachment of this same session: editing attaches automatically when you next send a message, and /edit-lock retry also takes it over; neither resumes editing by itself.'
356
- if (bindingFailure.holderKind === 'serving') return 'This session is being edited in another attachment right now; /edit-lock retry retries the connection once that turn finishes — it does not resume editing by itself.'
357
- return 'Do not retry failed edits: the binding is retried automatically when the user next sends a message, and /edit-lock retry also retries the connection; neither resumes editing by itself.'
358
- }
359
-
360
- /** Composition dependencies, not persisted settings. Defaults are the host paths.
361
- * @param {{ platform?: string, resolveRoot?: typeof managementRootFor, endpoint?: typeof endpointFor, exclude?: typeof excludeFromGit }} [dependencies] */
362
- export function createEditLockPlugin({ platform = process.platform, resolveRoot = managementRootFor, endpoint = endpointFor, exclude = excludeFromGit } = {}) {
363
- /** Arrow on purpose: cordis constructs prototype-bearing callbacks with `new`
364
- * and then drops their returned disposer, which would leak the reservation.
365
- * @param {any} ctx @param {unknown} config */
366
- return (ctx, config = {}) => {
367
- const settings = ctx.get?.('weirSettings')
368
- const options = editLockOptions(config, settings?.get?.('editLock'))
369
- // A generation-owned reporter; a later mount cannot overwrite this row.
370
- const evidence = settings?.editLockEvidence?.recordMount?.({
371
- enabled: Boolean(options), pinned: Object.hasOwn(config ?? {}, 'enabled'),
372
- fixed: options?.fixed != null, phase: options ? 'installing' : 'installed',
373
- })
374
- if (!options) return evidence ? () => evidence.dispose() : undefined
375
- // Resource identity is the narrowest supported envelope (Darwin/Linux/Win32;
376
- // each has a local endpoint). Its factory performs no filesystem operations.
377
- // Keep the provider mounted on failure: absence would select unlocked editors.
378
- // This mount-time decision never follows a later saved-off setting.
379
- let platformFailure = null
380
- try { createResourceIdentity({ platform }) } catch (error) {
381
- platformFailure = Object.assign(new Error(`Edit Lock ${errorMessage(error)}; managed editing is unavailable. Supported platforms: darwin, linux, win32.`), { code: 'weir-edit-lock/unsupported-platform' })
382
- evidence?.failed(platformFailure)
383
- ctx.logger?.warn?.(platformFailure.message)
384
- }
385
- /** The shared-registry handle, assigned inside the mount try so a failed
386
- * apply can release its count (design D1: a failed mount enters no count).
387
- * @type {any} */
388
- let shared = null
389
- try {
390
- /** @type {any} */
391
- let sandboxPolicyRef = null
392
- ctx.inject?.(['sandboxPolicy'], (/** @type {any} */ scope) => { sandboxPolicyRef = scope.sandboxPolicy })
393
- // Capture the original host filesystem once; the manager is its only writer.
394
- const fs = ctx.fs
395
- const audit = createAudit(ctx)
396
- // The host-lifetime key shared by the write-scope store, the domain registry
397
- // container and the live-service set (design D1/D3) — identical to the key
398
- // expression the settings evidence store uses.
399
- const host = ctx.root ?? ctx
400
- const liveServices = liveServicesFor(host)
401
- /** Successful undefined means no live runtime; missing service means unknown.
402
- * @param {string} sessionId */
403
- const servingRuntime = sessionId => {
404
- const agents = ctx.get?.('agents')
405
- if (typeof agents?.get !== 'function') throw new Error('agent registry unavailable')
406
- return agents.get(sessionId)
407
- }
408
- const attestBinding = createBindingAttester(servingRuntime)
409
- /** Detached stale-lock sweeps, coalesced per management-domain ROOT with a
410
- * single in-flight sweep and a 60s cooldown (see stale-sweep.js). */
411
- const sweeps = createStaleSweepScheduler({
412
- warn: message => ctx.logger?.warn?.(message),
413
- setTimer: fn => (safeSetTimer(ctx) ?? globalThis.setTimeout)(fn, 0),
414
- })
415
- // The binding state machine's stores (disposedAgents, startFailures,
416
- // bindingFailures, setupInFlight, boundAgents, dispositions,
417
- // agentDispositions) live in binding-machine.js and arrive destructured
418
- // below (change edit-lock-deepening, design D3).
419
- /** One queued automatic-recovery summary notice per management root (design
420
- * D3): delivered to the first agent that finishes domain start, then gone.
421
- * Shared across sibling mounts with the domain registry (change
422
- * edit-lock-shared-domain-mounts): the domain opens through the first
423
- * mount's closure, so the queue must live in the shared entry for the
424
- * mount that hears the first post-recovery turn to drain it. Assigned
425
- * with `shared` below. @type {Map<string, string>} */
426
- let recoveryNotices
427
- /** Managed write registrations outlive this mount: the tool is installed
428
- * into the agent's OWN scope layer, which a preset rebind never disposes.
429
- * Their disposers live in a host-lifetime store so any later generation can
430
- * retire the stale layer before re-installing (design D3); entries are
431
- * released at agent/disposed. Unmount deliberately leaves them in place —
432
- * tearing the managed write down mid-gap would expose the stock writers
433
- * until the next mount's per-agent re-install.
434
- * @type {WeakMap<object, import('./write-scopes.js').WriteScopeEntry>} */
435
- const writeScopes = writeScopesFor(host)
436
- let closed = false
437
- /** Remote channel agents on the publisher side → their notice sink.
438
- * @type {WeakMap<object, (event: unknown) => void>} */
439
- const sinks = new WeakMap()
440
- /** Settled domain per bound agent, for synchronous gates. Shared across
441
- * sibling mounts with the domain registry (design D3): whichever mount
442
- * hears an event reads the same binding. Assigned with `shared` below.
443
- * @type {WeakMap<object, any>} */
444
- let settled
445
- /** `wake` is used only for an ownership request addressed to an idle holder:
446
- * inject alone would sit unread until the holder's next user message, which is
447
- * how a request expired unanswered. Interrupted holders are never woken.
448
- * @param {any} agent @param {string} text @param {boolean} [wake] */
449
- const deliverLocal = (agent, text, wake = false) => {
450
- const message = userTextMessage(text, 'weir-edit-lock')
451
- if (wake && agent?.status === 'idle' && agent?.followup) agent.followup(message)
452
- else agent.inject(message)
453
- }
454
- /** @param {any} agent @param {any} event */
455
- function deliverEvent(agent, event) {
456
- if (event?.kind === 'notice' && typeof event.text === 'string') deliverLocal(agent, event.text, event.wake === true)
457
- if (event?.kind === 'pending' && Number.isSafeInteger(event.count)) syncReplyTool(agent, event.count)
458
- }
459
-
460
- /** @param {string} root @param {string} directory
461
- * @param {string} [noticeKey] registry key agents are bound to (differs from
462
- * `root` in fixed-domain compositions: the registry key is the resolved
463
- * management root of the session cwd) */
464
- async function openDomain(root, directory, noticeKey = root) {
465
- try {
466
- if (!existsSync(directory)) mkdirSync(directory, { recursive: true, mode: 0o700 })
467
- let runtime
468
- try {
469
- // The store mode is read only after the reservation is held.
470
- runtime = await openReservedEditLockRuntime({ root, directory, domainId: root, mode: () => storeMode(directory), fs })
471
- // Dead-process unknowns settled in the recovery commit (design D3):
472
- // one shared audit per recovery, and at most one summary notice
473
- // queued for the first agent that starts a turn in this domain.
474
- const settledRecovery = runtime.automaticRecovery
475
- if (settledRecovery?.records?.length) {
476
- const settledOperations = settledRecovery.records.reduce((count, record) => count + record.operations.length, 0)
477
- audit(null, AUDIT_TYPES.editLockMaintenance, {
478
- kind: 'automatic-recovery', root, revision: settledRecovery.revision,
479
- owners: settledRecovery.records.map(record => record.owner),
480
- recoveryIds: settledRecovery.records.map(record => record.recoveryId),
481
- operations: settledOperations,
482
- }, { root })
483
- recoveryNotices.set(noticeKey, `Edit Lock: ${settledOperations} unresolved publication(s) left behind by a previous, crashed process were settled automatically while opening this workspace. Their outcomes stay unknown in history; the blocked files are editable again. Details are in the administrative ledger and the maintenance audit.`)
484
- }
485
- } catch (error) {
486
- if (/** @type {any} */ (error)?.code !== 'EEXIST') throw error
487
- // Another cooperating host publishes: become its client, never a writer.
488
- const domain = remoteDomain(createRemoteEditLockDomain(endpoint(directory), {
489
- onNotice: deliverEvent,
490
- onChannelOpen: registerBindingRuntime,
491
- // The only way to reach here without a live publisher is a reservation left
492
- // by a host that crashed or was killed. Say so, and say what clears it.
493
- unreachableHint: `Another DeepSeek Harness reserved this project but is not answering. If no other Harness window is open on it, quit DeepSeek Harness, remove ${reservationPathFor(directory)}, and start it again.`,
494
- }))
495
- try { evidence?.recordDomain?.(root, { mode: domain.mode }) } catch { /* reporting only */ }
496
- return domain
497
- }
498
- const lifecycle = createEditLockLifecycle(runtime, sessionOf, {
499
- servingRuntime,
500
- // Bounded failure logging for the sweep: one warning per failed row
501
- // (skips stay quiet), routed to the host logger.
502
- warn: message => ctx.logger?.warn?.(message),
503
- deliver: (agent, text, wake) => { const sink = sinks.get(agent); if (sink) sink({ kind: 'notice', text, wake: wake === true }); else deliverLocal(agent, text, wake) },
504
- onPending: (agent, count) => { const sink = sinks.get(agent); if (sink) sink({ kind: 'pending', count }); else syncReplyTool(agent, count) },
505
- // Stale-lock sweep audit: publisher-side, after each durable release;
506
- // the JSONL mirror anchors at the management root. Never a session
507
- // event, never a conversation write (AGENTS §3.6).
508
- onStaleRelease: (row, triggerSessionId) => audit(null, AUDIT_TYPES.editLockMaintenance, {
509
- kind: 'stale-sweep', root, trigger: triggerSessionId ?? null, owner: row.owner, resourceId: row.resourceId, generation: row.generation,
510
- }, { root }),
511
- // Online administrative recovery audit (design D4): publisher-side,
512
- // after the durable commit; the trigger session comes from the
513
- // channel/local settings plane, never from the request payload.
514
- onAdminRecovery: info => audit(null, AUDIT_TYPES.editLockMaintenance, {
515
- kind: 'admin-override-online', root, trigger: info.trigger ?? null, owner: info.record.owner,
516
- recoveryId: info.record.recoveryId, revision: info.revision, idempotent: info.idempotent,
517
- operations: info.record.operations.length,
518
- }, { root }),
519
- // Revocation repair audit: publisher-side, after the durable resume
520
- // commit advanced the owner's epoch past its revokedEpoch. The ledger
521
- // record itself never changes; the repair is a new-epoch event.
522
- onRevocationRepair: info => audit(null, AUDIT_TYPES.editLockMaintenance, {
523
- kind: 'revocation-repaired', root, owner: info.owner, trigger: info.trigger,
524
- fromEpoch: info.fromEpoch ?? null, toEpoch: info.toEpoch,
525
- }, { root })
526
- })
527
- let server
528
- try { server = await serveEditLockEndpoint(lifecycle, endpoint(directory), sinks, attestBinding,
529
- (agent, requireProof, transfer = false) => {
530
- if (closed || disposedAgents.has(agent)) return false
531
- const agents = ctx.get?.('agents')
532
- if (typeof agents?.get === 'function') {
533
- if (transfer === true) {
534
- // A transfer claimant is NEVER the registry's serving agent —
535
- // that mismatch is the defect being repaired (change
536
- // edit-lock-binding-transfer). Its liveness proof is listing
537
- // among the live root agents; holder-side admission is the
538
- // lifecycle's transferStart.
539
- if (typeof agents.roots !== 'function' || !agents.roots().includes(agent)) return false
540
- } else if (agents.get(agent.id) !== agent) return false
541
- } else if (requireProof) return false
542
- // Fresh creation keeps the established missing/throwing-registry
543
- // fallback. A reclaim or retry never uses that fallback as proof.
544
- // Currency proof (design D3): the agent's resolved service must be a
545
- // LIVE service of this process — after a preset switch that is the
546
- // sibling mount's service, never this mount's own necessarily.
547
- if (typeof presets?.serviceFor === 'function') {
548
- try { return liveServices.has(presets.serviceFor(agent, 'weirEditLock')) }
549
- catch { return !requireProof }
550
- }
551
- return true
552
- }) }
553
- catch (error) { ctx.logger?.warn?.(`edit lock endpoint unavailable; other hosts fail closed: ${/** @type {any} */ (error)?.message ?? error}`) }
554
- const domain = localDomain(lifecycle, server)
555
- try { evidence?.recordDomain?.(root, { mode: domain.mode, bindings: () => [...lifecycle.bindings(), ...(server?.bindings() ?? [])] }) } catch { /* reporting only */ }
556
- return domain
557
- } catch (error) {
558
- // A domain that never opened is maintenance-visible as initialization
559
- // blocked; the manager behavior is unchanged.
560
- try { evidence?.recordDomain?.(root, { error: String(/** @type {any} */ (error)?.message ?? error) }) } catch { /* reporting only */ }
561
- throw error
562
- }
563
- }
564
- // One domain registry per host + effective domain configuration, shared by
565
- // every same-process preset mount of this bundle (design D1): a sibling
566
- // mount never degrades to a same-process remote client, and the LAST
567
- // disposer closes all domains through the established drain discipline. The
568
- // first mount's `open` closure serves every sharing mount — mounts are
569
- // process-persistent, so the capture is safe (design D1 trade-off).
570
- shared = sharedDomainRegistryFor(host, options.fixed ? `${options.fixed.root}\n${options.fixed.directory}` : '', () => createDomainRegistry(root => {
571
- const fixed = options.fixed
572
- if (fixed) return openDomain(fixed.root, fixed.directory, root)
573
- try { exclude(root) } catch (error) { ctx.logger?.warn?.(`edit lock: could not update .git/info/exclude: ${/** @type {any} */ (error)?.message ?? error}`) }
574
- return openDomain(root, join(root, AUTHORITY_DIR), root)
575
- }, resolveRoot))
576
- const registry = shared.registry
577
- settled = shared.settled
578
- recoveryNotices = shared.recoveryNotices
579
- /** @param {any} agent */
580
- const domainOf = agent => registry.forAgent(agent)
581
- /** Lazy re-bind (design D2): an own-preset main agent with no binding —
582
- * orphaned by a preset switch in the blank window — gets ONE idempotent
583
- * re-setup through the standard path before the call is refused. A recorded
584
- * failure suppresses retries for the rest of this mount generation, and
585
- * foreign-preset agents are never tried (the own-preset fence is unchanged).
586
- * `ownAgent`/`setupAgent` are declared below and only dereferenced when a
587
- * call actually arrives. @param {any} agent */
588
- async function domainFor(agent) {
589
- if (platformFailure) throw platformFailure
590
- if (!registry.rootOf(agent) && !closed && !startFailures.has(agent) && (agent?.session?.header?.delegationDepth ?? 0) === 0 && ownAgent(agent)) await setupAgent(agent)
591
- await setupInFlight.get(agent)
592
- if (settled.has(agent)) return settled.get(agent)
593
- const failure = startFailures.get(agent)
594
- if (failure && bindingFailures.has(agent)) throw new Error(bindingUnavailableMessage(failure, bindingFailures.get(agent)))
595
- if (failure) throw new Error(`this session has no Edit Lock domain: setup failed under the current preset generation (${failure}); the automatic re-bind was already attempted. Recovery: restart DeepSeek Harness or start a new conversation.`)
596
- // No fence match (foreign preset) or unmount: keep the stock refusal.
597
- return registry.forAgent(agent)
598
- }
599
-
600
- /** The holder's structured reply tool. It is registered on the first request
601
- * and retired at a turn boundary once no request is pending: unregistering it
602
- * mid-turn made an expired-but-retried reply report "unknown tool" instead of
603
- * the real reason.
604
- * @type {WeakMap<object, {dispose: () => void, stale: boolean}>} */
605
- const replyTools = new WeakMap()
606
- /** @param {any} agent @param {number} pending */
607
- function syncReplyTool(agent, pending) {
608
- const registered = replyTools.get(agent)
609
- if (pending > 0 && !registered && agent?.ctx?.tools?.register) {
610
- replyTools.set(agent, { dispose: agent.ctx.tools.register(replyToolDefinition(service)), stale: false })
611
- } else if (registered) {
612
- registered.stale = pending === 0
613
- }
614
- }
615
- /** @param {any} agent */
616
- function retireReplyTool(agent) {
617
- const registered = replyTools.get(agent)
618
- if (!registered?.stale) return
619
- replyTools.delete(agent)
620
- registered.dispose()
621
- }
622
- /** @param {string} method */
623
- const route = method => async (/** @type {any} */ exec, /** @type {any} */ request) => /** @type {any} */ ((await domainFor(exec.agent)).service)[method](exec, request)
624
- const recovery = createRecoveryDriver({
625
- domainFor: agent => settled.get(agent),
626
- followup: (agent, text) => { if (!disposedAgents.has(agent)) /** @type {any} */ (agent).followup(userTextMessage(text, 'weir-edit-lock-recovery')) },
627
- notify: deliverLocal,
628
- warn: message => ctx.logger?.warn?.(message),
629
- })
630
- /** Retention policy is resolved once per read from the settings section, so a
631
- * committed change takes effect without a restart and the kernel stays free of
632
- * configuration. @returns {any} */
633
- const strictLimits = () => editLockLimits(ctx.get?.('weirSettings')?.get?.('editLock'))
634
- /** An incoherent saved combination (each field is validated on its own, so e.g.
635
- * a single cap below the default can be saved) must never switch off settling,
636
- * status, release, stop or unlock. Those read the built-in defaults instead and
637
- * warn; only a retention request is refused, with the named reason, because
638
- * that is the one decision the bad setting is about. */
639
- let warnedLimits = ''
640
- const limits = () => {
641
- try { return strictLimits() } catch (error) {
642
- const message = String(/** @type {any} */ (error)?.message ?? error)
643
- if (warnedLimits !== message) { warnedLimits = message; ctx.logger?.warn?.(`edit lock settings ignored, using defaults: ${message}`) }
644
- // Fall back for the retention fields only. The notice count and the
645
- // disposition keep their saved values: a bad cap must never turn a team's
646
- // "flag for a human" choice into a silent release.
647
- const section = /** @type {any} */ (ctx.get?.('weirSettings')?.get?.('editLock')) ?? {}
648
- let nudge
649
- try { nudge = editLockLimits({ nudgeAttempts: section.nudgeAttempts, nudgeFallback: section.nudgeFallback }) } catch { nudge = editLockLimits(undefined) }
650
- return { ...editLockLimits(undefined), nudgeAttempts: nudge.nudgeAttempts, nudgeFallback: nudge.nudgeFallback, holdUnavailable: true }
651
- }
652
- }
653
- /** Turn-end settling: a finished turn that left locks behind is continued once,
654
- * asking for each file to be released or kept explicitly (design D2/D3). Only
655
- * `completed` enters here; the error and stop paths keep their own handling. */
656
- const settle = createSettlementDriver({
657
- domainFor: agent => settled.get(agent),
658
- followup: (agent, text) => { if (!disposedAgents.has(agent)) /** @type {any} */ (agent).followup(userTextMessage(text, 'weir-edit-lock-settle')) },
659
- notify: deliverLocal,
660
- limits,
661
- warn: message => ctx.logger?.warn?.(message),
662
- })
663
- /** Expiry timers, keyed per agent. Read-time settlement is authoritative, so a
664
- * lost timer (suspend, sleep) only delays the release, never the decision.
665
- * @type {WeakMap<object, {timer?: any}>} */
666
- const expiry = new WeakMap()
667
- /** A genuine user message asked for the stopped session's automatic resume.
668
- * Boolean, consumed once by the next turn's first pre-step (message-driven
669
- * auto-resume D1/D6); WeakMap keys die with their agent. @type {WeakMap<object, boolean>} */
670
- const pendingAutoResume = new WeakMap()
671
- const delegateResumes = new Map()
672
- const turnClassifications = new WeakMap()
673
- // The per-agent binding state machine (change edit-lock-deepening, design
674
- // D3): setup/retry/dispose/startup-diagnostic plus the binding stores,
675
- // shared by every entry point below. The remaining wiring reads the same
676
- // container objects through these destructured aliases.
677
- const {
678
- disposedAgents, startFailures, bindingFailures, setupInFlight,
679
- boundAgents, dispositions, agentDispositions,
680
- setupAgent, retryBinding, disposeAgent, startupFailure,
681
- } = createBindingMachine({
682
- ctx, sessionOf, GUARDED_TOOLS, options, registry, settled, writeScopes,
683
- domainOf, servingRuntime,
684
- // Late-bound wiring (ownAgent and the service object are declared below;
685
- // the machine dereferences them only when an entry point actually calls).
686
- ownAgent: (/** @type {any} */ agent) => ownAgent(agent),
687
- installWriteScope: (/** @type {any} */ agent) => ({ owner: service, dispose: installEditLockWriteScope(agent, ctx, service, sandboxPolicyRef) }),
688
- evidence, audit, deliverLocal,
689
- pendingAutoResume, delegateResumes, turnClassifications, replyTools, expiry, settle,
690
- isClosed: () => closed, platformFailure: () => platformFailure,
691
- })
692
- /** @param {any} agent @param {any} domain */
693
- function armExpiry(agent, domain) {
694
- const state = expiry.get(agent) ?? {}
695
- expiry.set(agent, state)
696
- if (state.timer) clearTimeout(state.timer)
697
- state.timer = undefined
698
- // Awaited: a cross-process client answers retention asynchronously, and a
699
- // synchronous read there never armed the timer at all.
700
- void Promise.resolve(domain.retention?.(agent)).then(retention => {
701
- if (!retention?.held || !(retention.remainingMs > 0)) return
702
- // Read the host timer through a guarded probe, not a bare member access: an
703
- // unavailable cordis service throws on access rather than returning undefined.
704
- const setTimer = safeSetTimer(ctx) ?? globalThis.setTimeout
705
- state.timer = setTimer(() => {
706
- state.timer = undefined
707
- // Busy at expiry: the turn-end handler releases it once that turn ends.
708
- void Promise.resolve(domain.settleExpired(agent)).then(result => {
709
- if (!result?.released?.length) return
710
- settle.settled(agent)
711
- deliverLocal(agent, `Edit Lock: the reservation ended, so ${result.released.length} file(s) are available to other sessions.`)
712
- }).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock expiry release failed: ${error?.message ?? error}`))
713
- }, Math.min(retention.remainingMs, 2 ** 31 - 1))
714
- state.timer?.unref?.()
715
- }).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock expiry arm failed: ${error?.message ?? error}`))
716
- }
717
- const service = Object.freeze({
718
- startupFailure,
719
- publish: route('publish'),
720
- publishBatch: route('publishBatch'),
721
- acquire: route('acquire'),
722
- release: route('release'),
723
- /** @param {any} exec */
724
- async locks(exec) { return (await domainFor(exec.agent)).service.locks(exec) },
725
- trySteal: route('trySteal'),
726
- reply: route('reply'),
727
- /** @param {any} exec @param {number} minutes */
728
- pause(exec, minutes) { return recovery.pause(exec.agent, minutes) },
729
- /** Ask to keep this session's locks after the turn ends, for a bounded time.
730
- * Retention only extends ownership; it grants nothing new (spec: Explicit
731
- * bounded retention). @param {any} exec @param {number} ms */
732
- async hold(exec, ms) {
733
- const policy = strictLimits()
734
- if (ms === undefined) ms = Math.round(policy.holdDefaultMinutes * 60_000)
735
- const result = await (await domainFor(exec.agent)).hold(exec.agent, ms, {
736
- singleMaxMs: policy.holdSingleMaxMinutes * 60_000,
737
- cumulativeMaxMs: policy.holdCumulativeMaxMinutes * 60_000,
738
- })
739
- armExpiry(exec.agent, settled.get(exec.agent))
740
- return { ...result, cumulativeMaxMinutes: policy.holdCumulativeMaxMinutes }
741
- },
742
- /** Retention state for this session, settled against now. Resolved through the
743
- * domain registry — the same path the lock list takes — rather than through the
744
- * per-agent binding, so a read-only observation works from any tool context.
745
- * @param {any} exec */
746
- async retention(exec) {
747
- const domain = await domainFor(exec.agent)
748
- return domain.retention(exec.agent)
749
- },
750
- /** Mount-time handshake from a managed editor. @param {object} definition */
751
- claim(definition) {
752
- const execute = /** @type {any} */ (definition)?.execute
753
- if (typeof execute === 'function') managed.add(execute)
754
- },
755
- /** Continuation gate: an interrupted or recovering session is never
756
- * re-armed implicitly. Unknown/unavailable state also blocks. @param {any} agent */
757
- blocksContinuation(agent) {
758
- const domain = settled.get(agent)
759
- return !domain || domain.blocks(agent)
760
- },
761
- /** Trusted UI observation for one agent; grants nothing. @param {any} agent */
762
- async describe(agent) {
763
- const failure = startupFailure(agent)
764
- if (failure) throw Object.assign(new Error(failure.reason), { bindingFailure: failure.bindingFailure })
765
- const domain = await domainFor(agent)
766
- return { root: registry.rootOf(agent), mode: domain.mode, status: await domain.status(agent), locks: await domain.locks(agent), recovery: recovery.state(agent) }
767
- },
768
- })
769
- ctx.reflect.provide('weirEditLock', service)
770
- // Any live sibling mount's service identity attests endpoint currency
771
- // (design D3); unregister at dispose.
772
- liveServices.add(service)
773
-
774
- const offTools = registerLockTools(ctx, service)
775
-
776
- ctx.on('tools/pre-execute', async (/** @type {any} */ exec, /** @type {() => Promise<any>} */ next) => {
777
- if (!GUARDED_TOOLS.includes(exec?.name)) return next()
778
- if (platformFailure) return { kind: 'deny', reason: platformFailure.message, info: { name: 'EditLockPlatformError', code: platformFailure.code } }
779
- const admitted = () => {
780
- const definition = ctx.tools.get(exec.name, exec.agent)
781
- return typeof definition?.execute === 'function' && managed.has(definition.execute)
782
- }
783
- if (admitted()) return next()
784
- // Lazy re-bind fallback (design D2): an own-preset agent whose binding was
785
- // orphaned by a blank-window preset switch gets ONE idempotent re-setup
786
- // before denial. A recorded failure suppresses further retries for the
787
- // rest of this mount generation; foreign agents are never tried.
788
- const agent = exec?.agent
789
- if (!closed && agent && !registry.rootOf(agent) && !startFailures.has(agent) && (agent?.session?.header?.delegationDepth ?? 0) === 0 && ownAgent(agent)) {
790
- await setupAgent(agent)
791
- // The lazy re-bind is a driving ingress (design D1, change
792
- // edit-lock-binding-transfer): when the re-setup just recorded a
793
- // duplicate-binding failure, ONE transfer-capable retry runs here — an
794
- // idle same-process holder hands the binding over mid-turn. The recorded
795
- // failure then suppresses further attempts for the rest of this mount
796
- // generation, so arbitrary write calls never loop retries.
797
- if (bindingFailures.get(agent)?.code === 'binding-duplicate') {
798
- try { await retryBinding(agent, 'write-guard') } catch { /* audited inside retryBinding; the denial below names the recorded cause */ }
799
- }
800
- if (admitted()) return next()
801
- }
802
- const failure = /** @type {any} */ (agent) ? startFailures.get(agent) : undefined
803
- if (failure) return { kind: 'deny', reason: `${exec.name} refused: this session has no Edit Lock domain under the current preset generation (setup failed: ${failure}); an automatic re-bind was attempted and did not recover it. Recovery: restart DeepSeek Harness or start a new conversation.` }
804
- return { kind: 'deny', reason: `${exec.name} is not routed through Edit Lock in this composition; unmanaged file mutation refused.` }
805
- })
806
- // The binding stores (boundAgents, dispositions, agentDispositions) and
807
- // dispositionFor moved into binding-machine.js; delegateResumes and
808
- // turnClassifications stay composition-owned and are declared above with it.
809
- // setupAgentOnce / setupAgent / retryBinding moved into binding-machine.js
810
- // (change edit-lock-deepening, design D3) — they arrive destructured above.
811
- ctx.on('agent/created', (/** @type {any} */ { agent }) => setupAgent(agent))
812
-
813
- // Late-mount catch-up (the same mount-order hazard the worktree-mode guard
814
- // documents): after an application restart the preset mounts FOR the first
815
- // session's main agent, so that agent's agent/created event is already past
816
- // and the listener above never sees it. Bind every existing root agent now,
817
- // through the identical setup; sub-agents are always created after mount and
818
- // need no catch-up. Failures are recorded and surfaced exactly like
819
- // creation-time ones, because it is the same code path.
820
- //
821
- // Cross-preset fence: the creation listener only ever hears agents whose
822
- // event bubbles through THIS preset mount, but agents.roots() is a
823
- // process-wide registry — a foreign agent bound here would get its edit
824
- // tools denied (and a denial against a foreign tool catalog used to escape
825
- // as a host-fatal error). Identity-compare the weirEditLock instance the
826
- // agent's own retained preset mount resolves: only an agent of THIS mount
827
- // sees this exact service object. Without a preset registry (host-level
828
- // compositions, e.g. the integration harness) every root is eligible.
829
- const presets = /** @type {any} */ (ctx.get?.('agentPresets'))
830
- const ownAgent = typeof presets?.serviceFor === 'function'
831
- ? (/** @type {any} */ agent) => {
832
- try { return presets.serviceFor(agent, 'weirEditLock') === service } catch { return false }
833
- }
834
- : () => true
835
- const offDelegateLifecycle = registerDelegateEditLifecycle({
836
- owns: ownAgent,
837
- async resume(childId) {
838
- const requestId = `supervised-resume:${crypto.randomUUID()}`
839
- if (delegateResumes.has(childId)) throw new Error('supervised resume already in progress')
840
- const resumed = new Map()
841
- const pending = new Set()
842
- let failure
843
- const intent = {
844
- resume(agent, domain) {
845
- const task = (async () => {
846
- if (!domain.blocks(agent)) return
847
- await domain.resume(agent, requestId)
848
- resumed.set(agent, domain)
849
- })()
850
- pending.add(task)
851
- return task.catch(error => { failure = error; throw error })
852
- },
853
- }
854
- delegateResumes.set(childId, intent)
855
- const finish = async delivered => {
856
- delegateResumes.delete(childId)
857
- await Promise.allSettled(pending)
858
- // Failed delivery must not leave authority armed, including a child
859
- // activated inside sendMessage. Stop uses the existing epoch fence.
860
- if (!delivered || failure) {
861
- await Promise.all([...resumed].map(([agent, domain]) => domain.stop(agent)))
862
- }
863
- if (failure) throw failure
864
- }
865
- try {
866
- const agent = boundAgents.get(childId)
867
- const domain = agent && settled.get(agent)
868
- if (domain) await intent.resume(agent, domain)
869
- } catch (error) { await finish(false); throw error }
870
- return finish
871
- },
872
- async settle(childId, agent) {
873
- const record = agent ? agentDispositions.get(agent) : dispositions.get(childId)
874
- await record?.promise
875
- },
876
- })
877
- const endedRuns = new Set()
878
- ctx.on('subagent/end', ({ id, runId, stopReason }) => {
879
- // id is the exact child SessionId, not runId. Only published terminal
880
- // outcomes enter here; ordinary continuable turn ends do not dispose.
881
- if (!['completed', 'aborted', 'error', 'max-tokens', 'refusal'].includes(stopReason)) return
882
- if (runId && endedRuns.has(runId)) return
883
- if (runId) endedRuns.add(runId)
884
- const agent = boundAgents.get(id)
885
- if (agent) return disposeAgent(agent).catch(error => ctx.logger?.warn?.(`edit lock delegate settlement failed: ${error?.message ?? error}`))
886
- })
887
- try {
888
- for (const agent of ctx.get?.('agents')?.roots?.() ?? []) {
889
- if ((agent?.session?.header?.delegationDepth ?? 0) !== 0) continue
890
- if (!ownAgent(agent)) continue
891
- void setupAgent(agent)
892
- }
893
- } catch {
894
- // No agent registry in this composition: only the creation path binds.
895
- }
896
-
897
- // Preset-switch re-binding (design D1): select → recompose rebinds the
898
- // agent's scope chain to the new generation WITHOUT re-emitting
899
- // agent/created; the preset registry re-emits this session event unscoped
900
- // (dsh-agent-preset-registry: ctx.emit('agent-preset/selected', session.id,
901
- // agentPreset)) AFTER the rebind has landed, so a re-setup here binds under
902
- // THIS generation. The listener must return undefined (serial-bail
903
- // discipline). A switch to a foreign preset fails the ownAgent fence and
904
- // stays hard-refused, exactly as before; a setup failure lands in the
905
- // fourfold record and the guard's lazy path does not retry it this
906
- // generation.
907
- ctx.on('agent-preset/selected', (/** @type {any} */ sessionId) => {
908
- if (closed) return
909
- const agent = typeof sessionId === 'string' ? ctx.get?.('agents')?.get?.(sessionId) : undefined
910
- if (!agent || !ownAgent(agent)) return
911
- void setupAgent(agent)
912
- })
913
-
914
- // Stock Stop aborts the active turn signal synchronously: close admission at
915
- // that instant. Idle Stop has no signal; /edit-lock stop is the awaitable path.
916
- /** Turn identity seen last per agent, so a new turn is detected exactly once.
917
- * @type {WeakMap<object, unknown>} */
918
- const lastTurn = new WeakMap()
919
- // pendingAutoResume moved next to the binding machine (its retry path clears
920
- // an armed auto-resume); it is declared above with it.
921
- /** A genuine user message also arms ONE bounded binding retry when this agent
922
- * has a recorded duplicate-binding failure (message-driven self-heal, design
923
- * D5): consumed at the next turn's first pre-step by awaiting the existing
924
- * trusted retryBinding — same attestation guards, same in-flight join,
925
- * fail-closed while a holder serves. @type {WeakMap<object, boolean>} */
926
- const pendingBindingRetry = new WeakMap()
927
- /** @type {WeakSet<AbortSignal>} */
928
- const watched = new WeakSet()
929
- ctx.on('agent/pre-step', async (/** @type {any} */ event, /** @type {() => Promise<any>} */ next) => {
930
- const { agent, signal, turn } = event ?? {}
931
- // `pre-step` fires once per STEP, not per turn, so "a new turn started" has to
932
- // be detected on turn identity. Doing it on every step would clear the
933
- // reservation a holder just asked for, one step later. The detection runs
934
- // even without a settled domain: a message-armed binding retry (D5) exists
935
- // exactly while no domain is settled and must precede the turn's first step.
936
- if (agent && turn && lastTurn.get(agent) !== turn) {
937
- lastTurn.set(agent, turn)
938
- if (pendingBindingRetry.get(agent) === true) {
939
- pendingBindingRetry.delete(agent)
940
- try {
941
- await retryBinding(agent, 'message')
942
- // The genuine message that armed this retry is also this turn's
943
- // Continue receipt: re-arm what setupAgentOnce(retry) cleared, then
944
- // fall through to the ordinary auto-resume consumption below. A
945
- // transfer success (registry named an idle same-process holder)
946
- // lands here identically: bound interrupted, then resumed in-turn.
947
- pendingAutoResume.set(agent, true)
948
- // The outcome audit (transferred / refused) was written inside
949
- // retryBinding — success AND refusal alike; the catch below is not
950
- // a silent swallow any more (design D3).
951
- } catch { /* a safe refusal keeps the recorded cause; the turn proceeds with edits denied */ }
952
- }
953
- const domain = settled.get(agent)
954
- if (domain) {
955
- // A queued automatic-recovery summary (design D3) reaches exactly one
956
- // agent as an ordinary mid-turn notice — the same proven delivery point
957
- // as the settling and expiry notices.
958
- const recoveryNotice = recoveryNotices.get(registry.rootOf(agent) ?? '')
959
- if (recoveryNotice) {
960
- recoveryNotices.delete(registry.rootOf(agent) ?? '')
961
- try { deliverLocal(agent, recoveryNotice) } catch { /* the audit record is the durable account */ }
962
- }
963
- // A genuine user message arrived while this session was stopped: resume
964
- // it and confirm its retained files BEFORE the turn's first step, so the
965
- // edits the turn is about to request are admitted (message-driven
966
- // auto-resume D1). A failure only warns and the turn proceeds denied —
967
- // the panel keeps showing the stopped state (D6).
968
- if (pendingAutoResume.get(agent) === true) {
969
- pendingAutoResume.delete(agent)
970
- if (domain.blocks(agent)) await autoResume(domain, agent, message => ctx.logger?.warn?.(message))
971
- }
972
- // The holder is working again: every lock it holds leaves the holding state at
973
- // once, without waiting for the reservation to run out (design D4), and the
974
- // notice budget for this batch restarts.
975
- settle.turnStarted(agent)
976
- void Promise.resolve(domain.turnStarted(agent)).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock turn start failed: ${error?.message ?? error}`))
977
- }
978
- }
979
- const domain = agent && settled.get(agent)
980
- if (domain && signal && !watched.has(signal)) {
981
- watched.add(signal)
982
- const latch = () => { if (abortedByUser(signal)) domain.latch(agent) }
983
- if (signal.aborted) latch()
984
- else signal.addEventListener('abort', latch, { once: true })
985
- }
986
- return next()
987
- })
988
-
989
- // Message-driven auto-resume, phase 1 (D1): a genuine user message to the
990
- // session arms the flag synchronously; runtime-injected (producer-tagged)
991
- // messages never do. The arming hook is the inbox intake, NOT the durable
992
- // user/message session event: that append only lands mid-turn in this
993
- // runtime — past the new-turn pre-step the resume must precede (integration
994
- // evidence: editlock-auto-resume). The setting is read per message, so a
995
- // committed change applies without a restart (D5). The payload carries the
996
- // agent, so no registry lookup is needed. Flag-set is synchronous-safe here
997
- // (no followup, no session.append — AGENTS §3.4).
998
- ctx.on('agent/inbox/inserted', (/** @type {any} */ { agent, message }) => {
999
- if (!agent || !isGenuineUserMessage(message)) return
1000
- // Stale-lock sweep: the SAME genuine-message hook as auto-resume, but the
1001
- // sweep runs DETACHED — the turn never waits for it (unlike auto-resume,
1002
- // which must precede the first step). Coalesced per management-domain root
1003
- // (not per session) with a single in-flight sweep and a 60s cooldown; the
1004
- // setting is read per message so a committed change applies at once
1005
- // (default ON). Runtime-injected messages never reach this branch.
1006
- if (ctx.get?.('weirSettings')?.get?.('editLock')?.staleSweep !== false) {
1007
- const root = registry.rootOf(agent)
1008
- if (root) sweeps.trigger(root, () => {
1009
- // An agent disposed (or the plugin unmounted) before dispatch cancels
1010
- // its undispatched work; an already-submitted authority transaction
1011
- // is never cancelled or reinterpreted.
1012
- if (closed || disposedAgents.has(agent) || registry.rootOf(agent) !== root) return undefined
1013
- return registry.forRoot(root).then(domain => {
1014
- // Re-check at fulfillment: a dispose landing while the domain-open
1015
- // promise was pending must cancel here, before any maintenance
1016
- // transaction is submitted for a disposed agent.
1017
- if (closed || disposedAgents.has(agent)) return undefined
1018
- return domain.sweepStale(agent, sessionOf(agent))
1019
- })
1020
- })
1021
- }
1022
- if (ctx.get?.('weirSettings')?.get?.('editLock')?.autoResume === false) return
1023
- pendingAutoResume.set(agent, true)
1024
- // Message-driven binding self-heal (D5): the same genuine message arms ONE
1025
- // bounded retry of a recorded duplicate-binding failure, executed through
1026
- // the trusted retryBinding path at the next turn's first pre-step. Injected
1027
- // messages, timers, write calls and status reads never reach this branch.
1028
- if (bindingFailures.get(agent)?.code === 'binding-duplicate') pendingBindingRetry.set(agent, true)
1029
- })
1030
-
1031
- // Classification and settling read only the durable turn/end reason
1032
- // (AGENTS §3.5): `error` classifies the locks abnormal, `completed` settles the
1033
- // locks the turn left behind, and `aborted` only latches. Nothing is inferred.
1034
- ctx.on('session/event', (/** @type {any} */ session, /** @type {any} */ event) => {
1035
- if (event?.type !== 'turn/end') return
1036
- const agent = ctx.get?.('agents')?.get?.(session?.id)
1037
- const domain = agent && settled.get(agent)
1038
- if (!domain) return
1039
- const classification = recovery.onTurnEnd(agent, event.data?.reason).catch((/** @type {any} */ error) => {
1040
- ctx.logger?.warn?.(`edit lock recovery classification failed: ${error?.message ?? error}`)
1041
- })
1042
- turnClassifications.set(agent, classification)
1043
- const reason = event.data?.reason
1044
- if (reason?.kind !== 'completed') return
1045
- // A reservation that ran out during the turn is released here (the turn is
1046
- // over, so the session counts as idle). Settling starts in the same tick, not
1047
- // after that release: a host that finishes when the agent goes idle would
1048
- // otherwise end before the follow-up is queued. Its own status read happens
1049
- // behind the release in the manager FIFO, so it sees the files already freed.
1050
- void Promise.resolve(domain.settleExpired(agent, true)).then(result => {
1051
- if (result?.released?.length) {
1052
- settle.settled(agent)
1053
- deliverLocal(agent, `Edit Lock: the reservation ended, so ${result.released.length} file(s) are available to other sessions.`)
1054
- }
1055
- }).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock deferred release failed: ${error?.message ?? error}`))
1056
- settle.onTurnEnd(agent, reason)
1057
- })
1058
-
1059
- // A reply tool may only disappear at a turn boundary, never mid-turn.
1060
- ctx.on('agent/turn-stopping', (/** @type {any} */ { agent }) => { retireReplyTool(agent) })
1061
- // disposeAgent and its disposalTasks moved into binding-machine.js (change
1062
- // edit-lock-deepening, design D3) — the destructured alias is shared below.
1063
- ctx.on('agent/disposed', ({ agent }) => disposeAgent(agent).catch(() => {}))
1064
-
1065
- /** Cold-read fallback for the status view (design D1/D2): a session with no
1066
- * live agent is observed cold through sessionQuery (never activated), its
1067
- * management root derived from the observed cwd, and the authority image read
1068
- * READ-ONLY with the maintenance inspector's discipline — no runtime open, no
1069
- * reservation, not one byte mutated. Publisher AND client mode both answer
1070
- * from this local read: the authority is a shared file on this filesystem.
1071
- * Every failure degrades to an unavailable view with an explicit reason —
1072
- * never a reasonless "starting" for a session that cannot be starting.
1073
- * @param {string} sessionId */
1074
- async function coldStatusView(sessionId) {
1075
- if (platformFailure) return unavailableView(platformFailure.message)
1076
- let observation
1077
- try { observation = await ctx.get?.('sessionQuery')?.observeSession?.(sessionId) } catch { observation = undefined }
1078
- if (!observation) return unavailableView('session is not live in this process and could not be cold-read')
1079
- try {
1080
- const fixed = options.fixed
1081
- const cwd = observation.header?.cwd
1082
- /** @type {string} */
1083
- let root
1084
- /** @type {string} */
1085
- let directory
1086
- /** @type {string} */
1087
- let domainId
1088
- if (fixed) {
1089
- // A pinned composition serves exactly one authority; the observed cwd
1090
- // only names the files in the view.
1091
- root = fixed.root; directory = fixed.directory; domainId = fixed.root
1092
- } else {
1093
- try { root = resolveRoot(/** @type {string} */ (cwd)) } catch (error) { return unavailableView(`session working directory resolves no Edit Lock domain: ${errorMessage(error)}`) }
1094
- directory = join(root, AUTHORITY_DIR); domainId = root
1095
- }
1096
- const read = readSnapshotBytes(directory)
1097
- if (read.presence === 'no-authority') return unavailableView('workspace has no Edit Lock authority')
1098
- if (read.presence === 'no-snapshot') return unavailableView('workspace authority has no committed snapshot')
1099
- if (read.presence !== 'valid') return unavailableView(`authority snapshot unreadable: ${read.presence === 'unreadable' ? read.message : read.presence}`)
1100
- let snapshot
1101
- try { snapshot = parseSnapshot(read.bytes, domainId) } catch (error) { return unavailableView(`authority snapshot invalid: ${errorMessage(error)}`) }
1102
- const autoResume = ctx.get?.('weirSettings')?.get?.('editLock')?.autoResume !== false
1103
- return buildColdView({ image: snapshot.state, sessionId, cwd, root, autoResume, now: Date.now() })
1104
- } finally {
1105
- observation[Symbol.dispose]?.()
1106
- }
1107
- }
1108
- // Structured status for the panel (design D6). Reading the view grants nothing
1109
- // and writes nothing to the conversation; every action still goes through an
1110
- // explicit /edit-lock command so it stays on the record. `connection` is a
1111
- // host-plane service, so the isolated realm does not hide it.
1112
- /** @type {() => void} */
1113
- let offView = () => {}
1114
- ctx.inject?.(['connection'], (/** @type {any} */ scope) => {
1115
- const connection = scope.connection
1116
- if (!connection?.fetch?.register) return
1117
- // Duplicate-route tolerance (design D2): with several preset mounts in one
1118
- // process the first mount owns these process-wide routes, and the host
1119
- // connection refuses a later mount's identical registration. Skipping only
1120
- // that exact refusal keeps this mount's inject callback intact instead of
1121
- // aborting it mid-mount; correctness never depends on mount order because
1122
- // the surviving handler routes every agent to its owning mount (D1).
1123
- /** @type {(() => void) | undefined} */
1124
- let dispose
1125
- try { dispose = connection.fetch.register({
1126
- path: '/api/weir-edit-lock/view',
1127
- methods: ['POST'],
1128
- requestBody: 'buffered',
1129
- fetch: async (/** @type {any} */ request) => {
1130
- const reply = (/** @type {any} */ payload, status = 200) => new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } })
1131
- let body
1132
- try { body = await request.json() } catch { body = null }
1133
- const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
1134
- if (!sessionId) return reply({ ok: false, error: { code: 'weir-edit-lock/invalid', message: 'body needs { sessionId }' } }, 400)
1135
- const agent = ctx.get?.('agents')?.get?.(sessionId)
1136
- // Multi-mount routing (design D1): this route belongs to whichever
1137
- // mount happened to register first, but the answer must come from the
1138
- // mount that owns the agent. Resolve the agent's own weirEditLock
1139
- // instance through the preset registry (the own-preset fence's
1140
- // mechanism) and let its describe build the view; an absent, throwing
1141
- // or non-resolving registry falls back to this mount's local records
1142
- // below (design D4).
1143
- if (agent) {
1144
- /** @type {any} */
1145
- let owning = null
1146
- try { owning = typeof presets?.serviceFor === 'function' ? presets.serviceFor(agent, 'weirEditLock') : null } catch { owning = null }
1147
- if (owning && typeof owning.describe === 'function') {
1148
- try {
1149
- const described = await owning.describe(agent)
1150
- return reply({ ok: true, value: buildView({ status: described.status, locks: described.locks, recoveryState: described.recovery, cwd: agent?.session?.header?.cwd, root: described.root, mode: described.mode, now: Date.now() }) })
1151
- } catch (error) {
1152
- // Failure loudening (design D3): a failed binding names its
1153
- // reason; a reasonless "starting" is reserved for a genuinely
1154
- // in-flight setup (describe's await absorbs that window).
1155
- return reply({ ok: true, value: unavailableView(String(/** @type {any} */ (error)?.message ?? error), /** @type {any} */ (error)?.bindingFailure) })
1156
- }
1157
- }
1158
- }
1159
- const domain = agent && settled.get(agent)
1160
- // A live agent whose domain is still starting (or failed to start)
1161
- // keeps the old answer; only a session with NO live agent cold-reads.
1162
- if (agent && !domain) return reply({ ok: true, value: unavailableView(platformFailure?.message ?? startFailures.get(agent) ?? null, startupFailure(agent)?.bindingFailure) })
1163
- if (!domain) return reply({ ok: true, value: await coldStatusView(sessionId) })
1164
- try {
1165
- const [status, locks] = await Promise.all([domain.status(agent), domain.locks(agent)])
1166
- return reply({ ok: true, value: buildView({ status, locks, recoveryState: recovery.state(agent), cwd: agent?.session?.header?.cwd, root: registry.rootOf(agent), mode: domain.mode, now: Date.now() }) })
1167
- } catch (error) {
1168
- return reply({ ok: false, error: { code: 'weir-edit-lock/internal', message: /** @type {any} */ (error)?.message ?? String(error) } }, 500)
1169
- }
1170
- },
1171
- }) } catch (error) {
1172
- if (!duplicateRouteRegistration(error, '/api/weir-edit-lock/view')) throw error
1173
- }
1174
- // Online administrative recovery (design D4): the maintenance panel's
1175
- // one-click confirmation. Root membership is exact against the roots this
1176
- // process actually serves (never an open, never a client path read); the
1177
- // manager re-verifies every condition at its FIFO position. Refusals
1178
- // expose no filesystem detail beyond what inspect already shows.
1179
- /** @type {(() => void) | undefined} */
1180
- let disposeRecovery
1181
- try { disposeRecovery = connection.fetch.register({
1182
- path: '/api/weir-edit-lock/maintenance/recover-online',
1183
- methods: ['POST'],
1184
- requestBody: 'buffered',
1185
- fetch: async (/** @type {any} */ request) => {
1186
- const reply = (/** @type {any} */ payload, /** @type {number} */ status = 200) => new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } })
1187
- let body
1188
- try { body = await request.json() } catch { return reply({ ok: false, error: { code: 'weir-edit-lock/invalid', message: 'Invalid recovery JSON' } }, 400) }
1189
- const requested = body !== null && typeof body === 'object' && typeof body.root === 'string' ? body.root : ''
1190
- let canonicalRoot = ''
1191
- try { canonicalRoot = requested ? realpathSync.native(requested) : '' } catch { canonicalRoot = '' }
1192
- if (!canonicalRoot || !registry.known(canonicalRoot)) {
1193
- return reply({ ok: false, error: { code: 'weir-edit-lock/untrusted-root', message: 'not an Edit Lock domain root this server derived' } }, 403)
1194
- }
1195
- try {
1196
- const domain = await registry.forRoot(canonicalRoot)
1197
- // Server-derived trigger: one of THIS process's own agents bound to
1198
- // the domain. A client-mode domain needs it to carry the channel;
1199
- // a publisher-local domain uses it for the audit trigger only.
1200
- const agents = ctx.get?.('agents')?.roots?.() ?? []
1201
- const triggerAgent = agents.find((/** @type {any} */ agent) => registry.rootOf(agent) === canonicalRoot) ?? null
1202
- if (domain.mode !== 'publisher' && !triggerAgent) {
1203
- return reply({ ok: false, error: { code: 'weir-edit-lock/recovery-unavailable', message: 'no live session in this process can carry the recovery to the publisher', commitStatus: 'not-acknowledged' } }, 409)
1204
- }
1205
- const { root: _root, ...input } = body
1206
- const value = await domain.adminRecover(triggerAgent, input, sessionOf(triggerAgent) ?? null)
1207
- return reply({ ok: true, value })
1208
- } catch (error) {
1209
- const e = /** @type {any} */ (error)
1210
- const code = typeof e?.code === 'string' && e.code.startsWith('weir-edit-lock/') ? e.code : 'weir-edit-lock/recovery-refused'
1211
- return reply({ ok: false, error: { code, message: 'Recovery not acknowledged. Inspect authority and retry the same recovery ID after resolving the cause.',
1212
- detail: e?.recoveryRefusal === true ? String(e.message) : undefined, commitStatus: 'not-acknowledged' } }, 409)
1213
- }
1214
- },
1215
- }) } catch (error) {
1216
- if (!duplicateRouteRegistration(error, '/api/weir-edit-lock/maintenance/recover-online')) throw error
1217
- }
1218
- offView = () => { dispose?.(); disposeRecovery?.() }
1219
- return offView
1220
- })
1221
- const commands = ctx.get?.('commands')
1222
- const offCommand = commands?.register({
1223
- name: 'edit-lock',
1224
- description: 'Edit Lock: status, retry (connection only; not Continue), locks, hold [minutes], release <path>, stop (durable revoke), resume (explicit Continue), confirm <path> | --all, unlock <path> <generation>.',
1225
- input: { hint: 'status | retry | locks | hold [minutes] | release <path> | stop | resume | confirm <path> | unlock <path> <generation>' },
1226
- handler: async (/** @type {any} */ invocation) => {
1227
- const agent = invocation?.agent
1228
- if (!agent) return { kind: 'error', text: 'edit-lock: requires an owning agent session' }
1229
- try {
1230
- const raw = String(invocation.rawInput ?? '')
1231
- if (raw.trim() === 'retry') return { kind: 'success', text: await retryBinding(agent) }
1232
- const domain = await domainFor(agent)
1233
- // Only the hold verb depends on the retention settings being coherent.
1234
- const policy = /^\s*hold\b/.test(raw) ? strictLimits() : limits()
1235
- const text = await runCommand(domain, agent, raw, String(invocation.commandId), policy)
1236
- return { kind: 'success', text: `${text}\nDomain: ${registry.rootOf(agent)} (${domain.mode})` }
1237
- } catch (error) {
1238
- return { kind: 'error', text: `edit-lock: ${/** @type {any} */ (error)?.message ?? error}` }
1239
- }
1240
- },
1241
- })
1242
-
1243
- if (!platformFailure) evidence?.installed()
1244
- return async () => {
1245
- if (closed) return
1246
- closed = true
1247
- liveServices.delete(service)
1248
- offDelegateLifecycle()
1249
- evidence?.disposing()
1250
- try {
1251
- // Cancel undispatched sweeps first; an already-running sweep's authority
1252
- // transactions drain with the domains below, never cancelled.
1253
- sweeps.close()
1254
- recovery.close()
1255
- settle.close()
1256
- offView()
1257
- offCommand?.()
1258
- for (const off of offTools) off?.()
1259
- // Retain uncertain evidence until every generation-owned domain is
1260
- // drained; only the LAST sharing disposer closes them (design D1).
1261
- await shared.release()
1262
- evidence?.dispose()
1263
- } catch (error) {
1264
- evidence?.failed(error)
1265
- ctx.logger?.warn?.(`edit lock shutdown incomplete; reservation retained: ${error?.message ?? error}`)
1266
- }
1267
- }
1268
- } catch (error) {
1269
- evidence?.failed(error)
1270
- // A mount that failed mid-apply must not stay counted in the shared
1271
- // registry (design D1); domains are lazy, so this release closes nothing
1272
- // unless a sibling mount already opened one — its count keeps it alive.
1273
- if (shared) void shared.release().catch(() => {})
1274
- throw error
1275
- }
1276
- }
1277
- }
1278
- const apply = createEditLockPlugin()
1279
-
1280
- /** The host timer is an optional service: accessing an unavailable cordis service
1281
- * throws rather than yielding undefined, so it is probed explicitly. Returning
1282
- * undefined lets the caller fall back to the platform timer.
1283
- * @param {any} ctx */
1284
- function safeSetTimer(ctx) {
1285
- try {
1286
- const timer = ctx?.setTimeout
1287
- return typeof timer === 'function' ? timer.bind(ctx) : undefined
1288
- } catch { return undefined }
1289
- }
1290
- /** The host connection refuses a second registration of an exact route path
1291
- * with this precise message (dsh-client-connection); only that refusal may be
1292
- * skipped by a later mount — every other registration error still aborts the
1293
- * mount. @param {any} error @param {string} path */
1294
- function duplicateRouteRegistration(error, path) {
1295
- return /** @type {any} */ (error)?.message === `connection: exact Fetch route ${JSON.stringify(path)} is already registered`
1296
- }
1297
- /** Any abort latches (denial is always safe) except disposal, which
1298
- * agent/disposed revokes and forgets. @param {AbortSignal} signal */
1299
- function abortedByUser(signal) {
1300
- return /** @type {any} */ (signal.reason)?.kind !== 'disposed'
1301
- }
1302
-
1303
- export { name, inject, apply }