weir-harness 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/lib/client.capability-badge.js +2 -0
  2. package/lib/client.capability-manager.js +46 -179
  3. package/lib/client.capability-model.js +428 -3
  4. package/lib/client.file-lease-tool-model.js +189 -0
  5. package/lib/client.file-lease-tool-view.js +434 -0
  6. package/lib/client.js +105 -7
  7. package/lib/client.mcp-manager.js +563 -0
  8. package/package.json +1 -1
  9. package/skills/mcp-server-setup/SKILL.md +124 -0
  10. package/src/capabilities/apply-engine.js +18 -4
  11. package/src/capabilities/capability-remote.js +296 -45
  12. package/src/capabilities/initial-selection.js +3 -2
  13. package/src/capabilities/lifecycle-preload.js +69 -11
  14. package/src/capabilities/lifecycle-snapshot.js +127 -39
  15. package/src/capabilities/mcp-admission.js +2 -2
  16. package/src/capabilities/mcp-config.js +344 -0
  17. package/src/capabilities/mcp-credentials.js +180 -0
  18. package/src/capabilities/mcp-diagnostics.js +169 -0
  19. package/src/capabilities/mcp-drain.js +18 -4
  20. package/src/capabilities/mcp-facade.js +40 -9
  21. package/src/capabilities/mcp-gate.js +51 -52
  22. package/src/capabilities/mcp-install-tool.js +276 -0
  23. package/src/capabilities/mcp-manager.js +575 -146
  24. package/src/capabilities/mcp-mount.js +220 -79
  25. package/src/capabilities/mcp-operations.js +359 -0
  26. package/src/capabilities/mcp-process.js +43 -0
  27. package/src/capabilities/mcp-registry.js +74 -60
  28. package/src/capabilities/mcp-runtime.js +103 -0
  29. package/src/capabilities/read-payloads.js +8 -3
  30. package/src/capabilities/skill-applicability.js +185 -0
  31. package/src/capabilities/skill-builtin-migration.js +2 -1
  32. package/src/capabilities/skill-selection-plugin.js +170 -57
  33. package/src/file-lease/client.js +268 -35
  34. package/src/file-lease/index.js +4 -0
  35. package/src/file-lease/plugin.js +239 -15
  36. package/src/file-lease/tools.js +426 -38
  37. package/src/file-lease/watch.js +473 -0
  38. package/src/intent-gate/index.js +29 -12
  39. package/src/intent-gate/matcher.js +36 -2
  40. package/src/lease-broker/protocol.js +3 -2
  41. package/src/lease-broker/server.js +6 -3
  42. package/src/lease-broker/state.js +68 -11
  43. package/src/process/index.js +4 -2
  44. package/src/shared/audit.js +3 -0
  45. package/src/shared/child-scope.js +2 -0
  46. package/src/worktree/rules.js +1 -1
@@ -1,6 +1,6 @@
1
1
  // The model-facing tool surface of the file-lease feature (design D4, D5, D13).
2
2
  //
3
- // Six pure definitions. They own no state: the arbiter is the only authority
3
+ // Seven pure definitions. They own no state: the arbiter is the only authority
4
4
  // (D6/D8), so a tool translates a model intent into one protocol call and
5
5
  // translates the verdict back into something a model can act on. Nothing here
6
6
  // re-derives ownership, remembers it, or waits for it.
@@ -18,9 +18,18 @@
18
18
  // short paths against the session cwd on the way in and render short names
19
19
  // relative to that same cwd on the way out. An absolute path never lands in a
20
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.
21
29
 
22
- import { basename, isAbsolute, relative, resolve } from 'node:path'
30
+ import { basename, relative, resolve } from 'node:path'
23
31
  import { isLeaseConflict } from './client.js'
32
+ import { createWatchCoordinator } from './watch.js'
24
33
 
25
34
  /** Retention bound in minutes; the protocol clamps here and so does the tool. */
26
35
  export const MAX_HOLD_MINUTES = 60
@@ -28,17 +37,191 @@ export const MAX_HOLD_MINUTES = 60
28
37
  /** The negotiation reply tool: registered only while a request is pending. */
29
38
  export const REPLY_TOOL = 'edit_lock_reply'
30
39
 
40
+ /** The release-subscription tool: one path, one in-memory subscription. */
41
+ export const WATCH_TOOL = 'edit_lock_watch'
42
+
31
43
  /** Default retention when the model asks for one without a length. */
32
44
  export const DEFAULT_HOLD_MINUTES = MAX_HOLD_MINUTES
33
45
 
34
46
  /**
35
- * Single-field text rendering, the shape every tool in this bundle uses.
36
- * @param {string} field
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
37
213
  */
38
- function textOutput(field) {
214
+ function leaseOutput(tool) {
39
215
  return {
40
216
  schema: { type: 'object' },
41
- render: (/** @type {any} */ _args, /** @type {any} */ result) => [{ type: 'text', text: result[field] }],
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
+ },
42
225
  }
43
226
  }
44
227
 
@@ -93,8 +276,29 @@ export function shortName(path, cwd) {
93
276
  }
94
277
 
95
278
  /**
96
- * Resolve the model's path batch to the absolute, de-duplicated list the
97
- * protocol accepts.
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.
98
302
  * @param {unknown} paths
99
303
  * @param {string} cwd
100
304
  * @returns {string[]}
@@ -106,13 +310,15 @@ function absoluteBatch(paths, cwd) {
106
310
  for (const entry of paths) {
107
311
  const raw = text(entry)
108
312
  if (raw === undefined) throw new Error('every entry of paths must be a non-empty string')
109
- const absolute = isAbsolute(raw) ? raw : resolve(cwd, raw)
313
+ const absolute = canonicalPath(raw, cwd)
110
314
  if (!out.includes(absolute)) out.push(absolute)
111
315
  }
112
316
  return out
113
317
  }
114
318
 
115
319
  /**
320
+ * The single-path form of {@link absoluteBatch}: normalize before the path can
321
+ * reach the arbiter or the subscription coordinator.
116
322
  * @param {unknown} filePath
117
323
  * @param {string} cwd
118
324
  * @returns {string}
@@ -120,7 +326,7 @@ function absoluteBatch(paths, cwd) {
120
326
  function absoluteOne(filePath, cwd) {
121
327
  const raw = text(filePath)
122
328
  if (raw === undefined) throw new Error('file_path must be a non-empty string')
123
- return isAbsolute(raw) ? raw : resolve(cwd, raw)
329
+ return canonicalPath(raw, cwd)
124
330
  }
125
331
 
126
332
  /** `2026-05-05T12:00:00.000Z` for a validity instant. @param {number} at epoch ms */
@@ -177,6 +383,70 @@ export function describeConflicts(error, cwd, at) {
177
383
  ].join('\n')
178
384
  }
179
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
+
180
450
  /**
181
451
  * Create the tool definitions.
182
452
  *
@@ -185,10 +455,15 @@ export function describeConflicts(error, cwd, at) {
185
455
  * @param {(agent: any) => { id: string, session: string, label: string }} options.ownerOf
186
456
  * @param {(agent: any, text: string) => any} [options.deliverNotice] model-facing
187
457
  * notice channel, used only where a tool result cannot carry the outcome
188
- * @returns {any[]} six definitions, `REPLY_TOOL` included (the plugin decides
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
189
463
  * when that one becomes visible)
190
464
  */
191
- export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} }) {
465
+ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {}, watchCoordinator = createWatchCoordinator(), mount }) {
466
+ const watches = watchCoordinator
192
467
  return [
193
468
  {
194
469
  name: 'edit_lock_acquire',
@@ -201,7 +476,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
201
476
  },
202
477
  required: ['paths'],
203
478
  },
204
- output: textOutput('message'),
479
+ output: leaseOutput('edit_lock_acquire'),
205
480
  async execute(/** @type {any} */ args, /** @type {any} */ exec) {
206
481
  const owner = ownerOf(exec?.agent)
207
482
  const cwd = requireCwd(exec)
@@ -210,15 +485,16 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
210
485
  const reply = await client.acquire(owner, paths, cwd)
211
486
  const granted = Array.isArray(reply?.granted) ? reply.granted : []
212
487
  const claimed = granted.map((/** @type {any} */ lease) => shortName(String(lease?.path ?? ''), cwd))
213
- return {
488
+ const result = {
214
489
  paths: claimed,
215
490
  message: claimed.length > 0
216
491
  ? `Claimed ${claimed.length} path(s) for this turn: ${claimed.join(', ')}.`
217
492
  : 'Nothing was claimed.',
218
493
  }
494
+ return present(result, { state: 'claimed', paths: [...claimed] })
219
495
  } catch (error) {
220
496
  if (isLeaseConflict(error)) throw new Error(describeConflicts(error, cwd, Date.now()))
221
- if (isTransport(error)) return unavailable(error, 'nothing was claimed')
497
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'nothing was claimed'), { paths: paths.map((path) => shortName(path, cwd)) })
222
498
  throw error
223
499
  }
224
500
  },
@@ -235,7 +511,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
235
511
  },
236
512
  required: ['paths'],
237
513
  },
238
- output: textOutput('message'),
514
+ output: leaseOutput('edit_lock_release'),
239
515
  async execute(/** @type {any} */ args, /** @type {any} */ exec) {
240
516
  const owner = ownerOf(exec?.agent)
241
517
  const cwd = requireCwd(exec)
@@ -244,7 +520,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
244
520
  try {
245
521
  reply = await client.request('release', { owner, paths })
246
522
  } catch (error) {
247
- if (isTransport(error)) return unavailable(error, 'the release was not confirmed')
523
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'the release was not confirmed'), { paths: paths.map((path) => shortName(path, cwd)) })
248
524
  throw error
249
525
  }
250
526
  const released = (Array.isArray(reply?.released) ? reply.released : []).map((/** @type {any} */ path) => shortName(String(path), cwd))
@@ -252,13 +528,13 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
252
528
  const lines = []
253
529
  lines.push(released.length > 0 ? `Released ${released.length} path(s): ${released.join(', ')}.` : 'Released nothing.')
254
530
  if (notOwned.length > 0) lines.push(`Not held by this session, so there was nothing to release: ${notOwned.join(', ')}.`)
255
- return { released, notOwned, message: lines.join('\n') }
531
+ return present({ released, notOwned, message: lines.join('\n') }, { state: 'released', released: [...released], notOwned: [...notOwned] })
256
532
  },
257
533
  },
258
534
 
259
535
  {
260
536
  name: 'edit_lock_hold',
261
- 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 and nothing is announced.`,
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.`,
262
538
  parameters: {
263
539
  type: 'object',
264
540
  properties: {
@@ -270,7 +546,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
270
546
  },
271
547
  required: ['paths'],
272
548
  },
273
- output: textOutput('message'),
549
+ output: leaseOutput('edit_lock_hold'),
274
550
  async execute(/** @type {any} */ args, /** @type {any} */ exec) {
275
551
  const owner = ownerOf(exec?.agent)
276
552
  const cwd = requireCwd(exec)
@@ -286,23 +562,26 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
286
562
  if (code === 'not-owned') {
287
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.`)
288
564
  }
289
- if (isTransport(error)) return unavailable(error, 'no retention was recorded')
565
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'no retention was recorded'), { paths: paths.map((path) => shortName(path, cwd)) })
290
566
  throw error
291
567
  }
292
568
  const held = Array.isArray(reply?.held) ? reply.held : []
293
569
  const at = Date.now()
570
+ /** @type {{ path: string, holdUntil: number }[]} */
571
+ const shown = []
294
572
  const entries = held.map((/** @type {any} */ row) => {
295
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 })
296
575
  return `${shortName(String(row?.path ?? ''), cwd)} until ${instant(until)} (about ${Math.ceil(secondsUntil(until, at) / 60)}m from now)`
297
576
  })
298
- return {
577
+ return present({
299
578
  // Short names only: an absolute path never appears in a tool result,
300
579
  // not even in a structured field the model also reads.
301
580
  held: held.map((/** @type {any} */ row) => ({ path: shortName(String(row?.path ?? ''), cwd), holdUntil: row?.holdUntil ?? null })),
302
581
  message: entries.length > 0
303
- ? `Kept ${entries.length} path(s) past this turn: ${entries.join(', ')}. At most ${MAX_HOLD_MINUTES} minutes per request; a deadline that passes announces nothing — the paths simply become available.`
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.`
304
583
  : 'Nothing was retained.',
305
- }
584
+ }, { state: 'held', held: shown })
306
585
  },
307
586
  },
308
587
 
@@ -317,7 +596,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
317
596
  },
318
597
  required: ['file_path'],
319
598
  },
320
- output: textOutput('message'),
599
+ output: leaseOutput('edit_lock_try_steal'),
321
600
  async execute(/** @type {any} */ args, /** @type {any} */ exec) {
322
601
  const owner = ownerOf(exec?.agent)
323
602
  const cwd = requireCwd(exec)
@@ -328,9 +607,9 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
328
607
  reply = await client.requestTransfer(path, owner)
329
608
  } catch (error) {
330
609
  const code = codeOf(error)
331
- if (code === 'free') return { 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.` }
332
- if (code === 'self') return { state: 'self', message: `This session already owns ${short}; no transfer is needed.` }
333
- if (isTransport(error)) return unavailable(error, 'no transfer request was created')
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 })
334
613
  throw error
335
614
  }
336
615
  const requestId = text(reply?.requestId) ?? ''
@@ -338,12 +617,12 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
338
617
  const label = text(holder.label) ?? 'the holder'
339
618
  const session = text(holder.session) ?? 'unknown'
340
619
  const expiresAt = typeof reply?.expiresAt === 'number' ? reply.expiresAt : Date.now()
341
- return {
620
+ return present({
342
621
  state: 'pending',
343
622
  request_id: requestId,
344
623
  holder: { label, session },
345
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.`,
346
- }
625
+ }, { state: 'pending', path: short, requestId, holder: { label, session }, expiresAt })
347
626
  },
348
627
  },
349
628
 
@@ -359,7 +638,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
359
638
  },
360
639
  required: ['request_id', 'decision'],
361
640
  },
362
- output: textOutput('message'),
641
+ output: leaseOutput(REPLY_TOOL),
363
642
  async execute(/** @type {any} */ args, /** @type {any} */ exec) {
364
643
  const owner = ownerOf(exec?.agent)
365
644
  const requestId = text(args?.request_id)
@@ -374,7 +653,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
374
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.`)
375
654
  if (code === 'stale') throw new Error(`Request ${requestId} is stale: ownership changed before the answer arrived, so nothing was transferred.`)
376
655
  if (code === 'not-holder') throw new Error(`Request ${requestId} is not addressed to this session; only the current holder can answer it.`)
377
- if (isTransport(error)) return unavailable(error, 'the answer was not delivered')
656
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'the answer was not delivered'), { requestId, decision })
378
657
  throw error
379
658
  }
380
659
  const state = text(reply?.state) ?? 'declined'
@@ -384,14 +663,14 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
384
663
  // result it may have already reasoned past.
385
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.`)
386
665
  }
387
- return {
666
+ return present({
388
667
  request_id: requestId,
389
668
  decision,
390
669
  state,
391
670
  message: state === 'transferred'
392
671
  ? `Request ${requestId}: transferred. The requester owns the path now; this session no longer holds it.`
393
672
  : `Request ${requestId}: declined. Ownership is unchanged and the path is still this session's.`,
394
- }
673
+ }, { state: state === 'transferred' ? 'transferred' : 'declined', requestId, decision })
395
674
  },
396
675
  },
397
676
 
@@ -400,7 +679,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
400
679
  description:
401
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.',
402
681
  parameters: { type: 'object', properties: {}, required: [] },
403
- output: textOutput('message'),
682
+ output: leaseOutput('edit_lock_status'),
404
683
  async execute(/** @type {any} */ _args, /** @type {any} */ exec) {
405
684
  const owner = ownerOf(exec?.agent)
406
685
  const cwd = requireCwd(exec)
@@ -408,7 +687,7 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
408
687
  try {
409
688
  reply = await client.status({ scope: cwd })
410
689
  } catch (error) {
411
- if (isTransport(error)) return unavailable(error, 'no ownership could be read')
690
+ if (isTransport(error)) return presentDegraded(unavailable(error, 'no ownership could be read'), {})
412
691
  throw error
413
692
  }
414
693
  const leases = Array.isArray(reply?.leases) ? reply.leases : []
@@ -416,12 +695,18 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
416
695
  const mine = leases.filter((/** @type {any} */ lease) => text(lease?.owner?.id) === owner.id || text(lease?.owner?.session) === owner.session)
417
696
  const others = leases.filter((/** @type {any} */ lease) => !mine.includes(lease))
418
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 = []
419
702
  lines.push(mine.length > 0 ? `Held by this session (${mine.length}):` : 'This session holds nothing.')
420
703
  for (const lease of mine) {
421
704
  const until = retainedUntil(lease)
705
+ const expiresAt = typeof lease?.expiresAt === 'number' ? lease.expiresAt : at
422
706
  const validity = until === undefined
423
- ? `valid for about ${secondsUntil(typeof lease?.expiresAt === 'number' ? lease.expiresAt : at, at)}s`
707
+ ? `valid for about ${secondsUntil(expiresAt, at)}s`
424
708
  : `retained until ${instant(until)}`
709
+ shownMine.push({ path: shortName(String(lease?.path ?? ''), cwd), until: until ?? expiresAt, retained: until !== undefined })
425
710
  lines.push(`- ${shortName(String(lease?.path ?? ''), cwd)} — ${validity}`)
426
711
  }
427
712
  if (others.length > 0) {
@@ -431,10 +716,113 @@ export function createLeaseTools({ client, ownerOf, deliverNotice = () => {} })
431
716
  const label = text(holder.label) ?? 'another session'
432
717
  const session = text(holder.session) ?? 'unknown'
433
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) })
434
720
  lines.push(`- ${shortName(String(lease?.path ?? ''), cwd)} — "${label}" (session ${session}), ${target > at ? `about ${secondsUntil(target, at)}s left` : 'lapsed, claimable'}`)
435
721
  }
436
722
  }
437
- return { mine: mine.map((/** @type {any} */ lease) => shortName(String(lease?.path ?? ''), cwd)), message: lines.join('\n') }
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
+ })
438
826
  },
439
827
  },
440
828
  ]