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,1013 @@
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
+ // 4. Release subscriptions (D2, D4, D5): ONE in-memory coordinator per lease
20
+ // client, shared by every mount of this bundle. A broadcast `released` fact
21
+ // is consumed once and delivered as a timer-deferred full UserMessage; a
22
+ // connection reset is a terminal state that tells each subscriber to
23
+ // re-check, never that the path is free.
24
+ //
25
+ // The transport is process-wide (D8/D11). Two preset mounts of this bundle
26
+ // share ONE client per socket path, and unmounting must never close it — that
27
+ // would rip the connection out from under the sibling mount. The default
28
+ // client is therefore a module-level lazy singleton, and the injected `client`
29
+ // option is the seam tests (and any future embedder) use instead.
30
+ //
31
+ // Nothing here imports `@deepseek-ai/*`: services arrive through `ctx`.
32
+
33
+ import { createLineageResolver } from './lineage.js'
34
+ import { userTextMessage } from '../shared/user-message.js'
35
+ import { createLeaseTools, REPLY_TOOL, shortName } from './tools.js'
36
+ import { createWatchCoordinator } from './watch.js'
37
+ import { AUDIT_TYPES, createAudit } from '../shared/audit.js'
38
+ import { getSharedLeaseClient } from './singleton.js'
39
+ import { buildLeaseView } from './view.js'
40
+
41
+ /** Plugin name (composition identity only; this row publishes no service). */
42
+ const PLUGIN_NAME = 'weir-file-lease'
43
+
44
+ /**
45
+ * The one read-only HTTP route of the feature (design D13). The path keeps the
46
+ * pre-rebuild name on purpose: the client chunk that reads it, the feature doc
47
+ * and the spec all name `/api/weir-edit-lock/view`, and a rename would buy
48
+ * nothing a reader could see.
49
+ */
50
+ const VIEW_PATH = '/api/weir-edit-lock/view'
51
+
52
+ /**
53
+ * The host's exact-duplicate-route refusal, and nothing else.
54
+ *
55
+ * Several preset mounts of this bundle live in ONE process (D11), while `fetch`
56
+ * routes are process-wide: the first mount owns the route and the host refuses
57
+ * a later mount's identical registration. Only that exact message is tolerated;
58
+ * every other registration error is a real fault and must surface.
59
+ * @param {unknown} error
60
+ * @returns {boolean}
61
+ */
62
+ function duplicateRouteRegistration(error) {
63
+ return messageOf(error) === `connection: exact Fetch route ${JSON.stringify(VIEW_PATH)} is already registered`
64
+ }
65
+
66
+ /** Producer tag every injected message carries (AGENTS §3.4). */
67
+ const SOURCE_KIND = 'weir-file-lease'
68
+
69
+ /**
70
+ * Request ids this process has already handed to a holder agent.
71
+ *
72
+ * Two preset mounts share ONE client (D11), so both hear the same `request`
73
+ * event; without this a holder would be woken twice for one request, which
74
+ * D4/spec explicitly forbid ("no second wake is spent for the same request").
75
+ * The arbiter allocates a monotonic request id per request and never reuses
76
+ * one, so a value in this set means exactly "this request was delivered".
77
+ * Cleared when the reply window closes (answer, turn end, disposal).
78
+ * @type {WeakMap<object, Set<string>>}
79
+ */
80
+ const noticedByAgent = new WeakMap()
81
+
82
+ /** @param {unknown} value @returns {string | undefined} */
83
+ function text(value) {
84
+ return typeof value === 'string' && value.length > 0 ? value : undefined
85
+ }
86
+
87
+ /**
88
+ * The delivery memo for one holder agent.
89
+ * @param {object} agent
90
+ * @returns {Set<string>}
91
+ */
92
+ function noticedFor(agent) {
93
+ let noticed = noticedByAgent.get(agent)
94
+ if (noticed === undefined) {
95
+ noticed = new Set()
96
+ noticedByAgent.set(agent, noticed)
97
+ }
98
+ return noticed
99
+ }
100
+
101
+ /**
102
+ * The settled outcomes this process has already delivered, per requesting agent.
103
+ *
104
+ * One `settled` event is one process-wide fact, but every mount hears it and the
105
+ * requester must be told exactly once (D4). `issued` cannot dedupe it: a turn
106
+ * boundary clears that map while the outcome is allowed to arrive afterwards —
107
+ * which is exactly when this memo is the only thing that keeps a second mount
108
+ * from delivering the same outcome a second time. It is keyed by the live agent
109
+ * because that is this process's handle on the requesting session, and it is
110
+ * bounded because the arbiter settles a request exactly once: forgetting an old
111
+ * id can only lose a repeat that never comes.
112
+ * @type {WeakMap<object, Map<string, number>>}
113
+ */
114
+ const settledNoticesByAgent = new WeakMap()
115
+
116
+ /** How many delivered outcomes one agent's memo remembers (bounded, FIFO). */
117
+ const SETTLED_NOTICE_MEMO_LIMIT = 64
118
+
119
+ /**
120
+ * The broker incarnation every memo entry belongs to.
121
+ *
122
+ * Request ids come from the daemon's own counter, which starts over whenever a
123
+ * new daemon takes the socket, so a live session can be handed an id this
124
+ * process already delivered an outcome for. A transport reset is the boundary
125
+ * between two incarnations: nothing recorded before it says anything about an id
126
+ * issued after it.
127
+ */
128
+ let settledEpoch = 0
129
+
130
+ /**
131
+ * Claim the one model-facing delivery of a settled outcome (D4).
132
+ * @param {object} agent @param {string} requestId
133
+ * @returns {boolean} whether this caller is the one that may deliver it
134
+ */
135
+ function claimSettledNotice(agent, requestId) {
136
+ let notices = settledNoticesByAgent.get(agent)
137
+ if (notices === undefined) {
138
+ notices = new Map()
139
+ settledNoticesByAgent.set(agent, notices)
140
+ }
141
+ if (notices.get(requestId) === settledEpoch) return false
142
+ notices.set(requestId, settledEpoch)
143
+ if (notices.size > SETTLED_NOTICE_MEMO_LIMIT) {
144
+ // Oldest first: the ids evicted here belong to outcomes the arbiter already
145
+ // announced once and will never announce again.
146
+ const [oldest] = notices.keys()
147
+ if (oldest !== undefined) notices.delete(oldest)
148
+ }
149
+ return true
150
+ }
151
+
152
+ /** @param {unknown} error @returns {string} */
153
+ function messageOf(error) {
154
+ return String(/** @type {any} */ (error)?.message ?? error)
155
+ }
156
+
157
+ /**
158
+ * ONE release-subscription coordinator per lease client (design D2/D4).
159
+ *
160
+ * The client is process-wide, so its view of who waits for which path must be
161
+ * too: keying the coordinator by the client makes two preset mounts share one
162
+ * entry, one status flight and one notice, and lets either mount deliver a
163
+ * pending notice after the other one is gone. The map is weak because the
164
+ * coordinator — like the client — lives exactly as long as something holds it.
165
+ * @type {WeakMap<object, import('./watch.js').WatchCoordinator>}
166
+ */
167
+ const watchCoordinators = new WeakMap()
168
+
169
+ /**
170
+ * @param {object} client
171
+ * @returns {import('./watch.js').WatchCoordinator}
172
+ */
173
+ function coordinatorFor(client) {
174
+ let coordinator = watchCoordinators.get(client)
175
+ if (coordinator === undefined) {
176
+ coordinator = createWatchCoordinator()
177
+ watchCoordinators.set(client, coordinator)
178
+ }
179
+ return coordinator
180
+ }
181
+
182
+ /**
183
+ * The session this agent acts for: the durable header id when there is one,
184
+ * else the live session id, else the agent id. Non-empty by construction, and
185
+ * `undefined` only for an agent object with no identity at all — a caller that
186
+ * cannot name a session cannot release one either.
187
+ * @param {any} agent
188
+ * @returns {string | undefined}
189
+ */
190
+ function sessionIdOf(agent) {
191
+ return text(agent?.session?.header?.id) ?? text(agent?.session?.id) ?? text(agent?.id)
192
+ }
193
+
194
+ /** @param {any} agent @returns {string | undefined} */
195
+ function cwdOf(agent) {
196
+ return text(agent?.session?.header?.cwd)
197
+ }
198
+
199
+ /**
200
+ * Resolve `ownerOf(agent)` from the live lineage rule (D9): the lineage id plus
201
+ * the session and label the arbiter only ever formats.
202
+ * @param {() => any} agents lazy accessor for the `agents` service
203
+ * @returns {(agent: any) => { id: string, session: string, label: string }}
204
+ */
205
+ function createOwnerResolver(agents) {
206
+ const resolver = createLineageResolver({
207
+ // The ancestor walk reads one header per hop; a missing registry (a cold or
208
+ // partially mounted host) degrades to "this session is its own lineage".
209
+ sessions: {
210
+ get: (/** @type {string} */ id) => {
211
+ try {
212
+ return agents()?.get?.(id)
213
+ } catch {
214
+ return undefined
215
+ }
216
+ },
217
+ },
218
+ })
219
+ return (agent) => {
220
+ const lineage = resolver.lineageOf(agent)
221
+ return {
222
+ id: lineage,
223
+ session: sessionIdOf(agent) ?? lineage,
224
+ label: resolver.labelOf(agent),
225
+ }
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Deliver one model-facing notice (AGENTS §3.4): `followup` when the wake flag
231
+ * is set and the agent is idle — that single call is what opens the turn the
232
+ * holder needs in order to answer — and `steer` otherwise, which lands inside a
233
+ * running turn instead of spending an extra one. Both channels failing is
234
+ * silence: the negotiation simply expires and audit belongs to the client (D7).
235
+ *
236
+ * @param {any} agent
237
+ * @param {string} text
238
+ * @param {boolean} [wake]
239
+ * @returns {Promise<boolean>} whether a channel accepted the message
240
+ */
241
+ async function defaultDeliver(agent, text, wake = false) {
242
+ const message = userTextMessage(text, SOURCE_KIND)
243
+ // `followup` only when asked to wake an idle agent: a followup to a running
244
+ // agent would queue a second turn, which is exactly the cost D4 forbids.
245
+ const channels = wake && agent?.status === 'idle' ? ['followup', 'steer'] : ['steer', 'followup']
246
+ for (const channel of channels) {
247
+ try {
248
+ const send = agent?.[channel]
249
+ if (typeof send !== 'function') continue
250
+ await send.call(agent, message)
251
+ return true
252
+ } catch {
253
+ // Fall through to the other channel; a total failure stays silent.
254
+ }
255
+ }
256
+ return false
257
+ }
258
+
259
+ /**
260
+ * The request notice addressed to the holder. Model-facing only: it names the
261
+ * requester, names the tool and the request id, and states that inaction keeps
262
+ * ownership. It must never read like something for a human to act on.
263
+ * @param {{ requestId: string, short: string, label: string, fromSession: string, seconds: number }} detail
264
+ * @returns {string}
265
+ */
266
+ function requestText({ requestId, short, label, fromSession, seconds }) {
267
+ return [
268
+ `Session "${label}" (session ${fromSession}) is asking to take over ${short}.`,
269
+ `Answer with edit_lock_reply: request_id "${requestId}", decision "release" to hand it over, or "keep" to hold on to it.`,
270
+ `Nothing moves until you answer — if you do not answer, ownership stays yours and the request expires by itself (about ${seconds}s left).`,
271
+ ].join(' ')
272
+ }
273
+
274
+ /**
275
+ * The result notice addressed to the requester once its request settled.
276
+ * @param {string} state `transferred` | `declined` | `expired`
277
+ * @param {string} requestId
278
+ * @param {string} short
279
+ * @returns {string}
280
+ */
281
+ function settledText(state, requestId, short) {
282
+ if (state === 'transferred') {
283
+ 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.`
284
+ }
285
+ if (state === 'declined') {
286
+ 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.`
287
+ }
288
+ 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.`
289
+ }
290
+
291
+ /**
292
+ * The release notice addressed to a subscriber (D4).
293
+ *
294
+ * It says what happened, what to do next, and — explicitly — what it does NOT
295
+ * mean: the arbiter selected no winner and the path may already be gone again.
296
+ * The path is the model's short name; an absolute path never lands in a message.
297
+ * @param {string} short
298
+ * @returns {string}
299
+ */
300
+ function releasedNoticeText(short) {
301
+ return [
302
+ `${short} is no longer held by another session.`,
303
+ 'This is only an opportunity to call edit_lock_acquire for it — not a grant of ownership, and not a reservation: another session waiting for the same release may claim it first, and the ordinary holder conflict still applies.',
304
+ 'If your claim is refused, negotiate with edit_lock_try_steal or call edit_lock_watch again for the next release.',
305
+ ].join(' ')
306
+ }
307
+
308
+ /**
309
+ * The notice for a subscription a connection reset ended (D5).
310
+ *
311
+ * A reset says nothing about ownership, so this must not read like a release:
312
+ * the subscriber is told to re-check and to subscribe again if it is still
313
+ * refused, and nothing else.
314
+ * @param {string} short
315
+ * @returns {string}
316
+ */
317
+ function resetNoticeText(short) {
318
+ return [
319
+ `The ownership arbiter connection was reset, so the release subscription for ${short} ended without learning anything about ownership.`,
320
+ 'Nothing was released and nothing is free by this notice. Re-check with edit_lock_status, and call edit_lock_watch again if the path is still held by another session.',
321
+ ].join(' ')
322
+ }
323
+
324
+ /**
325
+ * Adapt one degradation record from the lease client to the shared audit
326
+ * channel (design D7, `AUDIT_TYPES.fileLease`).
327
+ *
328
+ * The client calls its sink synchronously at the moment a managed write
329
+ * degrades, and rate limits it itself (one record per process window), so this
330
+ * adapter must never throttle again. `detail.session` is the owning session id;
331
+ * the shared channel takes the LIVE session object so the JSONL mirror lands in
332
+ * that session's workspace. A session this process can no longer resolve still
333
+ * emits the in-process event with `session: null` — audit is log-only and the
334
+ * degraded write proceeds either way.
335
+ *
336
+ * The client's detail is flat (`{code, session, lineage, scope, paths, at}`);
337
+ * the record names the owner under `owner` so a reader never depends on the
338
+ * client's internal field names. `paths` is a COUNT — no path content.
339
+ *
340
+ * @param {(session: any, type: string, data?: unknown) => void} audit the shared audit emitter
341
+ * @param {() => any} agents lazy `agents` service accessor
342
+ * @param {string} kind the degradation kind (`'degraded'`)
343
+ * @param {Record<string, any>} [detail] the client's degradation detail
344
+ */
345
+ function auditLeaseDegradation(audit, agents, kind, detail) {
346
+ try {
347
+ const sessionId = text(detail?.session)
348
+ /** @type {any} */
349
+ let session = null
350
+ if (sessionId !== undefined) {
351
+ try {
352
+ session = agents()?.get?.(sessionId)?.session ?? null
353
+ } catch {
354
+ session = null
355
+ }
356
+ }
357
+ audit(session, AUDIT_TYPES.fileLease, {
358
+ kind,
359
+ ...detail,
360
+ owner: { session: sessionId ?? null, lineage: text(detail?.lineage) ?? null },
361
+ })
362
+ } catch {
363
+ // Audit is log-only: a broken sink must never break a managed write.
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Create one plugin instance. Every collaborator is injectable so a unit test
369
+ * can drive the whole surface with a fake client and a fake ctx: the default
370
+ * client is the process-wide singleton and the default cortex is a live cordis
371
+ * context, neither of which a unit test should touch.
372
+ *
373
+ * @param {object} [options]
374
+ * @param {any} [options.client] lease client (default: the process-wide singleton)
375
+ * @param {(overrides?: any) => any} [options.sharedClient] shared-client factory
376
+ * (default: the process-wide singleton; injectable so a unit test can observe
377
+ * the degradation-audit wiring without a socket)
378
+ * @param {(agent: any) => { id: string, session: string, label: string }} [options.ownerOf] owner identity (default: the lineage rule)
379
+ * @param {(agent: any, text: string, wake?: boolean) => any} [options.deliver] model-facing message channel
380
+ * @param {() => number} [options.now] injected clock (epoch ms)
381
+ * @returns {{ name: string, inject: string[], apply: (ctx: any, config?: { enabled?: boolean }) => (() => void) | undefined }}
382
+ */
383
+ export function createFileLeasePlugin(options = {}) {
384
+ const injectedClient = options?.client ?? null
385
+ const sharedClient = typeof options?.sharedClient === 'function' ? options.sharedClient : getSharedLeaseClient
386
+ const injectedOwnerOf = options?.ownerOf ?? null
387
+ const injectedDeliver = options?.deliver ?? null
388
+ const now = typeof options?.now === 'function' ? options.now : Date.now
389
+
390
+ /**
391
+ * Mount this row.
392
+ *
393
+ * `config.enabled === false` is a COMPOSITION-LAYER SEAM, not a user setting
394
+ * and not a feature flag: it exists so a test composition (or an embedder) can
395
+ * mount this row switched off for one boot without deleting it from the patch.
396
+ * The production presets never write that key — the feature is always on there
397
+ * — so no settings schema, no command and no documentation promises it. With
398
+ * the seam off the mount does nothing at all: no tool is registered and no
399
+ * event is subscribed, which is exactly what "this row is not here" means.
400
+ * Every other value (including omitted) is enabled.
401
+ *
402
+ * @param {any} ctx the row's cordis context
403
+ * @param {{ enabled?: boolean }} [config] composition config (`enabled` seam only)
404
+ * @returns {(() => void) | undefined} the unmount disposer
405
+ */
406
+ function apply(ctx, config) {
407
+ // The seam is checked before anything touches the ctx: a disabled mount must
408
+ // leave no tool, no listener and no subscription behind (design D11 mount
409
+ // lifetime), so there is nothing to return a disposer for.
410
+ if (config?.enabled === false) return undefined
411
+ /** @type {(() => void)[]} */
412
+ const disposers = []
413
+ /** @type {WeakMap<object, Map<string, { path: string, from: any }>>} */
414
+ const pendingByAgent = new WeakMap()
415
+ /** @type {WeakMap<object, { dispose: () => void }>} */
416
+ const replyTools = new WeakMap()
417
+ /** @type {Set<any>} agents whose reply tool this mount registered */
418
+ const replyAgents = new Set()
419
+ /**
420
+ * `requestId -> the session that asked for it`. A `settled` event names the
421
+ * requester's session, but a broker older than that field pushes the outcome
422
+ * only to the requesting connection (which every session of this process
423
+ * shares), so this map remains the fallback route. It is routing state,
424
+ * never authority: the arbiter decides (D6).
425
+ * @type {Map<string, { session: string, path: string }>}
426
+ */
427
+ const issued = new Map()
428
+ let disposed = false
429
+ /** @type {() => void} this mount's read-only view route disposer (D13) */
430
+ let disposeView = () => {}
431
+
432
+ /** @returns {any} the `agents` service, or undefined when it is not mounted */
433
+ function agents() {
434
+ try {
435
+ return ctx.get?.('agents')
436
+ } catch {
437
+ return undefined
438
+ }
439
+ }
440
+
441
+ // The process-wide client (D11): shared with every other mount of this row
442
+ // and never closed by an unmount. An injected client wins and creates nothing.
443
+ //
444
+ // D7 wiring: the client's own degradation sink is already rate limited per
445
+ // process, so the adapter below only maps it onto the shared audit channel
446
+ // (never a second throttle). Only the SHARED client gets it — an injected
447
+ // client belongs to its embedder and keeps whatever sink it was built with.
448
+ const weirAudit = createAudit(ctx)
449
+ const client = injectedClient ?? sharedClient({
450
+ audit: (/** @type {string} */ kind, /** @type {Record<string, any>} */ detail) =>
451
+ auditLeaseDegradation(weirAudit, agents, kind, detail),
452
+ })
453
+ const ownerOf = injectedOwnerOf ?? createOwnerResolver(agents)
454
+ const deliver = injectedDeliver ?? defaultDeliver
455
+ /**
456
+ * This client's release subscriptions (D2/D4). ONE coordinator per client,
457
+ * not per mount: two mounts that subscribe the same session and path share
458
+ * one entry, one status flight and one notice, and one mount's teardown
459
+ * cannot cancel a wait another mount still owns.
460
+ */
461
+ const watches = coordinatorFor(client)
462
+ /**
463
+ * This mount's reference on the shared coordinator plus the notice channel it
464
+ * speaks through. The coordinator maintains `disposed`; `notify` only renders
465
+ * the outcome it has already re-checked (a release fact or a reset).
466
+ * @type {import('./watch.js').WatchMount}
467
+ */
468
+ const watchMount = {
469
+ disposed: false,
470
+ agentFor: (/** @type {string} */ session) => agentFor(session),
471
+ notify: (/** @type {import('./watch.js').WatchEntry} */ entry, /** @type {any} */ agent) => {
472
+ const short = shortName(entry.path, cwdOf(agent))
473
+ notify(agent, entry.state === 'reset' ? resetNoticeText(short) : releasedNoticeText(short), agent?.status === 'idle')
474
+ },
475
+ }
476
+ watches.join(watchMount)
477
+
478
+ /**
479
+ * Deliver a notice without letting delivery break the caller: both channels
480
+ * failing is silence, by contract.
481
+ * @param {any} agent @param {string} text @param {boolean} [wake]
482
+ */
483
+ function notify(agent, text, wake = false) {
484
+ if (disposed) return
485
+ try {
486
+ const outcome = deliver(agent, text, wake)
487
+ const promise = /** @type {any} */ (outcome)
488
+ if (promise !== null && typeof promise === 'object' && typeof promise.then === 'function') {
489
+ promise.then(undefined, () => {})
490
+ }
491
+ } catch {
492
+ // Silent: the negotiation expires on its own.
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Run a best-effort release. Never throws and never keeps state to retry:
498
+ * the lease validity is the guarantee, this is the optimisation (D10).
499
+ * @param {() => any} run
500
+ */
501
+ function quiet(run) {
502
+ try {
503
+ const outcome = run()
504
+ const promise = /** @type {any} */ (outcome)
505
+ if (promise !== null && typeof promise === 'object' && typeof promise.then === 'function') {
506
+ promise.then(undefined, () => {})
507
+ }
508
+ } catch {
509
+ // Same rule as notify().
510
+ }
511
+ }
512
+
513
+ /** @param {string | undefined} session @returns {any} */
514
+ function agentFor(session) {
515
+ if (session === undefined) return undefined
516
+ try {
517
+ return agents()?.get?.(session)
518
+ } catch {
519
+ return undefined
520
+ }
521
+ }
522
+
523
+ /** @param {any} agent @returns {Map<string, { path: string, from: any }>} */
524
+ function pendingOf(agent) {
525
+ let pending = pendingByAgent.get(agent)
526
+ if (pending === undefined) {
527
+ pending = new Map()
528
+ pendingByAgent.set(agent, pending)
529
+ }
530
+ return pending
531
+ }
532
+
533
+ /**
534
+ * Register the reply tool on THIS AGENT's scope (not the mount's): the tool
535
+ * answers a request addressed to one holder, and a tool visible to every
536
+ * agent in the preset would offer an answer nobody can give.
537
+ *
538
+ * Idempotent by construction — a second mount on the same agent scope must
539
+ * not throw, and a registration of the same name that this mount does not
540
+ * own is left alone.
541
+ * @param {any} agent
542
+ */
543
+ function registerReplyTool(agent) {
544
+ if (disposed || replyTools.has(agent)) return
545
+ const scope = agent?.ctx
546
+ if (!scope?.tools?.register) return
547
+ try {
548
+ if (typeof scope.tools.get === 'function' && scope.tools.get(REPLY_TOOL) !== undefined) return
549
+ } catch {
550
+ // A registry probe must never break delivery.
551
+ }
552
+ try {
553
+ const dispose = scope.tools.register(replyToolFor(agent))
554
+ replyTools.set(agent, { dispose: typeof dispose === 'function' ? dispose : () => {} })
555
+ replyAgents.add(agent)
556
+ } catch (error) {
557
+ ctx.logger?.warn?.(`file-lease: the reply tool could not be registered: ${messageOf(error)}`)
558
+ }
559
+ }
560
+
561
+ /**
562
+ * The reply definition, wrapped so this mount learns that a pending request
563
+ * was answered (the broker reports it to the requester, not to the holder).
564
+ * @param {any} agent
565
+ * @returns {any}
566
+ */
567
+ function replyToolFor(agent) {
568
+ const definition = /** @type {any} */ (replyDefinition)
569
+ return {
570
+ ...definition,
571
+ async execute(/** @type {any} */ args, /** @type {any} */ exec) {
572
+ try {
573
+ return await definition.execute(args, exec)
574
+ } finally {
575
+ answered(agent, args?.request_id)
576
+ }
577
+ },
578
+ }
579
+ }
580
+
581
+ /**
582
+ * One pending request is gone; the reply tool follows when none is left.
583
+ * @param {any} agent @param {unknown} requestId
584
+ */
585
+ function answered(agent, requestId) {
586
+ const pending = pendingByAgent.get(agent)
587
+ if (pending === undefined) return
588
+ const id = text(requestId)
589
+ if (id !== undefined) pending.delete(id)
590
+ if (pending.size === 0) retireReplyTool(agent)
591
+ }
592
+
593
+ /**
594
+ * The reply window for this agent is over: the tool goes, and the delivery
595
+ * memo goes with it so a later request is never mistaken for a repeat.
596
+ * @param {any} agent
597
+ */
598
+ function retireReplyTool(agent) {
599
+ if (agent === null || typeof agent !== 'object') return
600
+ const registered = replyTools.get(agent)
601
+ replyTools.delete(agent)
602
+ replyAgents.delete(agent)
603
+ pendingByAgent.delete(agent)
604
+ noticedByAgent.delete(agent)
605
+ if (registered === undefined) return
606
+ try {
607
+ registered.dispose()
608
+ } catch {
609
+ // Already unregistered by the scope's own teardown.
610
+ }
611
+ }
612
+
613
+ /** @param {string | undefined} session */
614
+ function forgetIssued(session) {
615
+ if (session === undefined) return
616
+ for (const [requestId, entry] of [...issued]) if (entry.session === session) issued.delete(requestId)
617
+ }
618
+
619
+ /**
620
+ * The turn boundary (D10). ANY reason releases — completed, aborted by the
621
+ * user, or an error: the turn is over, so the session is no longer editing,
622
+ * and the next message must be able to edit immediately. Retention is what
623
+ * survives a boundary, which `releaseSession` already encodes.
624
+ * @param {any} agent
625
+ */
626
+ function releaseOnTurnEnd(agent) {
627
+ const session = sessionIdOf(agent)
628
+ // The reply tool exists for one turn's pending answers; the boundary ends
629
+ // that window whether or not the holder replied.
630
+ retireReplyTool(agent)
631
+ forgetIssued(session)
632
+ if (session === undefined) return
633
+ quiet(() => client.releaseSession(session))
634
+ }
635
+
636
+ /**
637
+ * A disposed agent never comes back, so its retention ends with it (D10).
638
+ * @param {any} agent
639
+ */
640
+ function releaseOnDisposed(agent) {
641
+ const session = sessionIdOf(agent)
642
+ retireReplyTool(agent)
643
+ forgetIssued(session)
644
+ // A disposed agent never comes back: its subscriptions end with it — the
645
+ // active waits AND the consumed notices still waiting for their timer.
646
+ if (session !== undefined) watches.cancelSession(session)
647
+ if (session === undefined) return
648
+ quiet(() => client.releaseAll(session))
649
+ }
650
+
651
+ /**
652
+ * The holder a `request` event is addressed to. The wire event names the
653
+ * requester (`from`) but not the holder — it is pushed to the holder's
654
+ * connection, which this process shares across every session — so the
655
+ * holder is whoever the arbiter reports holding that path (D8). A future
656
+ * transport that names the holder is honoured first.
657
+ * @param {any} event @param {string} path
658
+ * @returns {Promise<string | undefined>}
659
+ */
660
+ async function holderSession(event, path) {
661
+ const named = text(event?.session) ?? text(event?.holder?.session) ?? text(event?.to?.session)
662
+ if (named !== undefined) return named
663
+ try {
664
+ const reply = await client.status({ path })
665
+ const leases = Array.isArray(reply?.leases) ? reply.leases : []
666
+ return text(leases[0]?.owner?.session)
667
+ } catch {
668
+ return undefined
669
+ }
670
+ }
671
+
672
+ /** @param {any} event */
673
+ async function onRequest(event) {
674
+ const requestId = text(event?.requestId)
675
+ const path = text(event?.path)
676
+ if (requestId === undefined || path === undefined) return
677
+ const session = await holderSession(event, path)
678
+ if (session === undefined) return
679
+ const agent = agentFor(session)
680
+ // Cross-process or already gone: drop silently. The arbiter expires the
681
+ // request and the requester is told; nothing here is asked to fix it.
682
+ if (!agent) return
683
+ // One delivery per request per process, however many mounts are alive.
684
+ const noticed = noticedFor(agent)
685
+ if (noticed.has(requestId)) return
686
+ noticed.add(requestId)
687
+ pendingOf(agent).set(requestId, { path, from: event?.from })
688
+ registerReplyTool(agent)
689
+ const from = event?.from ?? {}
690
+ const label = text(from.label) ?? 'another session'
691
+ const fromSession = text(from.session) ?? 'unknown'
692
+ const expiresAt = typeof event?.expiresAt === 'number' ? event.expiresAt : now()
693
+ const seconds = Math.max(0, Math.ceil((expiresAt - now()) / 1000))
694
+ notify(agent, requestText({ requestId, short: shortName(path, cwdOf(agent)), label, fromSession, seconds }), agent?.status === 'idle')
695
+ }
696
+
697
+ /**
698
+ * One negotiation outcome from the broker (D4).
699
+ *
700
+ * The event names the requester (`session`) and the path it asked about, and
701
+ * that pair is the routing address: a request outlives the turn that made it,
702
+ * while this mount's `issued` memo does not — a turn boundary clears it — so
703
+ * `issued` only routes an older broker's event, which names no session. An
704
+ * event that names neither is another process's outcome and is ignored here.
705
+ * @param {any} event
706
+ */
707
+ function onSettled(event) {
708
+ const requestId = text(event?.requestId)
709
+ if (requestId === undefined) return
710
+ const state = text(event?.state) ?? 'expired'
711
+ const entry = issued.get(requestId)
712
+ issued.delete(requestId)
713
+ // The transfer made this session an owner by another route, so a later
714
+ // release notice for the same path would wake it for nothing: the wait is
715
+ // over because the goal was reached, not because a release happened. The
716
+ // wait ends BEFORE the outcome is routed, so the cancellation can never be
717
+ // lost to a cleared route.
718
+ const session = text(event?.session) ?? entry?.session
719
+ const path = text(event?.path) ?? entry?.path
720
+ if (state === 'transferred' && session !== undefined && path !== undefined) {
721
+ watches.settleSelf(session, path)
722
+ }
723
+ // No session and no route: the request was issued in another process.
724
+ if (session === undefined || path === undefined) return
725
+ const agent = agentFor(session)
726
+ if (!agent) return
727
+ // ONE delivery per request in this process, however many mounts hear this
728
+ // event — including the mount whose `issued` entry routed it here.
729
+ if (!claimSettledNotice(agent, requestId)) return
730
+ notify(agent, settledText(state, requestId, shortName(path, cwdOf(agent))), agent?.status === 'idle')
731
+ }
732
+
733
+ /**
734
+ * One release fact from the broker (D1/D2).
735
+ *
736
+ * Consumption happens inside the coordinator, before anything is scheduled:
737
+ * the entry leaves the active map in `hit`, so a duplicate or repeated
738
+ * observation of the same release finds nothing to consume and delivers
739
+ * nothing. The fact carries no session, so every subscription of this client
740
+ * for that path is consumed — the arbiter never chooses a winner.
741
+ * @param {any} event
742
+ */
743
+ function onReleased(event) {
744
+ const path = text(event?.path)
745
+ if (path !== undefined) watches.consumePath(path)
746
+ }
747
+
748
+ /**
749
+ * The connection that could have delivered a release is gone (D5).
750
+ *
751
+ * Every active subscription ends in `reset` — a terminal state that claims
752
+ * nothing about ownership — and the coordinator's deferred notice tells each
753
+ * live subscriber to re-check and re-subscribe, never that the path became
754
+ * free.
755
+ */
756
+ function onConnectionReset() {
757
+ // A reset also ends the broker incarnation that allocated the request ids
758
+ // already answered in this process: the next one counts from its own zero.
759
+ settledEpoch += 1
760
+ if (disposed) return
761
+ watches.resetAll()
762
+ }
763
+
764
+ /** @param {any} event */
765
+ async function onClientEvent(event) {
766
+ if (disposed || event === null || typeof event !== 'object') return
767
+ try {
768
+ if (event.event === 'request') await onRequest(event)
769
+ else if (event.event === 'settled') onSettled(event)
770
+ else if (event.event === 'released') onReleased(event)
771
+ } catch {
772
+ // A listener is a consumer; it must never break the transport.
773
+ }
774
+ }
775
+
776
+ /**
777
+ * The tools see the client through this narrow facade for one reason: the
778
+ * requester of a transfer has to be remembered from the moment the request
779
+ * is made, because a broker older than `settled.session` pushes the outcome
780
+ * without naming it. The facade adds that routing memo and nothing else —
781
+ * every call still ends in the arbiter.
782
+ */
783
+ const routedClient = Object.freeze({
784
+ acquire: (/** @type {any} */ owner, /** @type {any} */ paths, /** @type {any} */ scope) => client.acquire(owner, paths, scope),
785
+ hold: (/** @type {any} */ paths, /** @type {any} */ minutes, /** @type {any} */ owner) => client.hold(paths, minutes, owner),
786
+ status: (/** @type {any} */ filter) => client.status(filter),
787
+ request: (/** @type {any} */ op, /** @type {any} */ payload) => client.request(op, payload),
788
+ reply: (/** @type {any} */ input) => client.reply(input),
789
+ // Capability is a property of the CURRENT handshake. A client facade
790
+ // without the query (an embedder, an older injection) advertises nothing,
791
+ // so the watch tool fails explicitly instead of waiting for an event that
792
+ // cannot arrive.
793
+ supportsEvent: (/** @type {string} */ name) =>
794
+ typeof client.supportsEvent === 'function' ? client.supportsEvent(name) === true : false,
795
+ requestTransfer: async (/** @type {any} */ path, /** @type {any} */ from) => {
796
+ const reply = await client.requestTransfer(path, from)
797
+ const requestId = text(reply?.requestId)
798
+ if (requestId !== undefined) issued.set(requestId, { session: text(from?.session) ?? '', path })
799
+ return reply
800
+ },
801
+ })
802
+
803
+ const definitions = createLeaseTools({
804
+ client: routedClient,
805
+ ownerOf,
806
+ deliverNotice: (agent, text) => notify(agent, text, false),
807
+ watchCoordinator: watches,
808
+ mount: watchMount,
809
+ })
810
+ const replyDefinition = definitions.find((definition) => definition?.name === REPLY_TOOL)
811
+ const staticDefinitions = definitions.filter((definition) => definition?.name !== REPLY_TOOL)
812
+
813
+ for (const definition of staticDefinitions) {
814
+ // A second mount on the same scope must not throw: the tool is already
815
+ // there, provided by the sibling mount (D11 allows exactly that shape).
816
+ try {
817
+ if (typeof ctx.tools?.get === 'function' && ctx.tools.get(definition.name) !== undefined) continue
818
+ } catch {
819
+ // A registry probe must never abort the mount.
820
+ }
821
+ try {
822
+ const dispose = ctx.tools?.register?.(definition)
823
+ if (typeof dispose === 'function') disposers.push(dispose)
824
+ } catch (error) {
825
+ ctx.logger?.warn?.(`file-lease: tool ${String(definition?.name)} was not registered: ${messageOf(error)}`)
826
+ }
827
+ }
828
+
829
+ // The turn boundary. `turn/end` is the host-level event this surface is
830
+ // defined against; this runtime publishes the same boundary as the durable
831
+ // `turn/end` session event, so both feed the ONE handler (release is
832
+ // idempotent per protocol, so a host that emits both is harmless).
833
+ const offTurnEnd = ctx.on?.('turn/end', (/** @type {any} */ payload) => {
834
+ releaseOnTurnEnd(payload?.agent)
835
+ })
836
+ if (typeof offTurnEnd === 'function') disposers.push(offTurnEnd)
837
+
838
+ const offSession = ctx.on?.('session/event', (/** @type {any} */ session, /** @type {any} */ event) => {
839
+ if (event?.type !== 'turn/end') return
840
+ const agent = agentFor(text(session?.id))
841
+ if (agent === undefined) return
842
+ releaseOnTurnEnd(agent)
843
+ })
844
+ if (typeof offSession === 'function') disposers.push(offSession)
845
+
846
+ const offDisposed = ctx.on?.('agent/disposed', (/** @type {any} */ payload) => {
847
+ releaseOnDisposed(payload?.agent)
848
+ })
849
+ if (typeof offDisposed === 'function') disposers.push(offDisposed)
850
+
851
+ /** @type {() => void} */
852
+ let offEvents = () => {}
853
+ try {
854
+ const off = client.onEvent((/** @type {any} */ event) => {
855
+ void onClientEvent(event)
856
+ })
857
+ if (typeof off === 'function') offEvents = off
858
+ } catch (error) {
859
+ ctx.logger?.warn?.(`file-lease: negotiation events are unavailable: ${messageOf(error)}`)
860
+ }
861
+
862
+ // A vanished broker cannot announce anything, so the reset is a transport
863
+ // lifecycle signal rather than a wire event (D5). A client without it simply
864
+ // never reports resets — the other paths still work.
865
+ /** @type {() => void} */
866
+ let offReset = () => {}
867
+ try {
868
+ const off = typeof client.onReset === 'function' ? client.onReset(() => onConnectionReset()) : undefined
869
+ if (typeof off === 'function') offReset = off
870
+ } catch (error) {
871
+ ctx.logger?.warn?.(`file-lease: transport resets are unavailable: ${messageOf(error)}`)
872
+ }
873
+
874
+ /**
875
+ * The arbiter's health as the read-only surface reports it (D13): the client's
876
+ * own connection state, never a fresh probe, so looking cannot change a
877
+ * verdict. A client that cannot answer is `unavailable` — an unknown
878
+ * connection must never be presented as healthy.
879
+ * @returns {'ok' | 'degraded' | 'unavailable'}
880
+ */
881
+ function arbiterState() {
882
+ try {
883
+ const state = typeof client.connectionState === 'function' ? client.connectionState() : 'unavailable'
884
+ return state === 'ok' || state === 'degraded' || state === 'unavailable' ? state : 'unavailable'
885
+ } catch {
886
+ return 'unavailable'
887
+ }
888
+ }
889
+
890
+ // ---- The read-only ownership view (design D13) --------------------------
891
+ //
892
+ // The ONLY user-facing ownership read in the rebuild, and it reads. Nothing
893
+ // in this handler acquires, releases, retains, negotiates or records
894
+ // anything: no lease changes, no conversation entry, no audit row. Every
895
+ // ownership state resolves by itself (turn end, expiry, negotiation; D10),
896
+ // so a surface that could act would reintroduce exactly the "some state
897
+ // needs a human" the redesign deletes.
898
+ //
899
+ // `connection` is a host-plane service, so the preset realm cannot hide it.
900
+ // Its fetch routes are process-wide (D11), which is why a second mount's
901
+ // identical registration is tolerated below and nothing else is.
902
+ ctx.inject?.(['connection'], (/** @type {any} */ scope) => {
903
+ const fetchRegistry = scope?.connection?.fetch
904
+ if (!fetchRegistry?.register) return
905
+ const reply = (/** @type {any} */ payload, /** @type {number} */ status = 200) =>
906
+ new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } })
907
+ /** @type {(() => void) | undefined} */
908
+ let dispose
909
+ try {
910
+ dispose = fetchRegistry.register({
911
+ path: VIEW_PATH,
912
+ methods: ['POST'],
913
+ requestBody: 'buffered',
914
+ fetch: async (/** @type {any} */ request) => {
915
+ let body
916
+ try {
917
+ body = await request.json()
918
+ } catch {
919
+ body = null
920
+ }
921
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
922
+ if (sessionId === '') {
923
+ return reply({ ok: false, error: { code: 'weir-edit-lock/invalid', message: 'body needs { sessionId }' } }, 400)
924
+ }
925
+ const agent = agentFor(sessionId)
926
+ const cwd = cwdOf(agent)
927
+ // A session with no live agent is its own lineage (D9), and its
928
+ // leases died with the process that held them (D10): the honest
929
+ // answer is "nothing held", never an error to act on.
930
+ const mineLineage = agent === undefined ? sessionId : ownerOf(agent).id
931
+ /** @type {any[]} */
932
+ let leases = []
933
+ try {
934
+ const snapshot = await client.status(cwd === undefined ? {} : { scope: cwd })
935
+ leases = Array.isArray(snapshot?.leases) ? snapshot.leases : []
936
+ } catch {
937
+ // An unreadable arbiter IS the degraded state (D7): answer with the
938
+ // same envelope so the panel reports it, and never claim "nothing
939
+ // held" on the strength of a failed read.
940
+ leases = []
941
+ }
942
+ return reply({ ok: true, value: buildLeaseView({ leases, mineLineage, cwd, arbiter: arbiterState(), now: now() }) })
943
+ },
944
+ })
945
+ } catch (error) {
946
+ // Duplicate-route tolerance (D11): the first mount in this process owns
947
+ // this process-wide route, and the host refuses a later mount's
948
+ // identical registration. Skipping ONLY that exact refusal keeps this
949
+ // mount's inject callback intact instead of aborting it mid-mount, and
950
+ // correctness never depends on mount order because the client and the
951
+ // owner rule are process-wide too.
952
+ if (!duplicateRouteRegistration(error)) throw error
953
+ return
954
+ }
955
+ disposeView = () => {
956
+ try {
957
+ dispose?.()
958
+ } catch {
959
+ // Already released by the injection scope's own teardown.
960
+ }
961
+ }
962
+ return disposeView
963
+ })
964
+
965
+ // Unmount: drop this mount's tools, its listeners, its event subscription and
966
+ // its view route. The client and the daemon keep running (D11) — a sibling
967
+ // mount, or the next mount of this row, owns the connection.
968
+ return () => {
969
+ if (disposed) return
970
+ disposed = true
971
+ try {
972
+ offEvents()
973
+ } catch {
974
+ // Already gone.
975
+ }
976
+ try {
977
+ offReset()
978
+ } catch {
979
+ // Already gone.
980
+ }
981
+ // This mount's references leave the shared coordinator. A wait another
982
+ // mount still owns survives, and a pending notice transfers to a surviving
983
+ // reference; the last mount's teardown clears everything that is left.
984
+ watches.leave(watchMount)
985
+ try {
986
+ disposeView()
987
+ } catch {
988
+ // Already released by the injection scope's own teardown.
989
+ }
990
+ disposeView = () => {}
991
+ for (const agent of [...replyAgents]) retireReplyTool(agent)
992
+ replyAgents.clear()
993
+ issued.clear()
994
+ for (const dispose of disposers.splice(0)) {
995
+ try {
996
+ dispose()
997
+ } catch {
998
+ // Already disposed by the scope's own teardown.
999
+ }
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ return { name: PLUGIN_NAME, inject: ['tools', 'agents'], apply }
1005
+ }
1006
+
1007
+ /**
1008
+ * The default instance: same export shape as every other row in this bundle, so
1009
+ * `cordis.patch.yml` can mount the module directly.
1010
+ */
1011
+ const { name, inject, apply } = createFileLeasePlugin()
1012
+
1013
+ export { name, inject, apply }