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
@@ -0,0 +1,847 @@
1
+ // The model-facing tool surface of the file-lease feature (design D4, D5, D13).
2
+ //
3
+ // Seven pure definitions. They own no state: the arbiter is the only authority
4
+ // (D6/D8), so a tool translates a model intent into one protocol call and
5
+ // translates the verdict back into something a model can act on. Nothing here
6
+ // re-derives ownership, remembers it, or waits for it.
7
+ //
8
+ // Text discipline (AGENTS §3.7): every template below is English, and every
9
+ // refusal is addressed to the MODEL — name the holder, say that the claim frees
10
+ // itself, say not to retry the same edit blindly, and offer the negotiation
11
+ // path. No template may name a command, a setting, a panel or any operator
12
+ // action: ownership always resolves by turn end, by validity elapsing, or by
13
+ // model-to-model negotiation (spec: "Refusal is addressed to the model", "No
14
+ // ownership state requires a human").
15
+ //
16
+ // Path discipline: the protocol speaks absolute paths only (a relative path
17
+ // would be `invalid-path` and then degrade), so tools resolve the model's
18
+ // short paths against the session cwd on the way in and render short names
19
+ // relative to that same cwd on the way out. An absolute path never lands in a
20
+ // message body.
21
+ //
22
+ // Normalization is lexical and it happens HERE, for every tool input: `resolve`
23
+ // folds `.`, `..`, repeated and trailing separators and keeps an absolute input
24
+ // absolute, so one file has one spelling for arbitration and for the
25
+ // subscription table. The broker deliberately does not normalize — there string
26
+ // equality IS a path (docs/features/edit-lock-protocol.md) — so a verbatim `..`
27
+ // spelling would be granted a SECOND lease on a file another session holds, and
28
+ // `edit_lock_watch` would miss the held canonical path entirely.
29
+
30
+ import { basename, relative, resolve } from 'node:path'
31
+ import { isLeaseConflict } from './client.js'
32
+ import { createWatchCoordinator } from './watch.js'
33
+
34
+ /** Retention bound in minutes; the protocol clamps here and so does the tool. */
35
+ export const MAX_HOLD_MINUTES = 60
36
+
37
+ /** The negotiation reply tool: registered only while a request is pending. */
38
+ export const REPLY_TOOL = 'edit_lock_reply'
39
+
40
+ /** The release-subscription tool: one path, one in-memory subscription. */
41
+ export const WATCH_TOOL = 'edit_lock_watch'
42
+
43
+ /** Default retention when the model asks for one without a length. */
44
+ export const DEFAULT_HOLD_MINUTES = MAX_HOLD_MINUTES
45
+
46
+ /**
47
+ * UI-only presentation data handed from `execute` to `presentationMeta`
48
+ * (openspec edit-lock-tool-cards, design D1). The conversation cards need a
49
+ * few facts the model never reads — the try_steal deadline, the per-row
50
+ * validity of a status answer, the paths of a degraded call — and the hard
51
+ * constraint is that the result object stays field-for-field what it was. So
52
+ * those facts never become result fields: `execute` files them here and
53
+ * `presentationMeta` takes them out again.
54
+ *
55
+ * The handoff is keyed by the result's JSON text, not by object identity: the
56
+ * tool registry snapshots the value `execute` returned (a lossless-JSON copy,
57
+ * then deep-frozen) and hands that COPY to `render` and `presentationMeta`, so
58
+ * an identity-keyed WeakMap never hits. A copy serializes to the same text,
59
+ * so the same key finds the entry. Each entry is taken once (a queue per key
60
+ * keeps two identical concurrent answers apart), the table is bounded, and a
61
+ * result with no entry still gets the subset its own fields can carry.
62
+ * @type {Map<string, Record<string, any>[]>}
63
+ */
64
+ const presented = new Map()
65
+
66
+ /** Upper bound on filed-but-never-read entries (a result the host dropped). */
67
+ const MAX_PRESENTED = 256
68
+
69
+ /** @param {unknown} value @returns {string | undefined} */
70
+ function presentationKey(value) {
71
+ if (value === null || typeof value !== 'object') return undefined
72
+ try {
73
+ return JSON.stringify(value)
74
+ } catch {
75
+ return undefined
76
+ }
77
+ }
78
+
79
+ /** @param {unknown} value @returns {Record<string, any> | undefined} */
80
+ function takePresented(value) {
81
+ const key = presentationKey(value)
82
+ if (key === undefined) return undefined
83
+ const queue = presented.get(key)
84
+ if (queue === undefined) return undefined
85
+ const fields = queue.shift()
86
+ if (queue.length === 0) presented.delete(key)
87
+ return fields
88
+ }
89
+
90
+ /** Presentation metadata schema version; the client narrows on it. */
91
+ export const LEASE_META_VERSION = 1
92
+
93
+ /**
94
+ * The closed state set per tool (design D1). Anything outside it is never
95
+ * emitted, so the client can treat an unknown state as malformed metadata.
96
+ * @type {Record<string, readonly string[]>}
97
+ */
98
+ export const LEASE_META_STATES = Object.freeze({
99
+ edit_lock_acquire: Object.freeze(['claimed', 'degraded']),
100
+ edit_lock_release: Object.freeze(['released', 'degraded']),
101
+ edit_lock_hold: Object.freeze(['held', 'degraded']),
102
+ edit_lock_status: Object.freeze(['status', 'degraded']),
103
+ edit_lock_try_steal: Object.freeze(['pending', 'free', 'self', 'degraded']),
104
+ edit_lock_reply: Object.freeze(['transferred', 'declined', 'degraded']),
105
+ edit_lock_watch: Object.freeze(['watching', 'free', 'self', 'released', 'reset', 'unsupported', 'cancelled', 'degraded']),
106
+ })
107
+
108
+ /**
109
+ * File the presentation data for one result and hand the result back
110
+ * untouched: nothing is added to the object the model reads.
111
+ * @template {object} T
112
+ * @param {T} value
113
+ * @param {Record<string, any> | undefined} fields
114
+ * @returns {T}
115
+ */
116
+ function present(value, fields) {
117
+ const key = presentationKey(value)
118
+ if (fields === undefined || key === undefined) return value
119
+ const queue = presented.get(key)
120
+ if (queue === undefined) presented.set(key, [fields])
121
+ else queue.push(fields)
122
+ // Insertion order is age order: drop the oldest key once over the bound.
123
+ if (presented.size > MAX_PRESENTED) {
124
+ const oldest = presented.keys().next().value
125
+ if (oldest !== undefined) presented.delete(oldest)
126
+ }
127
+ return value
128
+ }
129
+
130
+ /**
131
+ * File a degraded (arbiter-unreachable) answer: the card shows "protection
132
+ * temporarily unavailable", never a refusal, plus what the call was about.
133
+ * @template {{ code: string }} T
134
+ * @param {T} value
135
+ * @param {Record<string, any>} about
136
+ * @returns {T}
137
+ */
138
+ function presentDegraded(value, about) {
139
+ return present(value, { state: 'degraded', code: value.code, ...about })
140
+ }
141
+
142
+ /** @param {unknown} list @returns {string[]} */
143
+ function strings(list) {
144
+ return Array.isArray(list) ? list.filter((entry) => typeof entry === 'string') : []
145
+ }
146
+
147
+ /** @param {any} holder */
148
+ function holderOf(holder) {
149
+ return holder && typeof holder === 'object' && typeof holder.label === 'string' && typeof holder.session === 'string'
150
+ ? { label: holder.label, session: holder.session }
151
+ : undefined
152
+ }
153
+
154
+ /**
155
+ * The presentation fields one result's OWN fields can carry. This is both the
156
+ * base every `execute` extends and the whole answer for a result that has no
157
+ * filed entry.
158
+ * @param {string} tool
159
+ * @param {any} value
160
+ * @returns {Record<string, any> | undefined}
161
+ */
162
+ function derivedFields(tool, value) {
163
+ if (value === null || typeof value !== 'object') return undefined
164
+ if (value.degraded === true) {
165
+ const code = typeof value.code === 'string' ? value.code : 'internal'
166
+ // A watch without release events or one cancelled underneath it is not an
167
+ // unreachable arbiter, and the card must not read it as one.
168
+ if (tool === WATCH_TOOL && code === 'release-events-unsupported') return { state: 'unsupported', code }
169
+ if (tool === WATCH_TOOL && code === 'watch-cancelled') return { state: 'cancelled', code }
170
+ return { state: 'degraded', code }
171
+ }
172
+ switch (tool) {
173
+ case 'edit_lock_acquire':
174
+ return { state: 'claimed', paths: strings(value.paths) }
175
+ case 'edit_lock_release':
176
+ return { state: 'released', released: strings(value.released), notOwned: strings(value.notOwned) }
177
+ case 'edit_lock_hold':
178
+ return {
179
+ state: 'held',
180
+ held: (Array.isArray(value.held) ? value.held : []).map((/** @type {any} */ row) => ({ path: String(row?.path ?? ''), holdUntil: typeof row?.holdUntil === 'number' ? row.holdUntil : null })),
181
+ }
182
+ case 'edit_lock_status':
183
+ return { state: 'status', mine: strings(value.mine).map((path) => ({ path })), others: [] }
184
+ case 'edit_lock_try_steal': {
185
+ if (!['pending', 'free', 'self'].includes(value.state)) return undefined
186
+ const holder = holderOf(value.holder)
187
+ return { state: value.state, ...(typeof value.request_id === 'string' ? { requestId: value.request_id } : {}), ...(holder ? { holder } : {}) }
188
+ }
189
+ case REPLY_TOOL:
190
+ return {
191
+ state: value.state === 'transferred' ? 'transferred' : 'declined',
192
+ ...(typeof value.request_id === 'string' ? { requestId: value.request_id } : {}),
193
+ ...(typeof value.decision === 'string' ? { decision: value.decision } : {}),
194
+ }
195
+ case WATCH_TOOL: {
196
+ if (!LEASE_META_STATES[WATCH_TOOL].includes(value.state)) return undefined
197
+ const holder = holderOf(value.holder)
198
+ return { state: value.state, ...(typeof value.file_path === 'string' ? { path: value.file_path } : {}), ...(holder ? { holder } : {}) }
199
+ }
200
+ default:
201
+ return undefined
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The output contract of every edit-lock tool. `render` is byte-identical to
207
+ * the single-field text rendering it replaces (the model reads `message` and
208
+ * nothing else from the text channel); `presentationMeta` is the UI-only
209
+ * channel the conversation cards read from the persisted tool result.
210
+ * Short names only and epoch-ms instants: an absolute path never appears
211
+ * here either.
212
+ * @param {string} tool
213
+ */
214
+ function leaseOutput(tool) {
215
+ return {
216
+ schema: { type: 'object' },
217
+ render: (/** @type {any} */ _args, /** @type {any} */ result) => [{ type: 'text', text: result.message }],
218
+ // `null`, never `undefined`, for a value it cannot describe: the registry
219
+ // rejects a non-JSON projection, which would turn the call into an error.
220
+ presentationMeta: (/** @type {any} */ _args, /** @type {any} */ value) => {
221
+ const fields = takePresented(value) ?? derivedFields(tool, value)
222
+ if (fields === undefined || !LEASE_META_STATES[tool]?.includes(fields.state)) return null
223
+ return { editLock: { v: LEASE_META_VERSION, tool, ...fields } }
224
+ },
225
+ }
226
+ }
227
+
228
+ /** @param {string} description */
229
+ function pathList(description) {
230
+ return { type: 'array', items: { type: 'string' }, minItems: 1, description }
231
+ }
232
+
233
+ /** @param {unknown} value @returns {string | undefined} */
234
+ function text(value) {
235
+ return typeof value === 'string' && value.length > 0 ? value : undefined
236
+ }
237
+
238
+ /** A broker refusal code, or `internal` when the failure carries none. @param {any} error */
239
+ function codeOf(error) {
240
+ const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
241
+ return typeof code === 'string' ? code : 'internal'
242
+ }
243
+
244
+ /** True for the one failure class that is infrastructure, not a verdict (D7). */
245
+ function isTransport(error) {
246
+ return codeOf(error) === 'transport'
247
+ }
248
+
249
+ /**
250
+ * The session working directory, which is both the resolution base for short
251
+ * paths and the opaque project scope the arbiter only ever uses for `status`.
252
+ * @param {any} exec
253
+ * @returns {string}
254
+ */
255
+ function requireCwd(exec) {
256
+ const cwd = text(exec?.agent?.session?.header?.cwd)
257
+ if (cwd === undefined) throw new Error('the session working directory is unavailable, so paths cannot be resolved')
258
+ return cwd
259
+ }
260
+
261
+ /**
262
+ * Render a path the way the model thinks about it: relative to the session cwd,
263
+ * or as a bare name when it lives outside. A resolved absolute path is accepted
264
+ * on the way in — that symmetry is what makes the model's own absolute path
265
+ * echo readable instead of pasted verbatim.
266
+ * @param {string} path
267
+ * @param {string | undefined} cwd
268
+ * @returns {string}
269
+ */
270
+ export function shortName(path, cwd) {
271
+ const name = basename(path)
272
+ if (cwd === undefined) return name
273
+ const rel = relative(cwd, path)
274
+ if (rel === '' || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\')) return name
275
+ return rel
276
+ }
277
+
278
+ /**
279
+ * One file, one spelling: the lexical normalization every tool input passes
280
+ * through before it can become a claim, a retention, a release, a transfer
281
+ * request or a release subscription.
282
+ *
283
+ * `resolve` is the right instrument and no other is needed: it folds `.`,
284
+ * `..`, repeated and trailing separators, keeps an absolute input absolute
285
+ * (the base is then ignored) and resolves a relative one against the session
286
+ * cwd. It touches no filesystem, so symlinks and case are left alone — the
287
+ * protocol makes that the client's call, and it keys leases by string equality,
288
+ * which is exactly why `/p/src/../src/a.js` and `/p/src/a.js` must never reach
289
+ * it as two paths.
290
+ * @param {string} raw
291
+ * @param {string} cwd
292
+ * @returns {string}
293
+ */
294
+ function canonicalPath(raw, cwd) {
295
+ return resolve(cwd, raw)
296
+ }
297
+
298
+ /**
299
+ * Resolve the model's path batch to the canonical, de-duplicated list the
300
+ * protocol accepts. Normalization happens before the de-duplication, so two
301
+ * spellings of one file collapse into one entry instead of becoming two claims.
302
+ * @param {unknown} paths
303
+ * @param {string} cwd
304
+ * @returns {string[]}
305
+ */
306
+ function absoluteBatch(paths, cwd) {
307
+ if (!Array.isArray(paths) || paths.length === 0) throw new Error('paths must list at least one path')
308
+ /** @type {string[]} */
309
+ const out = []
310
+ for (const entry of paths) {
311
+ const raw = text(entry)
312
+ if (raw === undefined) throw new Error('every entry of paths must be a non-empty string')
313
+ const absolute = canonicalPath(raw, cwd)
314
+ if (!out.includes(absolute)) out.push(absolute)
315
+ }
316
+ return out
317
+ }
318
+
319
+ /**
320
+ * The single-path form of {@link absoluteBatch}: normalize before the path can
321
+ * reach the arbiter or the subscription coordinator.
322
+ * @param {unknown} filePath
323
+ * @param {string} cwd
324
+ * @returns {string}
325
+ */
326
+ function absoluteOne(filePath, cwd) {
327
+ const raw = text(filePath)
328
+ if (raw === undefined) throw new Error('file_path must be a non-empty string')
329
+ return canonicalPath(raw, cwd)
330
+ }
331
+
332
+ /** `2026-05-05T12:00:00.000Z` for a validity instant. @param {number} at epoch ms */
333
+ function instant(at) {
334
+ return new Date(at).toISOString()
335
+ }
336
+
337
+ /**
338
+ * Remaining validity in whole seconds, rounded up so a positive window never
339
+ * renders as "0s" and a lapsed one never renders as still valid.
340
+ * @param {number} target epoch ms
341
+ * @param {number} at epoch ms
342
+ * @returns {number}
343
+ */
344
+ function secondsUntil(target, at) {
345
+ return Math.max(0, Math.ceil((target - at) / 1000))
346
+ }
347
+
348
+ /** Whether a row carries a retention deadline beyond its plain validity. */
349
+ function retainedUntil(/** @type {any} */ row) {
350
+ const expiresAt = typeof row?.expiresAt === 'number' ? row.expiresAt : 0
351
+ const holdUntil = typeof row?.holdUntil === 'number' ? row.holdUntil : 0
352
+ return holdUntil > expiresAt ? holdUntil : undefined
353
+ }
354
+
355
+ /**
356
+ * The refusal text for a `held` verdict. The obligations of the spec's "Refusal
357
+ * names the holder" and "Refusal offers no human step" scenarios are encoded
358
+ * here and nowhere else, so the wording cannot drift between the tools that can
359
+ * hit a conflict — including the managed editors that will acquire leases
360
+ * instead of calling a tool, which is why this is exported.
361
+ * @param {any} error a `held` LeaseFailure carrying `conflicts`
362
+ * @param {string} cwd
363
+ * @param {number} at epoch ms
364
+ * @returns {string}
365
+ */
366
+ export function describeConflicts(error, cwd, at) {
367
+ const conflicts = Array.isArray(error?.conflicts) ? error.conflicts : []
368
+ const lines = conflicts.map((/** @type {any} */ conflict) => {
369
+ const holder = conflict?.holder ?? {}
370
+ const label = text(holder.label) ?? 'another session'
371
+ const session = text(holder.session) ?? 'unknown'
372
+ const target = Math.max(typeof conflict?.expiresAt === 'number' ? conflict.expiresAt : 0, typeof conflict?.holdUntil === 'number' ? conflict.holdUntil : 0)
373
+ return `- ${shortName(String(conflict?.path ?? ''), cwd)} is held by "${label}" (session ${session}) for about ${secondsUntil(target, at)}s more`
374
+ })
375
+ const headline = conflicts.length > 0
376
+ ? `Refused: ${conflicts.length} of the requested path(s) are owned by another session.`
377
+ : 'Refused: another session owns one or more of the requested paths.'
378
+ return [
379
+ headline,
380
+ ...lines,
381
+ 'Ownership frees itself when its validity ends, so do not retry this edit blindly: the same call keeps being refused while the holder keeps the path.',
382
+ 'If you need a path now, ask for it — call edit_lock_try_steal with that path and wait for the holder\'s answer (release transfers it, keep or silence leaves it where it is).',
383
+ ].join('\n')
384
+ }
385
+
386
+ /**
387
+ * The synchronous answer for a subscription that is already over. Every await in
388
+ * the watch tool re-reads this first (design D2): a release, a reset or a
389
+ * transfer that landed while the authoritative read was in flight is the truth,
390
+ * and a late read result or failure may neither overwrite it nor cancel its
391
+ * pending notice.
392
+ * @param {import('./watch.js').WatchEntry} entry
393
+ * @param {string} short
394
+ * @returns {any | undefined}
395
+ */
396
+ function terminalOutcome(entry, short) {
397
+ if (entry.state === 'hit') {
398
+ entry.settledByTool = true
399
+ return {
400
+ state: 'released',
401
+ file_path: short,
402
+ message: `The release of ${short} happened while this subscription was being established, so nothing is pending. Claim it with edit_lock_acquire if you still need it — a release is an opportunity, not an ownership grant.`,
403
+ }
404
+ }
405
+ if (entry.state === 'reset') {
406
+ entry.settledByTool = true
407
+ return {
408
+ state: 'reset',
409
+ file_path: short,
410
+ message: `The ownership arbiter connection was reset while this subscription was being established, so nothing is pending and nothing is known about ownership. Re-check with edit_lock_status, and call edit_lock_watch again if ${short} is still held by another session.`,
411
+ }
412
+ }
413
+ if (entry.state === 'self') {
414
+ entry.settledByTool = true
415
+ return {
416
+ state: 'self',
417
+ file_path: short,
418
+ message: `This session already owns ${short} — ownership arrived while this subscription was being established — so no release subscription was kept and there is nothing to wait for.`,
419
+ }
420
+ }
421
+ return undefined
422
+ }
423
+
424
+ /**
425
+ * The degraded answer for a wait that ended without an ownership fact.
426
+ * @param {string} short
427
+ * @returns {any}
428
+ */
429
+ function cancelledOutcome(short) {
430
+ return {
431
+ degraded: true,
432
+ code: 'watch-cancelled',
433
+ message: `The release subscription for ${short} was cancelled before it could be established (this agent or the feature was shut down), so nothing is pending. Call edit_lock_watch again in a live agent if you still need the path.`,
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Whether one entry is still the active wait it was. `watching` may only be
439
+ * reported for the SAME object that is currently registered (design D2): an
440
+ * overlapping call, a transfer or a disposal can end the wait while a read is
441
+ * in flight, and a stale entry must never come back as `watching`.
442
+ * @param {import('./watch.js').WatchCoordinator} watches
443
+ * @param {import('./watch.js').WatchEntry} entry
444
+ * @returns {boolean}
445
+ */
446
+ function stillWatching(watches, entry) {
447
+ return entry.state === 'watching' && watches.peek(entry.sessionId, entry.path) === entry
448
+ }
449
+
450
+ /**
451
+ * Create the tool definitions.
452
+ *
453
+ * @param {object} options
454
+ * @param {any} options.client the lease client (or a facade with the same surface)
455
+ * @param {(agent: any) => { id: string, session: string, label: string }} options.ownerOf
456
+ * @param {(agent: any, text: string) => any} [options.deliverNotice] model-facing
457
+ * notice channel, used only where a tool result cannot carry the outcome
458
+ * @param {import('./watch.js').WatchCoordinator} [options.watchCoordinator] the
459
+ * subscription coordinator shared with the plugin's event routing
460
+ * @param {import('./watch.js').WatchMount} [options.mount] the mount reference
461
+ * that owns this call's subscription, when a mount is in play
462
+ * @returns {any[]} seven definitions, `REPLY_TOOL` included (the plugin decides
463
+ * when that one becomes visible)
464
+ */
465
+ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {}, watchCoordinator = createWatchCoordinator(), mount }) {
466
+ const watches = watchCoordinator
467
+ return [
468
+ {
469
+ name: 'edit_lock_acquire',
470
+ description:
471
+ 'Claim file paths for this session before touching them, so another session cannot take them while you analyse. Managed edits claim implicitly; call this to hold the paths up front. The whole batch is granted or refused together. A refusal names the current holder and frees itself — do not retry it blindly.',
472
+ parameters: {
473
+ type: 'object',
474
+ properties: {
475
+ paths: pathList('Paths to claim, absolute or relative to the session working directory.'),
476
+ },
477
+ required: ['paths'],
478
+ },
479
+ output: leaseOutput('edit_lock_acquire'),
480
+ async execute(/** @type {any} */ args, /** @type {any} */ exec) {
481
+ const owner = ownerOf(exec?.agent)
482
+ const cwd = requireCwd(exec)
483
+ const paths = absoluteBatch(args?.paths, cwd)
484
+ try {
485
+ const reply = await client.acquire(owner, paths, cwd)
486
+ const granted = Array.isArray(reply?.granted) ? reply.granted : []
487
+ const claimed = granted.map((/** @type {any} */ lease) => shortName(String(lease?.path ?? ''), cwd))
488
+ const result = {
489
+ paths: claimed,
490
+ message: claimed.length > 0
491
+ ? `Claimed ${claimed.length} path(s) for this turn: ${claimed.join(', ')}.`
492
+ : 'Nothing was claimed.',
493
+ }
494
+ return present(result, { state: 'claimed', paths: [...claimed] })
495
+ } catch (error) {
496
+ if (isLeaseConflict(error)) throw new Error(describeConflicts(error, cwd, Date.now()))
497
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'nothing was claimed'), { paths: paths.map((path) => shortName(path, cwd)) })
498
+ throw error
499
+ }
500
+ },
501
+ },
502
+
503
+ {
504
+ name: 'edit_lock_release',
505
+ description:
506
+ 'Release paths this session owns, retained ones included, so another session can claim them without waiting for any deadline. Releasing a path nobody owns is not an error: the answer simply says it was not held.',
507
+ parameters: {
508
+ type: 'object',
509
+ properties: {
510
+ paths: pathList('Paths to release, absolute or relative to the session working directory.'),
511
+ },
512
+ required: ['paths'],
513
+ },
514
+ output: leaseOutput('edit_lock_release'),
515
+ async execute(/** @type {any} */ args, /** @type {any} */ exec) {
516
+ const owner = ownerOf(exec?.agent)
517
+ const cwd = requireCwd(exec)
518
+ const paths = absoluteBatch(args?.paths, cwd)
519
+ let reply
520
+ try {
521
+ reply = await client.request('release', { owner, paths })
522
+ } catch (error) {
523
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'the release was not confirmed'), { paths: paths.map((path) => shortName(path, cwd)) })
524
+ throw error
525
+ }
526
+ const released = (Array.isArray(reply?.released) ? reply.released : []).map((/** @type {any} */ path) => shortName(String(path), cwd))
527
+ const notOwned = (Array.isArray(reply?.notOwned) ? reply.notOwned : []).map((/** @type {any} */ path) => shortName(String(path), cwd))
528
+ const lines = []
529
+ lines.push(released.length > 0 ? `Released ${released.length} path(s): ${released.join(', ')}.` : 'Released nothing.')
530
+ if (notOwned.length > 0) lines.push(`Not held by this session, so there was nothing to release: ${notOwned.join(', ')}.`)
531
+ return present({ released, notOwned, message: lines.join('\n') }, { state: 'released', released: [...released], notOwned: [...notOwned] })
532
+ },
533
+ },
534
+
535
+ {
536
+ name: 'edit_lock_hold',
537
+ description: `Keep paths this session already owns past the end of this turn, for up to ${MAX_HOLD_MINUTES} minutes from now. Use it when the work continues after this turn; the paths stay yours and other sessions are refused until the deadline. A longer request is clamped, not ignored. When the deadline passes the paths become available again, and the expiry is announced only to a session that explicitly subscribed to that path's release with edit_lock_watch: a matching subscriber gets the usual notice that it may claim the path again, while with no subscriber nothing is announced.`,
538
+ parameters: {
539
+ type: 'object',
540
+ properties: {
541
+ paths: pathList('Paths to keep, absolute or relative to the session working directory. They must already be owned.'),
542
+ minutes: {
543
+ type: 'number',
544
+ description: `How long to keep them, counted from now; clamped to ${MAX_HOLD_MINUTES}. Omit for the full ${DEFAULT_HOLD_MINUTES} minutes.`,
545
+ },
546
+ },
547
+ required: ['paths'],
548
+ },
549
+ output: leaseOutput('edit_lock_hold'),
550
+ async execute(/** @type {any} */ args, /** @type {any} */ exec) {
551
+ const owner = ownerOf(exec?.agent)
552
+ const cwd = requireCwd(exec)
553
+ const paths = absoluteBatch(args?.paths, cwd)
554
+ const requested = args?.minutes === undefined || args?.minutes === null ? DEFAULT_HOLD_MINUTES : Number(args.minutes)
555
+ if (!Number.isFinite(requested) || requested <= 0) throw new Error('minutes must be a positive number of minutes')
556
+ const minutes = Math.min(MAX_HOLD_MINUTES, requested)
557
+ let reply
558
+ try {
559
+ reply = await client.hold(paths, minutes, owner)
560
+ } catch (error) {
561
+ const code = codeOf(error)
562
+ if (code === 'not-owned') {
563
+ throw new Error(`This session does not own ${paths.map((path) => shortName(path, cwd)).join(', ')}. Retention only extends ownership that already exists — claim the paths with edit_lock_acquire first.`)
564
+ }
565
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'no retention was recorded'), { paths: paths.map((path) => shortName(path, cwd)) })
566
+ throw error
567
+ }
568
+ const held = Array.isArray(reply?.held) ? reply.held : []
569
+ const at = Date.now()
570
+ /** @type {{ path: string, holdUntil: number }[]} */
571
+ const shown = []
572
+ const entries = held.map((/** @type {any} */ row) => {
573
+ const until = typeof row?.holdUntil === 'number' ? row.holdUntil : typeof row?.expiresAt === 'number' ? row.expiresAt : at
574
+ shown.push({ path: shortName(String(row?.path ?? ''), cwd), holdUntil: until })
575
+ return `${shortName(String(row?.path ?? ''), cwd)} until ${instant(until)} (about ${Math.ceil(secondsUntil(until, at) / 60)}m from now)`
576
+ })
577
+ return present({
578
+ // Short names only: an absolute path never appears in a tool result,
579
+ // not even in a structured field the model also reads.
580
+ held: held.map((/** @type {any} */ row) => ({ path: shortName(String(row?.path ?? ''), cwd), holdUntil: row?.holdUntil ?? null })),
581
+ message: entries.length > 0
582
+ ? `Kept ${entries.length} path(s) past this turn: ${entries.join(', ')}. At most ${MAX_HOLD_MINUTES} minutes per request; a deadline that passes simply makes the paths available, and the expiry is announced only to a session that explicitly subscribed to that path with edit_lock_watch — a matching subscriber gets the usual chance to claim it again, while with no subscriber nothing is announced.`
583
+ : 'Nothing was retained.',
584
+ }, { state: 'held', held: shown })
585
+ },
586
+ },
587
+
588
+ {
589
+ name: 'edit_lock_try_steal',
590
+ description:
591
+ 'Ask the session that owns a file to hand it over. Returns immediately with a pending request: it never waits for the holder. The holder is told about it and answers with edit_lock_reply; release transfers ownership, keep (or silence) leaves it where it is. Do not edit the file before the transfer is granted.',
592
+ parameters: {
593
+ type: 'object',
594
+ properties: {
595
+ file_path: { type: 'string', description: 'The owned file you need, absolute or relative to the session working directory.' },
596
+ },
597
+ required: ['file_path'],
598
+ },
599
+ output: leaseOutput('edit_lock_try_steal'),
600
+ async execute(/** @type {any} */ args, /** @type {any} */ exec) {
601
+ const owner = ownerOf(exec?.agent)
602
+ const cwd = requireCwd(exec)
603
+ const path = absoluteOne(args?.file_path, cwd)
604
+ const short = shortName(path, cwd)
605
+ let reply
606
+ try {
607
+ reply = await client.requestTransfer(path, owner)
608
+ } catch (error) {
609
+ const code = codeOf(error)
610
+ if (code === 'free') return present({ state: 'free', message: `Nobody holds ${short} right now: edit it directly, or claim it with edit_lock_acquire so another session cannot take it mid-work.` }, { state: 'free', path: short })
611
+ if (code === 'self') return present({ state: 'self', message: `This session already owns ${short}; no transfer is needed.` }, { state: 'self', path: short })
612
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'no transfer request was created'), { path: short })
613
+ throw error
614
+ }
615
+ const requestId = text(reply?.requestId) ?? ''
616
+ const holder = reply?.holder ?? {}
617
+ const label = text(holder.label) ?? 'the holder'
618
+ const session = text(holder.session) ?? 'unknown'
619
+ const expiresAt = typeof reply?.expiresAt === 'number' ? reply.expiresAt : Date.now()
620
+ return present({
621
+ state: 'pending',
622
+ request_id: requestId,
623
+ holder: { label, session },
624
+ message: `Request ${requestId} for ${short} is pending with "${label}" (session ${session}). Do not modify ${short} until ownership is granted — a pending request is not an approval, and edits keep being refused meanwhile. The holder answers with edit_lock_reply (release hands it over, keep keeps it); an unanswered request expires in about ${secondsUntil(expiresAt, Date.now())}s and ownership stays where it is. A message will tell you how it settled.`,
625
+ }, { state: 'pending', path: short, requestId, holder: { label, session }, expiresAt })
626
+ },
627
+ },
628
+
629
+ {
630
+ name: REPLY_TOOL,
631
+ description:
632
+ 'Answer a pending ownership request another session addressed to this session. Only the current holder can answer, this tool exists only while a request is pending, and stale or already-settled requests are refused. "release" hands the path over (this session stops owning it); "keep" leaves ownership unchanged. Not answering also leaves ownership unchanged.',
633
+ parameters: {
634
+ type: 'object',
635
+ properties: {
636
+ request_id: { type: 'string', description: 'The request_id from the ownership request message.' },
637
+ decision: { type: 'string', enum: ['release', 'keep'], description: 'release to hand the path over, keep to hold on to it.' },
638
+ },
639
+ required: ['request_id', 'decision'],
640
+ },
641
+ output: leaseOutput(REPLY_TOOL),
642
+ async execute(/** @type {any} */ args, /** @type {any} */ exec) {
643
+ const owner = ownerOf(exec?.agent)
644
+ const requestId = text(args?.request_id)
645
+ if (requestId === undefined) throw new Error('request_id must be a non-empty string')
646
+ const decision = args?.decision === 'release' ? 'release' : args?.decision === 'keep' ? 'keep' : undefined
647
+ if (decision === undefined) throw new Error('decision must be "release" or "keep"')
648
+ let reply
649
+ try {
650
+ reply = await client.reply({ requestId, owner, decision })
651
+ } catch (error) {
652
+ const code = codeOf(error)
653
+ if (code === 'expired') throw new Error(`Request ${requestId} has expired and ownership is unchanged. The requester can ask again later if it still needs the path.`)
654
+ if (code === 'stale') throw new Error(`Request ${requestId} is stale: ownership changed before the answer arrived, so nothing was transferred.`)
655
+ if (code === 'not-holder') throw new Error(`Request ${requestId} is not addressed to this session; only the current holder can answer it.`)
656
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'the answer was not delivered'), { requestId, decision })
657
+ throw error
658
+ }
659
+ const state = text(reply?.state) ?? 'declined'
660
+ if (state === 'transferred') {
661
+ // Ownership moved: the caller may have more tool calls queued for that
662
+ // path, so this reaches the model as a message rather than only as a
663
+ // result it may have already reasoned past.
664
+ deliverNotice(exec?.agent, `Ownership handed over: request ${requestId} is settled and this session no longer holds that path. Claim it again with edit_lock_acquire if you still need it.`)
665
+ }
666
+ return present({
667
+ request_id: requestId,
668
+ decision,
669
+ state,
670
+ message: state === 'transferred'
671
+ ? `Request ${requestId}: transferred. The requester owns the path now; this session no longer holds it.`
672
+ : `Request ${requestId}: declined. Ownership is unchanged and the path is still this session's.`,
673
+ }, { state: state === 'transferred' ? 'transferred' : 'declined', requestId, decision })
674
+ },
675
+ },
676
+
677
+ {
678
+ name: 'edit_lock_status',
679
+ description:
680
+ 'Read-only ownership report: the paths this session holds, and the sessions that currently hold paths in this working directory. Grants nothing and changes nothing. Ownership is arbitrated centrally, so the answer covers other processes too.',
681
+ parameters: { type: 'object', properties: {}, required: [] },
682
+ output: leaseOutput('edit_lock_status'),
683
+ async execute(/** @type {any} */ _args, /** @type {any} */ exec) {
684
+ const owner = ownerOf(exec?.agent)
685
+ const cwd = requireCwd(exec)
686
+ let reply
687
+ try {
688
+ reply = await client.status({ scope: cwd })
689
+ } catch (error) {
690
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'no ownership could be read'), {})
691
+ throw error
692
+ }
693
+ const leases = Array.isArray(reply?.leases) ? reply.leases : []
694
+ const at = Date.now()
695
+ const mine = leases.filter((/** @type {any} */ lease) => text(lease?.owner?.id) === owner.id || text(lease?.owner?.session) === owner.session)
696
+ const others = leases.filter((/** @type {any} */ lease) => !mine.includes(lease))
697
+ const lines = []
698
+ /** @type {{ path: string, until: number, retained: boolean }[]} */
699
+ const shownMine = []
700
+ /** @type {{ path: string, label: string, session: string, until: number, lapsed: boolean }[]} */
701
+ const shownOthers = []
702
+ lines.push(mine.length > 0 ? `Held by this session (${mine.length}):` : 'This session holds nothing.')
703
+ for (const lease of mine) {
704
+ const until = retainedUntil(lease)
705
+ const expiresAt = typeof lease?.expiresAt === 'number' ? lease.expiresAt : at
706
+ const validity = until === undefined
707
+ ? `valid for about ${secondsUntil(expiresAt, at)}s`
708
+ : `retained until ${instant(until)}`
709
+ shownMine.push({ path: shortName(String(lease?.path ?? ''), cwd), until: until ?? expiresAt, retained: until !== undefined })
710
+ lines.push(`- ${shortName(String(lease?.path ?? ''), cwd)} — ${validity}`)
711
+ }
712
+ if (others.length > 0) {
713
+ lines.push(`Held by other sessions here (${others.length}):`)
714
+ for (const lease of others) {
715
+ const holder = lease?.owner ?? {}
716
+ const label = text(holder.label) ?? 'another session'
717
+ const session = text(holder.session) ?? 'unknown'
718
+ const target = Math.max(typeof lease?.expiresAt === 'number' ? lease.expiresAt : 0, typeof lease?.holdUntil === 'number' ? lease.holdUntil : 0)
719
+ shownOthers.push({ path: shortName(String(lease?.path ?? ''), cwd), label, session, until: target, lapsed: !(target > at) })
720
+ lines.push(`- ${shortName(String(lease?.path ?? ''), cwd)} — "${label}" (session ${session}), ${target > at ? `about ${secondsUntil(target, at)}s left` : 'lapsed, claimable'}`)
721
+ }
722
+ }
723
+ return present(
724
+ { mine: mine.map((/** @type {any} */ lease) => shortName(String(lease?.path ?? ''), cwd)), message: lines.join('\n') },
725
+ { state: 'status', at, mine: shownMine, others: shownOthers },
726
+ )
727
+ },
728
+ },
729
+
730
+ {
731
+ name: WATCH_TOOL,
732
+ description:
733
+ 'Wait for the next release of one contested path without polling it. Use it after an acquisition conflict or a declined/expired transfer request. The answer says whether anything is actually pending: a path that is already free or already yours returns that immediately and keeps no subscription. While it is held by another session, this subscribes once for this session and the next release arrives as a message; a release is only an opportunity to call edit_lock_acquire, never a grant of ownership, and another waiting session may claim it first. Repeating the call is idempotent.',
734
+ parameters: {
735
+ type: 'object',
736
+ properties: {
737
+ file_path: { type: 'string', description: 'The contested file to watch for a release, absolute or relative to the session working directory.' },
738
+ },
739
+ required: ['file_path'],
740
+ },
741
+ output: leaseOutput(WATCH_TOOL),
742
+ async execute(/** @type {any} */ args, /** @type {any} */ exec) {
743
+ const owner = ownerOf(exec?.agent)
744
+ const cwd = requireCwd(exec)
745
+ const path = absoluteOne(args?.file_path, cwd)
746
+ const short = shortName(path, cwd)
747
+ // Every answer below carries the watched short path for the card, the
748
+ // degraded ones included (their result object has no path field).
749
+ const settle = (/** @type {any} */ value) => present(value, { ...derivedFields(WATCH_TOOL, value), path: short })
750
+ // Registration comes FIRST (D2): a release that lands between the
751
+ // registration and the authoritative read is caught by the event
752
+ // callback, and one that landed before it is caught by the read below.
753
+ // The opposite order would leave a permanent hole for that release.
754
+ const entry = watches.register(owner.session, path, mount, exec?.agent)
755
+
756
+ /** @type {any} */
757
+ let reply
758
+ try {
759
+ // ONE read per entry: an overlapping call for the same session and
760
+ // path joins this flight instead of running its own, so both calls
761
+ // judge the same answer and the same entry object.
762
+ reply = await watches.shareStatus(entry, () => client.status({ path }))
763
+ } catch (error) {
764
+ // A terminal outcome wins over any failure (D2): a release, a reset or
765
+ // a transfer that landed while the read was in flight IS the answer,
766
+ // and its pending notice must survive. A late rejection may only
767
+ // cancel a plain wait.
768
+ const terminal = terminalOutcome(entry, short)
769
+ if (terminal !== undefined) return settle(terminal)
770
+ // No authoritative answer means no subscription: never leave a
771
+ // record that could wait forever for a release nobody described.
772
+ // The removal is conditional on THIS entry still being active, so a
773
+ // late caller can never cancel a newer episode.
774
+ watches.cancel(owner.session, path, entry)
775
+ if (isTransport(error)) return settle(unavailable(error, 'no release subscription was created'))
776
+ throw error
777
+ }
778
+
779
+ // Same precedence on the success path: the entry may have been consumed
780
+ // or ended while the read was in flight, whatever the read now says.
781
+ const terminal = terminalOutcome(entry, short)
782
+ if (terminal !== undefined) return settle(terminal)
783
+
784
+ const leases = Array.isArray(reply?.leases) ? reply.leases : []
785
+ if (leases.length === 0) {
786
+ watches.cancel(owner.session, path, entry)
787
+ return settle({
788
+ state: 'free',
789
+ file_path: short,
790
+ message: `Nobody holds ${short} right now, so no release subscription was kept. Claim it with edit_lock_acquire (or just edit it) before working on it.`,
791
+ })
792
+ }
793
+ const holder = leases[0]?.owner ?? {}
794
+ const mine = text(holder.id) === owner.id || text(holder.session) === owner.session
795
+ if (mine) {
796
+ // `self` is terminal, not merely cancelled: an overlapping call that
797
+ // shares this entry must read the same verdict instead of reporting a
798
+ // wait that no longer exists.
799
+ watches.cancel(owner.session, path, entry, 'self')
800
+ return settle({
801
+ state: 'self',
802
+ file_path: short,
803
+ message: `This session already owns ${short}, so no release subscription was kept and there is nothing to wait for.`,
804
+ })
805
+ }
806
+ if (typeof client.supportsEvent !== 'function' || client.supportsEvent('released') !== true) {
807
+ watches.cancel(owner.session, path, entry)
808
+ return settle({
809
+ degraded: true,
810
+ code: 'release-events-unsupported',
811
+ message: `The connected arbiter does not announce release events (release-events-unsupported), so no release subscription was created for ${short}. Only this subscription is unavailable: conflicts, managed edits and negotiation behave exactly as before, and this tool can be used again once the arbiter restarts with the capability.`,
812
+ })
813
+ }
814
+ // `watching` is only claimed while THIS entry is still the active one:
815
+ // an overlapping call or a disposal may have ended the wait while the
816
+ // read was in flight, and a stale entry must never read as a live wait.
817
+ if (!stillWatching(watches, entry)) return settle(terminalOutcome(entry, short) ?? cancelledOutcome(short))
818
+ const label = text(holder.label) ?? 'another session'
819
+ const session = text(holder.session) ?? 'unknown'
820
+ return settle({
821
+ state: 'watching',
822
+ file_path: short,
823
+ holder: { label, session },
824
+ message: `Watching ${short} for its next release; "${label}" (session ${session}) holds it now. A release notice will arrive as a message: it means only that the path is free to compete for, so call edit_lock_acquire then. It is not an ownership grant and it reserves nothing — another waiting session may claim the path first, and the ordinary holder conflict applies.`,
825
+ })
826
+ },
827
+ },
828
+ ]
829
+ }
830
+
831
+ /**
832
+ * The one non-verdict answer: the arbiter could not be reached, so nothing was
833
+ * decided. It is explicitly not a refusal — managed edits proceed unowned while
834
+ * the arbiter is down (D7), and telling the model otherwise would stop work for
835
+ * an infrastructure blip.
836
+ * @param {any} error
837
+ * @param {string} outcome
838
+ * @returns {{ degraded: true, code: string, message: string }}
839
+ */
840
+ function unavailable(error, outcome) {
841
+ const code = codeOf(error)
842
+ return {
843
+ degraded: true,
844
+ code,
845
+ message: `The ownership arbiter is unreachable (${code}), so ${outcome}. This is not a conflict: managed edits proceed without ownership while the arbiter is down, and nothing needs clearing afterwards. Continue with the work.`,
846
+ }
847
+ }