weir-harness 1.2.3 → 2.0.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 (81) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.file-lease-badge.js +307 -0
  3. package/lib/client.js +20 -30
  4. package/lib/client.settings-page.js +10 -34
  5. package/package.json +2 -2
  6. package/src/capabilities/store/checked-contract.d.ts +2 -2
  7. package/src/capabilities/store/fs-adapter.js +2 -3
  8. package/src/context-guard/index.js +2 -2
  9. package/src/delegate/supervision-mount.js +5 -5
  10. package/src/delegate/tool.js +2 -10
  11. package/src/file-lease/client.js +1066 -0
  12. package/src/file-lease/index.js +30 -0
  13. package/src/file-lease/lineage.js +207 -0
  14. package/src/file-lease/node-contracts.d.ts +42 -0
  15. package/src/file-lease/plugin.js +789 -0
  16. package/src/file-lease/singleton.js +155 -0
  17. package/src/file-lease/spawn.js +62 -0
  18. package/src/file-lease/tools.js +459 -0
  19. package/src/file-lease/view.js +144 -0
  20. package/src/hashline-edit/index.js +76 -42
  21. package/src/hashline-edit/write-tool.js +90 -0
  22. package/src/lease-broker/entry.js +98 -0
  23. package/src/lease-broker/node-contracts.d.ts +63 -0
  24. package/src/lease-broker/protocol.js +236 -0
  25. package/src/lease-broker/server.js +399 -0
  26. package/src/lease-broker/state.js +425 -0
  27. package/src/lsp/tools.js +137 -27
  28. package/src/settings/index.js +2 -44
  29. package/src/settings/sections.js +2 -24
  30. package/src/shared/audit.js +6 -1
  31. package/src/todo-driver/index.js +1 -6
  32. package/src/worktree/cards.js +6 -21
  33. package/src/worktree/lane-core.js +1 -19
  34. package/src/worktree/lane-workflows.js +11 -32
  35. package/lib/client.edit-lock-maintenance.js +0 -592
  36. package/lib/client.edit-lock-panel.js +0 -405
  37. package/src/edit-lock/adapter.js +0 -57
  38. package/src/edit-lock/admin-ledger.js +0 -149
  39. package/src/edit-lock/admin-recovery.js +0 -151
  40. package/src/edit-lock/admission.js +0 -82
  41. package/src/edit-lock/binding-attestation.js +0 -29
  42. package/src/edit-lock/binding-guidance.js +0 -72
  43. package/src/edit-lock/binding-machine.js +0 -312
  44. package/src/edit-lock/call-context.js +0 -65
  45. package/src/edit-lock/derived-facts.js +0 -67
  46. package/src/edit-lock/domain.js +0 -165
  47. package/src/edit-lock/domains.js +0 -170
  48. package/src/edit-lock/host.js +0 -149
  49. package/src/edit-lock/incarnations.js +0 -150
  50. package/src/edit-lock/index.js +0 -1303
  51. package/src/edit-lock/inspect.js +0 -219
  52. package/src/edit-lock/lifecycle.js +0 -579
  53. package/src/edit-lock/limits.js +0 -61
  54. package/src/edit-lock/maintenance.js +0 -310
  55. package/src/edit-lock/manager.js +0 -875
  56. package/src/edit-lock/negotiation.js +0 -132
  57. package/src/edit-lock/operation-history.js +0 -214
  58. package/src/edit-lock/peer-client.js +0 -86
  59. package/src/edit-lock/peer-transport.js +0 -76
  60. package/src/edit-lock/peer.js +0 -87
  61. package/src/edit-lock/publisher.js +0 -191
  62. package/src/edit-lock/read-authority.js +0 -101
  63. package/src/edit-lock/recovery.js +0 -139
  64. package/src/edit-lock/remote.js +0 -362
  65. package/src/edit-lock/request-binding.js +0 -42
  66. package/src/edit-lock/request-data.js +0 -40
  67. package/src/edit-lock/reservation.js +0 -301
  68. package/src/edit-lock/reserved-runtime.js +0 -45
  69. package/src/edit-lock/resource-identity.js +0 -130
  70. package/src/edit-lock/runtime.js +0 -145
  71. package/src/edit-lock/settle.js +0 -144
  72. package/src/edit-lock/snapshot.js +0 -175
  73. package/src/edit-lock/stale-sweep.js +0 -120
  74. package/src/edit-lock/state.js +0 -462
  75. package/src/edit-lock/store.js +0 -327
  76. package/src/edit-lock/tool-scope.js +0 -29
  77. package/src/edit-lock/view.js +0 -170
  78. package/src/edit-lock/write-scopes.js +0 -48
  79. package/src/edit-lock/write-tool.js +0 -45
  80. package/src/shared/delegate-edit-lifecycle.js +0 -36
  81. package/src/worktree/authority.js +0 -56
@@ -0,0 +1,789 @@
1
+ // The preset entry point of the file-lease feature (design D4, D5, D10, D11).
2
+ //
3
+ // This module owns exactly three things the arbiter cannot know:
4
+ //
5
+ // 1. The tool surface: five ownership tools that always exist, plus the
6
+ // negotiation reply tool that exists only while a request is pending. A
7
+ // tool nobody can answer is noise; a tool that vanishes mid-turn makes a
8
+ // late answer report "unknown tool", so the reply tool is retired when its
9
+ // pending set empties and no later than the turn boundary.
10
+ // 2. The turn lifecycle (D10): `turn/end` releases what the turn acquired
11
+ // without releasing retention, `agent/disposed` releases everything,
12
+ // retention included. Both are best-effort — a missed release is covered by
13
+ // the lease validity, which is why neither path may ever throw.
14
+ // 3. Wake delivery (D4): a transfer request addressed to a session that is
15
+ // idle in this process opens ONE turn so it can answer; a request addressed
16
+ // to a session that is mid-turn simply lands inside that turn and costs no
17
+ // extra wake. A request for a session that is not here (another process, a
18
+ // disposed agent) is silently dropped: the arbiter will expire it.
19
+ //
20
+ // The transport is process-wide (D8/D11). Two preset mounts of this bundle
21
+ // share ONE client per socket path, and unmounting must never close it — that
22
+ // would rip the connection out from under the sibling mount. The default
23
+ // client is therefore a module-level lazy singleton, and the injected `client`
24
+ // option is the seam tests (and any future embedder) use instead.
25
+ //
26
+ // Nothing here imports `@deepseek-ai/*`: services arrive through `ctx`.
27
+
28
+ import { createLineageResolver } from './lineage.js'
29
+ import { userTextMessage } from '../shared/user-message.js'
30
+ import { createLeaseTools, REPLY_TOOL, shortName } from './tools.js'
31
+ import { AUDIT_TYPES, createAudit } from '../shared/audit.js'
32
+ import { getSharedLeaseClient } from './singleton.js'
33
+ import { buildLeaseView } from './view.js'
34
+
35
+ /** Plugin name (composition identity only; this row publishes no service). */
36
+ const PLUGIN_NAME = 'weir-file-lease'
37
+
38
+ /**
39
+ * The one read-only HTTP route of the feature (design D13). The path keeps the
40
+ * pre-rebuild name on purpose: the client chunk that reads it, the feature doc
41
+ * and the spec all name `/api/weir-edit-lock/view`, and a rename would buy
42
+ * nothing a reader could see.
43
+ */
44
+ const VIEW_PATH = '/api/weir-edit-lock/view'
45
+
46
+ /**
47
+ * The host's exact-duplicate-route refusal, and nothing else.
48
+ *
49
+ * Several preset mounts of this bundle live in ONE process (D11), while `fetch`
50
+ * routes are process-wide: the first mount owns the route and the host refuses
51
+ * a later mount's identical registration. Only that exact message is tolerated;
52
+ * every other registration error is a real fault and must surface.
53
+ * @param {unknown} error
54
+ * @returns {boolean}
55
+ */
56
+ function duplicateRouteRegistration(error) {
57
+ return messageOf(error) === `connection: exact Fetch route ${JSON.stringify(VIEW_PATH)} is already registered`
58
+ }
59
+
60
+ /** Producer tag every injected message carries (AGENTS §3.4). */
61
+ const SOURCE_KIND = 'weir-file-lease'
62
+
63
+ /**
64
+ * Request ids this process has already handed to a holder agent.
65
+ *
66
+ * Two preset mounts share ONE client (D11), so both hear the same `request`
67
+ * event; without this a holder would be woken twice for one request, which
68
+ * D4/spec explicitly forbid ("no second wake is spent for the same request").
69
+ * The arbiter allocates a monotonic request id per request and never reuses
70
+ * one, so a value in this set means exactly "this request was delivered".
71
+ * Cleared when the reply window closes (answer, turn end, disposal).
72
+ * @type {WeakMap<object, Set<string>>}
73
+ */
74
+ const noticedByAgent = new WeakMap()
75
+
76
+ /** @param {unknown} value @returns {string | undefined} */
77
+ function text(value) {
78
+ return typeof value === 'string' && value.length > 0 ? value : undefined
79
+ }
80
+
81
+ /**
82
+ * The delivery memo for one holder agent.
83
+ * @param {object} agent
84
+ * @returns {Set<string>}
85
+ */
86
+ function noticedFor(agent) {
87
+ let noticed = noticedByAgent.get(agent)
88
+ if (noticed === undefined) {
89
+ noticed = new Set()
90
+ noticedByAgent.set(agent, noticed)
91
+ }
92
+ return noticed
93
+ }
94
+
95
+ /** @param {unknown} error @returns {string} */
96
+ function messageOf(error) {
97
+ return String(/** @type {any} */ (error)?.message ?? error)
98
+ }
99
+
100
+ /**
101
+ * The session this agent acts for: the durable header id when there is one,
102
+ * else the live session id, else the agent id. Non-empty by construction, and
103
+ * `undefined` only for an agent object with no identity at all — a caller that
104
+ * cannot name a session cannot release one either.
105
+ * @param {any} agent
106
+ * @returns {string | undefined}
107
+ */
108
+ function sessionIdOf(agent) {
109
+ return text(agent?.session?.header?.id) ?? text(agent?.session?.id) ?? text(agent?.id)
110
+ }
111
+
112
+ /** @param {any} agent @returns {string | undefined} */
113
+ function cwdOf(agent) {
114
+ return text(agent?.session?.header?.cwd)
115
+ }
116
+
117
+ /**
118
+ * Resolve `ownerOf(agent)` from the live lineage rule (D9): the lineage id plus
119
+ * the session and label the arbiter only ever formats.
120
+ * @param {() => any} agents lazy accessor for the `agents` service
121
+ * @returns {(agent: any) => { id: string, session: string, label: string }}
122
+ */
123
+ function createOwnerResolver(agents) {
124
+ const resolver = createLineageResolver({
125
+ // The ancestor walk reads one header per hop; a missing registry (a cold or
126
+ // partially mounted host) degrades to "this session is its own lineage".
127
+ sessions: {
128
+ get: (/** @type {string} */ id) => {
129
+ try {
130
+ return agents()?.get?.(id)
131
+ } catch {
132
+ return undefined
133
+ }
134
+ },
135
+ },
136
+ })
137
+ return (agent) => {
138
+ const lineage = resolver.lineageOf(agent)
139
+ return {
140
+ id: lineage,
141
+ session: sessionIdOf(agent) ?? lineage,
142
+ label: resolver.labelOf(agent),
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Deliver one model-facing notice (AGENTS §3.4): `followup` when the wake flag
149
+ * is set and the agent is idle — that single call is what opens the turn the
150
+ * holder needs in order to answer — and `steer` otherwise, which lands inside a
151
+ * running turn instead of spending an extra one. Both channels failing is
152
+ * silence: the negotiation simply expires and audit belongs to the client (D7).
153
+ *
154
+ * @param {any} agent
155
+ * @param {string} text
156
+ * @param {boolean} [wake]
157
+ * @returns {Promise<boolean>} whether a channel accepted the message
158
+ */
159
+ async function defaultDeliver(agent, text, wake = false) {
160
+ const message = userTextMessage(text, SOURCE_KIND)
161
+ // `followup` only when asked to wake an idle agent: a followup to a running
162
+ // agent would queue a second turn, which is exactly the cost D4 forbids.
163
+ const channels = wake && agent?.status === 'idle' ? ['followup', 'steer'] : ['steer', 'followup']
164
+ for (const channel of channels) {
165
+ try {
166
+ const send = agent?.[channel]
167
+ if (typeof send !== 'function') continue
168
+ await send.call(agent, message)
169
+ return true
170
+ } catch {
171
+ // Fall through to the other channel; a total failure stays silent.
172
+ }
173
+ }
174
+ return false
175
+ }
176
+
177
+ /**
178
+ * The request notice addressed to the holder. Model-facing only: it names the
179
+ * requester, names the tool and the request id, and states that inaction keeps
180
+ * ownership. It must never read like something for a human to act on.
181
+ * @param {{ requestId: string, short: string, label: string, fromSession: string, seconds: number }} detail
182
+ * @returns {string}
183
+ */
184
+ function requestText({ requestId, short, label, fromSession, seconds }) {
185
+ return [
186
+ `Session "${label}" (session ${fromSession}) is asking to take over ${short}.`,
187
+ `Answer with edit_lock_reply: request_id "${requestId}", decision "release" to hand it over, or "keep" to hold on to it.`,
188
+ `Nothing moves until you answer — if you do not answer, ownership stays yours and the request expires by itself (about ${seconds}s left).`,
189
+ ].join(' ')
190
+ }
191
+
192
+ /**
193
+ * The result notice addressed to the requester once its request settled.
194
+ * @param {string} state `transferred` | `declined` | `expired`
195
+ * @param {string} requestId
196
+ * @param {string} short
197
+ * @returns {string}
198
+ */
199
+ function settledText(state, requestId, short) {
200
+ if (state === 'transferred') {
201
+ return `Request ${requestId}: ownership of ${short} was transferred to this session. It is yours now — edit it, and claim it again only if it lapses.`
202
+ }
203
+ if (state === 'declined') {
204
+ return `Request ${requestId}: the holder kept ${short}. Ownership is unchanged, and it becomes available when its validity ends — do not retry the same edit blindly.`
205
+ }
206
+ return `Request ${requestId}: no answer arrived for ${short}, so the request expired and ownership is unchanged. It becomes available when its validity ends; ask again later if you still need it.`
207
+ }
208
+
209
+ /**
210
+ * Adapt one degradation record from the lease client to the shared audit
211
+ * channel (design D7, `AUDIT_TYPES.fileLease`).
212
+ *
213
+ * The client calls its sink synchronously at the moment a managed write
214
+ * degrades, and rate limits it itself (one record per process window), so this
215
+ * adapter must never throttle again. `detail.session` is the owning session id;
216
+ * the shared channel takes the LIVE session object so the JSONL mirror lands in
217
+ * that session's workspace. A session this process can no longer resolve still
218
+ * emits the in-process event with `session: null` — audit is log-only and the
219
+ * degraded write proceeds either way.
220
+ *
221
+ * The client's detail is flat (`{code, session, lineage, scope, paths, at}`);
222
+ * the record names the owner under `owner` so a reader never depends on the
223
+ * client's internal field names. `paths` is a COUNT — no path content.
224
+ *
225
+ * @param {(session: any, type: string, data?: unknown) => void} audit the shared audit emitter
226
+ * @param {() => any} agents lazy `agents` service accessor
227
+ * @param {string} kind the degradation kind (`'degraded'`)
228
+ * @param {Record<string, any>} [detail] the client's degradation detail
229
+ */
230
+ function auditLeaseDegradation(audit, agents, kind, detail) {
231
+ try {
232
+ const sessionId = text(detail?.session)
233
+ /** @type {any} */
234
+ let session = null
235
+ if (sessionId !== undefined) {
236
+ try {
237
+ session = agents()?.get?.(sessionId)?.session ?? null
238
+ } catch {
239
+ session = null
240
+ }
241
+ }
242
+ audit(session, AUDIT_TYPES.fileLease, {
243
+ kind,
244
+ ...detail,
245
+ owner: { session: sessionId ?? null, lineage: text(detail?.lineage) ?? null },
246
+ })
247
+ } catch {
248
+ // Audit is log-only: a broken sink must never break a managed write.
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Create one plugin instance. Every collaborator is injectable so a unit test
254
+ * can drive the whole surface with a fake client and a fake ctx: the default
255
+ * client is the process-wide singleton and the default cortex is a live cordis
256
+ * context, neither of which a unit test should touch.
257
+ *
258
+ * @param {object} [options]
259
+ * @param {any} [options.client] lease client (default: the process-wide singleton)
260
+ * @param {(overrides?: any) => any} [options.sharedClient] shared-client factory
261
+ * (default: the process-wide singleton; injectable so a unit test can observe
262
+ * the degradation-audit wiring without a socket)
263
+ * @param {(agent: any) => { id: string, session: string, label: string }} [options.ownerOf] owner identity (default: the lineage rule)
264
+ * @param {(agent: any, text: string, wake?: boolean) => any} [options.deliver] model-facing message channel
265
+ * @param {() => number} [options.now] injected clock (epoch ms)
266
+ * @returns {{ name: string, inject: string[], apply: (ctx: any, config?: { enabled?: boolean }) => (() => void) | undefined }}
267
+ */
268
+ export function createFileLeasePlugin(options = {}) {
269
+ const injectedClient = options?.client ?? null
270
+ const sharedClient = typeof options?.sharedClient === 'function' ? options.sharedClient : getSharedLeaseClient
271
+ const injectedOwnerOf = options?.ownerOf ?? null
272
+ const injectedDeliver = options?.deliver ?? null
273
+ const now = typeof options?.now === 'function' ? options.now : Date.now
274
+
275
+ /**
276
+ * Mount this row.
277
+ *
278
+ * `config.enabled === false` is a COMPOSITION-LAYER SEAM, not a user setting
279
+ * and not a feature flag: it exists so a test composition (or an embedder) can
280
+ * mount this row switched off for one boot without deleting it from the patch.
281
+ * The production presets never write that key — the feature is always on there
282
+ * — so no settings schema, no command and no documentation promises it. With
283
+ * the seam off the mount does nothing at all: no tool is registered and no
284
+ * event is subscribed, which is exactly what "this row is not here" means.
285
+ * Every other value (including omitted) is enabled.
286
+ *
287
+ * @param {any} ctx the row's cordis context
288
+ * @param {{ enabled?: boolean }} [config] composition config (`enabled` seam only)
289
+ * @returns {(() => void) | undefined} the unmount disposer
290
+ */
291
+ function apply(ctx, config) {
292
+ // The seam is checked before anything touches the ctx: a disabled mount must
293
+ // leave no tool, no listener and no subscription behind (design D11 mount
294
+ // lifetime), so there is nothing to return a disposer for.
295
+ if (config?.enabled === false) return undefined
296
+ /** @type {(() => void)[]} */
297
+ const disposers = []
298
+ /** @type {WeakMap<object, Map<string, { path: string, from: any }>>} */
299
+ const pendingByAgent = new WeakMap()
300
+ /** @type {WeakMap<object, { dispose: () => void }>} */
301
+ const replyTools = new WeakMap()
302
+ /** @type {Set<any>} agents whose reply tool this mount registered */
303
+ const replyAgents = new Set()
304
+ /**
305
+ * `requestId -> the session that asked for it`. A `settled` event carries no
306
+ * session (it is pushed to the requesting connection, which every session of
307
+ * this process shares), so this map is the only way to address the outcome.
308
+ * It is routing state, never authority: the arbiter decides (D6).
309
+ * @type {Map<string, { session: string, path: string }>}
310
+ */
311
+ const issued = new Map()
312
+ let disposed = false
313
+ /** @type {() => void} this mount's read-only view route disposer (D13) */
314
+ let disposeView = () => {}
315
+
316
+ /** @returns {any} the `agents` service, or undefined when it is not mounted */
317
+ function agents() {
318
+ try {
319
+ return ctx.get?.('agents')
320
+ } catch {
321
+ return undefined
322
+ }
323
+ }
324
+
325
+ // The process-wide client (D11): shared with every other mount of this row
326
+ // and never closed by an unmount. An injected client wins and creates nothing.
327
+ //
328
+ // D7 wiring: the client's own degradation sink is already rate limited per
329
+ // process, so the adapter below only maps it onto the shared audit channel
330
+ // (never a second throttle). Only the SHARED client gets it — an injected
331
+ // client belongs to its embedder and keeps whatever sink it was built with.
332
+ const weirAudit = createAudit(ctx)
333
+ const client = injectedClient ?? sharedClient({
334
+ audit: (/** @type {string} */ kind, /** @type {Record<string, any>} */ detail) =>
335
+ auditLeaseDegradation(weirAudit, agents, kind, detail),
336
+ })
337
+ const ownerOf = injectedOwnerOf ?? createOwnerResolver(agents)
338
+ const deliver = injectedDeliver ?? defaultDeliver
339
+
340
+ /**
341
+ * Deliver a notice without letting delivery break the caller: both channels
342
+ * failing is silence, by contract.
343
+ * @param {any} agent @param {string} text @param {boolean} [wake]
344
+ */
345
+ function notify(agent, text, wake = false) {
346
+ if (disposed) return
347
+ try {
348
+ const outcome = deliver(agent, text, wake)
349
+ const promise = /** @type {any} */ (outcome)
350
+ if (promise !== null && typeof promise === 'object' && typeof promise.then === 'function') {
351
+ promise.then(undefined, () => {})
352
+ }
353
+ } catch {
354
+ // Silent: the negotiation expires on its own.
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Run a best-effort release. Never throws and never keeps state to retry:
360
+ * the lease validity is the guarantee, this is the optimisation (D10).
361
+ * @param {() => any} run
362
+ */
363
+ function quiet(run) {
364
+ try {
365
+ const outcome = run()
366
+ const promise = /** @type {any} */ (outcome)
367
+ if (promise !== null && typeof promise === 'object' && typeof promise.then === 'function') {
368
+ promise.then(undefined, () => {})
369
+ }
370
+ } catch {
371
+ // Same rule as notify().
372
+ }
373
+ }
374
+
375
+ /** @param {string | undefined} session @returns {any} */
376
+ function agentFor(session) {
377
+ if (session === undefined) return undefined
378
+ try {
379
+ return agents()?.get?.(session)
380
+ } catch {
381
+ return undefined
382
+ }
383
+ }
384
+
385
+ /** @param {any} agent @returns {Map<string, { path: string, from: any }>} */
386
+ function pendingOf(agent) {
387
+ let pending = pendingByAgent.get(agent)
388
+ if (pending === undefined) {
389
+ pending = new Map()
390
+ pendingByAgent.set(agent, pending)
391
+ }
392
+ return pending
393
+ }
394
+
395
+ /**
396
+ * Register the reply tool on THIS AGENT's scope (not the mount's): the tool
397
+ * answers a request addressed to one holder, and a tool visible to every
398
+ * agent in the preset would offer an answer nobody can give.
399
+ *
400
+ * Idempotent by construction — a second mount on the same agent scope must
401
+ * not throw, and a registration of the same name that this mount does not
402
+ * own is left alone.
403
+ * @param {any} agent
404
+ */
405
+ function registerReplyTool(agent) {
406
+ if (disposed || replyTools.has(agent)) return
407
+ const scope = agent?.ctx
408
+ if (!scope?.tools?.register) return
409
+ try {
410
+ if (typeof scope.tools.get === 'function' && scope.tools.get(REPLY_TOOL) !== undefined) return
411
+ } catch {
412
+ // A registry probe must never break delivery.
413
+ }
414
+ try {
415
+ const dispose = scope.tools.register(replyToolFor(agent))
416
+ replyTools.set(agent, { dispose: typeof dispose === 'function' ? dispose : () => {} })
417
+ replyAgents.add(agent)
418
+ } catch (error) {
419
+ ctx.logger?.warn?.(`file-lease: the reply tool could not be registered: ${messageOf(error)}`)
420
+ }
421
+ }
422
+
423
+ /**
424
+ * The reply definition, wrapped so this mount learns that a pending request
425
+ * was answered (the broker reports it to the requester, not to the holder).
426
+ * @param {any} agent
427
+ * @returns {any}
428
+ */
429
+ function replyToolFor(agent) {
430
+ const definition = /** @type {any} */ (replyDefinition)
431
+ return {
432
+ ...definition,
433
+ async execute(/** @type {any} */ args, /** @type {any} */ exec) {
434
+ try {
435
+ return await definition.execute(args, exec)
436
+ } finally {
437
+ answered(agent, args?.request_id)
438
+ }
439
+ },
440
+ }
441
+ }
442
+
443
+ /**
444
+ * One pending request is gone; the reply tool follows when none is left.
445
+ * @param {any} agent @param {unknown} requestId
446
+ */
447
+ function answered(agent, requestId) {
448
+ const pending = pendingByAgent.get(agent)
449
+ if (pending === undefined) return
450
+ const id = text(requestId)
451
+ if (id !== undefined) pending.delete(id)
452
+ if (pending.size === 0) retireReplyTool(agent)
453
+ }
454
+
455
+ /**
456
+ * The reply window for this agent is over: the tool goes, and the delivery
457
+ * memo goes with it so a later request is never mistaken for a repeat.
458
+ * @param {any} agent
459
+ */
460
+ function retireReplyTool(agent) {
461
+ if (agent === null || typeof agent !== 'object') return
462
+ const registered = replyTools.get(agent)
463
+ replyTools.delete(agent)
464
+ replyAgents.delete(agent)
465
+ pendingByAgent.delete(agent)
466
+ noticedByAgent.delete(agent)
467
+ if (registered === undefined) return
468
+ try {
469
+ registered.dispose()
470
+ } catch {
471
+ // Already unregistered by the scope's own teardown.
472
+ }
473
+ }
474
+
475
+ /** @param {string | undefined} session */
476
+ function forgetIssued(session) {
477
+ if (session === undefined) return
478
+ for (const [requestId, entry] of [...issued]) if (entry.session === session) issued.delete(requestId)
479
+ }
480
+
481
+ /**
482
+ * The turn boundary (D10). ANY reason releases — completed, aborted by the
483
+ * user, or an error: the turn is over, so the session is no longer editing,
484
+ * and the next message must be able to edit immediately. Retention is what
485
+ * survives a boundary, which `releaseSession` already encodes.
486
+ * @param {any} agent
487
+ */
488
+ function releaseOnTurnEnd(agent) {
489
+ const session = sessionIdOf(agent)
490
+ // The reply tool exists for one turn's pending answers; the boundary ends
491
+ // that window whether or not the holder replied.
492
+ retireReplyTool(agent)
493
+ forgetIssued(session)
494
+ if (session === undefined) return
495
+ quiet(() => client.releaseSession(session))
496
+ }
497
+
498
+ /**
499
+ * A disposed agent never comes back, so its retention ends with it (D10).
500
+ * @param {any} agent
501
+ */
502
+ function releaseOnDisposed(agent) {
503
+ const session = sessionIdOf(agent)
504
+ retireReplyTool(agent)
505
+ forgetIssued(session)
506
+ if (session === undefined) return
507
+ quiet(() => client.releaseAll(session))
508
+ }
509
+
510
+ /**
511
+ * The holder a `request` event is addressed to. The wire event names the
512
+ * requester (`from`) but not the holder — it is pushed to the holder's
513
+ * connection, which this process shares across every session — so the
514
+ * holder is whoever the arbiter reports holding that path (D8). A future
515
+ * transport that names the holder is honoured first.
516
+ * @param {any} event @param {string} path
517
+ * @returns {Promise<string | undefined>}
518
+ */
519
+ async function holderSession(event, path) {
520
+ const named = text(event?.session) ?? text(event?.holder?.session) ?? text(event?.to?.session)
521
+ if (named !== undefined) return named
522
+ try {
523
+ const reply = await client.status({ path })
524
+ const leases = Array.isArray(reply?.leases) ? reply.leases : []
525
+ return text(leases[0]?.owner?.session)
526
+ } catch {
527
+ return undefined
528
+ }
529
+ }
530
+
531
+ /** @param {any} event */
532
+ async function onRequest(event) {
533
+ const requestId = text(event?.requestId)
534
+ const path = text(event?.path)
535
+ if (requestId === undefined || path === undefined) return
536
+ const session = await holderSession(event, path)
537
+ if (session === undefined) return
538
+ const agent = agentFor(session)
539
+ // Cross-process or already gone: drop silently. The arbiter expires the
540
+ // request and the requester is told; nothing here is asked to fix it.
541
+ if (!agent) return
542
+ // One delivery per request per process, however many mounts are alive.
543
+ const noticed = noticedFor(agent)
544
+ if (noticed.has(requestId)) return
545
+ noticed.add(requestId)
546
+ pendingOf(agent).set(requestId, { path, from: event?.from })
547
+ registerReplyTool(agent)
548
+ const from = event?.from ?? {}
549
+ const label = text(from.label) ?? 'another session'
550
+ const fromSession = text(from.session) ?? 'unknown'
551
+ const expiresAt = typeof event?.expiresAt === 'number' ? event.expiresAt : now()
552
+ const seconds = Math.max(0, Math.ceil((expiresAt - now()) / 1000))
553
+ notify(agent, requestText({ requestId, short: shortName(path, cwdOf(agent)), label, fromSession, seconds }), agent?.status === 'idle')
554
+ }
555
+
556
+ /** @param {any} event */
557
+ function onSettled(event) {
558
+ const requestId = text(event?.requestId)
559
+ if (requestId === undefined) return
560
+ const entry = issued.get(requestId)
561
+ issued.delete(requestId)
562
+ // A request issued by another process settles in that process.
563
+ if (entry === undefined) return
564
+ const agent = agentFor(entry.session)
565
+ if (!agent) return
566
+ const state = text(event?.state) ?? 'expired'
567
+ notify(agent, settledText(state, requestId, shortName(entry.path, cwdOf(agent))), agent?.status === 'idle')
568
+ }
569
+
570
+ /** @param {any} event */
571
+ async function onClientEvent(event) {
572
+ if (disposed || event === null || typeof event !== 'object') return
573
+ try {
574
+ if (event.event === 'request') await onRequest(event)
575
+ else if (event.event === 'settled') onSettled(event)
576
+ } catch {
577
+ // A listener is a consumer; it must never break the transport.
578
+ }
579
+ }
580
+
581
+ /**
582
+ * The tools see the client through this narrow facade for one reason: a
583
+ * `settled` event carries no session, so the plugin has to remember which
584
+ * session issued which requestId at the moment the request is made. The
585
+ * facade adds that bookkeeping and nothing else — every call still ends in
586
+ * the arbiter.
587
+ */
588
+ const routedClient = Object.freeze({
589
+ acquire: (/** @type {any} */ owner, /** @type {any} */ paths, /** @type {any} */ scope) => client.acquire(owner, paths, scope),
590
+ hold: (/** @type {any} */ paths, /** @type {any} */ minutes, /** @type {any} */ owner) => client.hold(paths, minutes, owner),
591
+ status: (/** @type {any} */ filter) => client.status(filter),
592
+ request: (/** @type {any} */ op, /** @type {any} */ payload) => client.request(op, payload),
593
+ reply: (/** @type {any} */ input) => client.reply(input),
594
+ requestTransfer: async (/** @type {any} */ path, /** @type {any} */ from) => {
595
+ const reply = await client.requestTransfer(path, from)
596
+ const requestId = text(reply?.requestId)
597
+ if (requestId !== undefined) issued.set(requestId, { session: text(from?.session) ?? '', path })
598
+ return reply
599
+ },
600
+ })
601
+
602
+ const definitions = createLeaseTools({
603
+ client: routedClient,
604
+ ownerOf,
605
+ deliverNotice: (agent, text) => notify(agent, text, false),
606
+ })
607
+ const replyDefinition = definitions.find((definition) => definition?.name === REPLY_TOOL)
608
+ const staticDefinitions = definitions.filter((definition) => definition?.name !== REPLY_TOOL)
609
+
610
+ for (const definition of staticDefinitions) {
611
+ // A second mount on the same scope must not throw: the tool is already
612
+ // there, provided by the sibling mount (D11 allows exactly that shape).
613
+ try {
614
+ if (typeof ctx.tools?.get === 'function' && ctx.tools.get(definition.name) !== undefined) continue
615
+ } catch {
616
+ // A registry probe must never abort the mount.
617
+ }
618
+ try {
619
+ const dispose = ctx.tools?.register?.(definition)
620
+ if (typeof dispose === 'function') disposers.push(dispose)
621
+ } catch (error) {
622
+ ctx.logger?.warn?.(`file-lease: tool ${String(definition?.name)} was not registered: ${messageOf(error)}`)
623
+ }
624
+ }
625
+
626
+ // The turn boundary. `turn/end` is the host-level event this surface is
627
+ // defined against; this runtime publishes the same boundary as the durable
628
+ // `turn/end` session event, so both feed the ONE handler (release is
629
+ // idempotent per protocol, so a host that emits both is harmless).
630
+ const offTurnEnd = ctx.on?.('turn/end', (/** @type {any} */ payload) => {
631
+ releaseOnTurnEnd(payload?.agent)
632
+ })
633
+ if (typeof offTurnEnd === 'function') disposers.push(offTurnEnd)
634
+
635
+ const offSession = ctx.on?.('session/event', (/** @type {any} */ session, /** @type {any} */ event) => {
636
+ if (event?.type !== 'turn/end') return
637
+ const agent = agentFor(text(session?.id))
638
+ if (agent === undefined) return
639
+ releaseOnTurnEnd(agent)
640
+ })
641
+ if (typeof offSession === 'function') disposers.push(offSession)
642
+
643
+ const offDisposed = ctx.on?.('agent/disposed', (/** @type {any} */ payload) => {
644
+ releaseOnDisposed(payload?.agent)
645
+ })
646
+ if (typeof offDisposed === 'function') disposers.push(offDisposed)
647
+
648
+ /** @type {() => void} */
649
+ let offEvents = () => {}
650
+ try {
651
+ const off = client.onEvent((/** @type {any} */ event) => {
652
+ void onClientEvent(event)
653
+ })
654
+ if (typeof off === 'function') offEvents = off
655
+ } catch (error) {
656
+ ctx.logger?.warn?.(`file-lease: negotiation events are unavailable: ${messageOf(error)}`)
657
+ }
658
+
659
+ /**
660
+ * The arbiter's health as the read-only surface reports it (D13): the client's
661
+ * own connection state, never a fresh probe, so looking cannot change a
662
+ * verdict. A client that cannot answer is `unavailable` — an unknown
663
+ * connection must never be presented as healthy.
664
+ * @returns {'ok' | 'degraded' | 'unavailable'}
665
+ */
666
+ function arbiterState() {
667
+ try {
668
+ const state = typeof client.connectionState === 'function' ? client.connectionState() : 'unavailable'
669
+ return state === 'ok' || state === 'degraded' || state === 'unavailable' ? state : 'unavailable'
670
+ } catch {
671
+ return 'unavailable'
672
+ }
673
+ }
674
+
675
+ // ---- The read-only ownership view (design D13) --------------------------
676
+ //
677
+ // The ONLY user-facing ownership read in the rebuild, and it reads. Nothing
678
+ // in this handler acquires, releases, retains, negotiates or records
679
+ // anything: no lease changes, no conversation entry, no audit row. Every
680
+ // ownership state resolves by itself (turn end, expiry, negotiation; D10),
681
+ // so a surface that could act would reintroduce exactly the "some state
682
+ // needs a human" the redesign deletes.
683
+ //
684
+ // `connection` is a host-plane service, so the preset realm cannot hide it.
685
+ // Its fetch routes are process-wide (D11), which is why a second mount's
686
+ // identical registration is tolerated below and nothing else is.
687
+ ctx.inject?.(['connection'], (/** @type {any} */ scope) => {
688
+ const fetchRegistry = scope?.connection?.fetch
689
+ if (!fetchRegistry?.register) return
690
+ const reply = (/** @type {any} */ payload, /** @type {number} */ status = 200) =>
691
+ new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } })
692
+ /** @type {(() => void) | undefined} */
693
+ let dispose
694
+ try {
695
+ dispose = fetchRegistry.register({
696
+ path: VIEW_PATH,
697
+ methods: ['POST'],
698
+ requestBody: 'buffered',
699
+ fetch: async (/** @type {any} */ request) => {
700
+ let body
701
+ try {
702
+ body = await request.json()
703
+ } catch {
704
+ body = null
705
+ }
706
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
707
+ if (sessionId === '') {
708
+ return reply({ ok: false, error: { code: 'weir-edit-lock/invalid', message: 'body needs { sessionId }' } }, 400)
709
+ }
710
+ const agent = agentFor(sessionId)
711
+ const cwd = cwdOf(agent)
712
+ // A session with no live agent is its own lineage (D9), and its
713
+ // leases died with the process that held them (D10): the honest
714
+ // answer is "nothing held", never an error to act on.
715
+ const mineLineage = agent === undefined ? sessionId : ownerOf(agent).id
716
+ /** @type {any[]} */
717
+ let leases = []
718
+ try {
719
+ const snapshot = await client.status(cwd === undefined ? {} : { scope: cwd })
720
+ leases = Array.isArray(snapshot?.leases) ? snapshot.leases : []
721
+ } catch {
722
+ // An unreadable arbiter IS the degraded state (D7): answer with the
723
+ // same envelope so the panel reports it, and never claim "nothing
724
+ // held" on the strength of a failed read.
725
+ leases = []
726
+ }
727
+ return reply({ ok: true, value: buildLeaseView({ leases, mineLineage, cwd, arbiter: arbiterState(), now: now() }) })
728
+ },
729
+ })
730
+ } catch (error) {
731
+ // Duplicate-route tolerance (D11): the first mount in this process owns
732
+ // this process-wide route, and the host refuses a later mount's
733
+ // identical registration. Skipping ONLY that exact refusal keeps this
734
+ // mount's inject callback intact instead of aborting it mid-mount, and
735
+ // correctness never depends on mount order because the client and the
736
+ // owner rule are process-wide too.
737
+ if (!duplicateRouteRegistration(error)) throw error
738
+ return
739
+ }
740
+ disposeView = () => {
741
+ try {
742
+ dispose?.()
743
+ } catch {
744
+ // Already released by the injection scope's own teardown.
745
+ }
746
+ }
747
+ return disposeView
748
+ })
749
+
750
+ // Unmount: drop this mount's tools, its listeners, its event subscription and
751
+ // its view route. The client and the daemon keep running (D11) — a sibling
752
+ // mount, or the next mount of this row, owns the connection.
753
+ return () => {
754
+ if (disposed) return
755
+ disposed = true
756
+ try {
757
+ offEvents()
758
+ } catch {
759
+ // Already gone.
760
+ }
761
+ try {
762
+ disposeView()
763
+ } catch {
764
+ // Already released by the injection scope's own teardown.
765
+ }
766
+ disposeView = () => {}
767
+ for (const agent of [...replyAgents]) retireReplyTool(agent)
768
+ replyAgents.clear()
769
+ issued.clear()
770
+ for (const dispose of disposers.splice(0)) {
771
+ try {
772
+ dispose()
773
+ } catch {
774
+ // Already disposed by the scope's own teardown.
775
+ }
776
+ }
777
+ }
778
+ }
779
+
780
+ return { name: PLUGIN_NAME, inject: ['tools', 'agents'], apply }
781
+ }
782
+
783
+ /**
784
+ * The default instance: same export shape as every other row in this bundle, so
785
+ * `cordis.patch.yml` can mount the module directly.
786
+ */
787
+ const { name, inject, apply } = createFileLeasePlugin()
788
+
789
+ export { name, inject, apply }