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,1299 @@
1
+ // The client half of the edit-lock lease protocol (design D6, D7, D8).
2
+ //
3
+ // The daemon in ../lease-broker/ is the ONLY arbiter: every verdict about who
4
+ // may write a file arrives over this socket, and nothing here re-derives one.
5
+ // What lives in this file:
6
+ //
7
+ // - one lazily established connection per client, one `hello` handshake per
8
+ // connection, one `reqId` per request (replies are matched by that id and by
9
+ // nothing else);
10
+ // - the in-memory lease ledger (`path -> {session, lineage, holdUntil}`). It is
11
+ // used for exactly two things — knowing what a turn boundary must release and
12
+ // what a reconnect must replay. It NEVER decides a conflict (D6): the moment
13
+ // this file starts reasoning about ownership it becomes a second authority,
14
+ // which is the failure mode this whole design exists to delete;
15
+ // - degrade-and-proceed (D7). An unhealthy arbiter never blocks a write; a real
16
+ // `held` conflict always does. The protocol's closed error vocabulary is what
17
+ // keeps those two apart — no heuristics, no "is it probably fine".
18
+ //
19
+ // Deliberately absent: killing or restarting the daemon. The daemon is a cache
20
+ // with an idle window and a stale-socket recovery of its own, and a client that
21
+ // permanently gives up after one transport failure would turn a transient blip
22
+ // into a session-long loss of arbitration. Every later call re-tries the
23
+ // connection (and the spawn); only the audit is rate limited.
24
+ //
25
+ // Every external dependency is injected so a unit test can drive the state
26
+ // machine without a socket, a clock, or a child process.
27
+
28
+ import { isAbsolute } from 'node:path'
29
+ import { createConnection } from 'node:net'
30
+ import { PROTOCOL } from '../lease-broker/protocol.js'
31
+ import { defaultSocketPath } from '../lease-broker/server.js'
32
+ import { spawnDaemon as defaultSpawnDaemon } from './spawn.js'
33
+
34
+ /** Per-request deadline. A slow arbiter is an unhealthy arbiter (D7). */
35
+ const DEFAULT_TIMEOUT_MS = 2_000
36
+
37
+ /** Pause between connection attempts while a spawned daemon comes up. */
38
+ const DEFAULT_CONNECT_RETRY_MS = 50
39
+
40
+ /** Total budget for "spawn, then wait for the socket" before degrading. */
41
+ const DEFAULT_CONNECT_RETRY_BUDGET_MS = 1_500
42
+
43
+ /** Retention bound in minutes, mirrored from the protocol for replay math. */
44
+ const HOLD_MAX_MINUTES = 60
45
+
46
+ /** Infrastructure failure: connection, handshake, timeout, write, closed. */
47
+ export const TRANSPORT_CODE = 'transport'
48
+
49
+ /** Real conflict: someone else holds the path. Never degraded (D7). */
50
+ const CONFLICT_CODE = 'held'
51
+
52
+ /** Endpoint-absent errors: nobody is listening yet, so spawning may help. */
53
+ const ABSENT_ENDPOINT_CODES = new Set(['ENOENT', 'ECONNREFUSED'])
54
+
55
+ /**
56
+ * Degraded-mode audit throttle: at most one record per process per window (D7).
57
+ * Exported because the operator-facing contract names the number.
58
+ */
59
+ export const DEGRADED_AUDIT_WINDOW_MS = 60_000
60
+
61
+ /**
62
+ * @typedef {Error & {
63
+ * code: string,
64
+ * conflicts?: any[],
65
+ * paths?: string[],
66
+ * maxMinutes?: number,
67
+ * cause?: unknown,
68
+ * }} LeaseFailure
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} LeaseRecord
73
+ * @property {string} session the session that last touched the lease (release scope)
74
+ * @property {string} lineage the opaque `owner.id` this client acquired under
75
+ * @property {number | null} holdUntil retention deadline in epoch ms, `null` when unretained
76
+ */
77
+
78
+ /**
79
+ * The transport contract the state machine speaks. `connect()` may resolve a
80
+ * connection asynchronously and must reject with the underlying error (so
81
+ * `ENOENT`/`ECONNREFUSED` can be recognised as "daemon not up yet").
82
+ *
83
+ * @typedef {object} LeaseConnection
84
+ * @property {(text: string) => void} write may throw; every throw is a transport failure
85
+ * @property {(handler: (chunk: string) => void) => void} onData newline-delimited frames
86
+ * @property {(handler: () => void) => void} onClose called once when the connection is gone
87
+ * @property {() => void} close idempotent
88
+ */
89
+
90
+ /**
91
+ * @typedef {object} LeaseClientOptions
92
+ * @property {string} [socketPath] endpoint address; defaults to the daemon's own `defaultSocketPath({protocol})`
93
+ * @property {number} [protocol] protocol major sent in the handshake
94
+ * @property {number} [timeoutMs] per-request deadline
95
+ * @property {() => number} [now] injected clock (epoch ms)
96
+ * @property {(socketPath: string) => LeaseConnection | Promise<LeaseConnection>} [connect] dial the endpoint
97
+ * @property {(() => unknown) | null} [spawnDaemon] called at most once per connection attempt; `null` disables spawning
98
+ * @property {(kind: string, detail: Record<string, any>) => void} [audit] audit sink (`kind` is `'degraded'`)
99
+ * @property {number} [connectRetryMs] pause between connection attempts after a spawn
100
+ * @property {number} [connectRetryBudgetMs] total budget for connect attempts after a spawn
101
+ * @property {number} [degradedAuditWindowMs] throttle window for the degraded audit record
102
+ */
103
+
104
+ /**
105
+ * @param {string} code
106
+ * @param {string} message
107
+ * @param {{ conflicts?: any[], paths?: string[], maxMinutes?: number, cause?: unknown }} [detail]
108
+ * @returns {LeaseFailure}
109
+ */
110
+ function leaseError(code, message, detail = {}) {
111
+ /** @type {LeaseFailure} */
112
+ const error = /** @type {LeaseFailure} */ (new Error(message))
113
+ error.code = code
114
+ if (Array.isArray(detail.conflicts)) error.conflicts = detail.conflicts
115
+ if (Array.isArray(detail.paths)) error.paths = detail.paths
116
+ if (typeof detail.maxMinutes === 'number') error.maxMinutes = detail.maxMinutes
117
+ if (detail.cause !== undefined) error.cause = detail.cause
118
+ return error
119
+ }
120
+
121
+ /**
122
+ * @param {string} message
123
+ * @param {unknown} [cause]
124
+ * @returns {LeaseFailure}
125
+ */
126
+ function transportError(message, cause) {
127
+ return leaseError(TRANSPORT_CODE, message, { cause })
128
+ }
129
+
130
+ /**
131
+ * True for a real conflict — the only refusal that may stop a write.
132
+ * @param {unknown} error
133
+ * @returns {boolean}
134
+ */
135
+ export function isLeaseConflict(error) {
136
+ return !!error && typeof error === 'object' && /** @type {any} */ (error).code === CONFLICT_CODE
137
+ }
138
+
139
+ /**
140
+ * @param {unknown} error
141
+ * @returns {string}
142
+ */
143
+ function errorCode(error) {
144
+ const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
145
+ return typeof code === 'string' ? code : 'internal'
146
+ }
147
+
148
+ /**
149
+ * @param {number} ms
150
+ * @returns {Promise<void>}
151
+ */
152
+ function delay(ms) {
153
+ return new Promise((resolve) => {
154
+ setTimeout(resolve, ms)
155
+ })
156
+ }
157
+
158
+ /**
159
+ * Default dialer: a plain newline-JSON socket from `node:net`.
160
+ * @param {string} socketPath
161
+ * @returns {Promise<LeaseConnection>}
162
+ */
163
+ function defaultConnect(socketPath) {
164
+ return new Promise((resolve, reject) => {
165
+ const socket = createConnection({ path: socketPath })
166
+ socket.setEncoding('utf8')
167
+ let connected = false
168
+ let settled = false
169
+ socket.once('connect', () => {
170
+ connected = true
171
+ settled = true
172
+ socket.setNoDelay(true)
173
+ // A lease connection is process state (design D11), not a pending task: it
174
+ // must never keep an otherwise-finished Node process alive. Without this
175
+ // an idle client pins the event loop, so a headless CLI turn that ended
176
+ // normally only dies when its caller times out (observed as a hung
177
+ // integration boot), and a graceful host shutdown would wait forever.
178
+ // `unref` is part of Node's net.Socket but not modelled by the hand-written
179
+ // node contracts this bundle compiles against (`types: []`), and the
180
+ // connection is an injectable stub in tests — hence the guarded call.
181
+ const timer = /** @type {{ unref?: () => void }} */ (/** @type {unknown} */ (socket))
182
+ if (typeof timer.unref === 'function') timer.unref()
183
+ resolve(wrapSocket(socket))
184
+ })
185
+ socket.once('error', (error) => {
186
+ // Pre-connect: the dial failed and the caller decides whether to spawn and
187
+ // re-try. Post-connect: the wrapper's own listener turns it into a close.
188
+ if (connected || settled) return
189
+ settled = true
190
+ try {
191
+ socket.destroy()
192
+ } catch {
193
+ // Already gone.
194
+ }
195
+ reject(error)
196
+ })
197
+ socket.on('close', () => {
198
+ if (connected || settled) return
199
+ settled = true
200
+ reject(leaseError(TRANSPORT_CODE, `lease broker closed the connection during dial to ${socketPath}`))
201
+ })
202
+ })
203
+ }
204
+
205
+ /**
206
+ * Narrow a Node socket to the `LeaseConnection` contract, making the close
207
+ * notification exactly-once (Node may emit both `error` and `close`).
208
+ * @param {import('node:net').Socket} socket
209
+ * @returns {LeaseConnection}
210
+ */
211
+ function wrapSocket(socket) {
212
+ let released = false
213
+ /** @type {(() => void)[]} */
214
+ const listeners = []
215
+ const notify = () => {
216
+ if (released) return
217
+ released = true
218
+ for (const listener of listeners.splice(0)) {
219
+ try {
220
+ listener()
221
+ } catch {
222
+ // A transport listener must never break the client's own bookkeeping.
223
+ }
224
+ }
225
+ }
226
+ socket.on('error', () => {
227
+ try {
228
+ socket.destroy()
229
+ } catch {
230
+ // Already gone.
231
+ }
232
+ notify()
233
+ })
234
+ socket.on('close', notify)
235
+ return {
236
+ write(text) {
237
+ socket.write(text)
238
+ },
239
+ onData(handler) {
240
+ socket.on('data', handler)
241
+ },
242
+ onClose(handler) {
243
+ if (released) {
244
+ // Registered too late: report asynchronously so the caller's state
245
+ // machine cannot re-enter mid-registration.
246
+ queueMicrotask(() => {
247
+ try {
248
+ handler()
249
+ } catch {
250
+ // Same rule as notify().
251
+ }
252
+ })
253
+ return
254
+ }
255
+ listeners.push(handler)
256
+ },
257
+ close() {
258
+ notify()
259
+ try {
260
+ socket.destroy()
261
+ } catch {
262
+ // Already gone.
263
+ }
264
+ },
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Create one lease client.
270
+ *
271
+ * A process shares ONE client (design D8/D11): one connection carries every
272
+ * lease of this process, so two preset mounts must never each create their own.
273
+ * The factory exists so tests and the wiring can build independent instances.
274
+ *
275
+ * @param {LeaseClientOptions} [options]
276
+ */
277
+ export function createLeaseClient({
278
+ socketPath,
279
+ protocol = PROTOCOL,
280
+ timeoutMs = DEFAULT_TIMEOUT_MS,
281
+ now = Date.now,
282
+ connect = defaultConnect,
283
+ spawnDaemon = defaultSpawnDaemon,
284
+ audit = () => {},
285
+ connectRetryMs = DEFAULT_CONNECT_RETRY_MS,
286
+ connectRetryBudgetMs = DEFAULT_CONNECT_RETRY_BUDGET_MS,
287
+ degradedAuditWindowMs = DEGRADED_AUDIT_WINDOW_MS,
288
+ } = {}) {
289
+ const endpoint = typeof socketPath === 'string' && socketPath.length > 0 ? socketPath : defaultSocketPath({ protocol })
290
+
291
+ /** @type {LeaseConnection | null} */
292
+ let connection = null
293
+ let greeted = false
294
+ /** @type {Promise<LeaseConnection> | null} */
295
+ let connecting = null
296
+ let closed = false
297
+ /**
298
+ * Waits that a close answers instead of the dial. `connect` has no abort
299
+ * contract — an injected one may never settle at all — so a close must
300
+ * release these itself; nothing here waits for a socket (D5).
301
+ * @type {Set<() => void>}
302
+ */
303
+ const closeWaiters = new Set()
304
+ /** @type {number | undefined} */
305
+ let startedAt
306
+ /** A connection reset means the daemon may have restarted with an empty table (D6). */
307
+ let replayPending = false
308
+ /** A new incarnation was detected; the next request (or `replayHolds`) replays first. */
309
+ let replayQueued = false
310
+ /** @type {Promise<{ replayed: number, dropped: number }> | null} */
311
+ let replaying = null
312
+ /** @type {number | null} */
313
+ let lastDegradedAt = null
314
+ /** @type {string | undefined} */
315
+ let lastScope
316
+ /**
317
+ * The push events the CURRENT handshake advertised. Empty until a handshake
318
+ * answers and after the connection is gone, so "not advertised" and "not
319
+ * connected" both read as unsupported: a caller must never wait for an event
320
+ * this incarnation cannot send.
321
+ * @type {Set<string>}
322
+ */
323
+ let eventCapabilities = new Set()
324
+
325
+ /**
326
+ * The replay ledger. `path -> {session, lineage, holdUntil}` — nothing more.
327
+ * In particular there is no `owner`-decides-anything field here, by design.
328
+ * @type {Map<string, LeaseRecord>}
329
+ */
330
+ const ledger = new Map()
331
+ /** `lineage -> label`, presentation only; the broker never compares it. @type {Map<string, string>} */
332
+ const labels = new Map()
333
+ /** @type {Set<(event: any) => void>} */
334
+ const eventHandlers = new Set()
335
+ /** Connection-reset lifecycle listeners (D5). Separate from the wire events
336
+ * above on purpose: a reset is a property of THIS client's transport, not a
337
+ * fact the broker can send. @type {Set<() => void>} */
338
+ const resetHandlers = new Set()
339
+ /**
340
+ * @type {Map<string, {
341
+ * resolve: (reply: any) => void,
342
+ * reject: (error: any) => void,
343
+ * timer: ReturnType<typeof setTimeout>,
344
+ * }>}
345
+ */
346
+ const waiters = new Map()
347
+ let requestSeq = 0
348
+
349
+ // ---------------------------------------------------------------------
350
+ // Transport: connection lifecycle, framing, correlation
351
+ // ---------------------------------------------------------------------
352
+
353
+ /** @returns {string} a correlation token unique to this client */
354
+ function nextReqId() {
355
+ requestSeq += 1
356
+ return `c${requestSeq}`
357
+ }
358
+
359
+ /**
360
+ * @param {LeaseConnection} target
361
+ * @param {Record<string, any>} frame
362
+ * @returns {Promise<any>}
363
+ */
364
+ function sendFrame(target, frame) {
365
+ return new Promise((resolve, reject) => {
366
+ const reqId = nextReqId()
367
+ const timer = setTimeout(() => {
368
+ waiters.delete(reqId)
369
+ reject(transportError(`lease broker did not answer ${String(frame.op)} within ${timeoutMs}ms`))
370
+ }, timeoutMs)
371
+ waiters.set(reqId, { resolve, reject, timer })
372
+ try {
373
+ // `reqId` is written last so a caller-supplied payload cannot collide.
374
+ target.write(`${JSON.stringify({ ...frame, reqId })}\n`)
375
+ } catch (error) {
376
+ clearTimeout(timer)
377
+ waiters.delete(reqId)
378
+ reject(transportError(`lease broker write failed at ${endpoint}`, error))
379
+ }
380
+ })
381
+ }
382
+
383
+ /**
384
+ * @param {LeaseConnection} target
385
+ * @param {string} op
386
+ * @param {Record<string, any>} [payload]
387
+ * @returns {Promise<any>}
388
+ */
389
+ async function exchange(target, op, payload = {}) {
390
+ const reply = await sendFrame(target, { op, ...payload })
391
+ if (reply !== null && typeof reply === 'object' && reply.ok === true) return reply
392
+ const detail = reply !== null && typeof reply === 'object' && reply.error !== null && typeof reply.error === 'object' ? reply.error : {}
393
+ const code = typeof detail.code === 'string' ? detail.code : 'internal'
394
+ throw leaseError(code, `lease broker refused ${op}: ${code}`, detail)
395
+ }
396
+
397
+ /** @param {any} frame */
398
+ function dispatchEvent(frame) {
399
+ for (const handler of [...eventHandlers]) {
400
+ try {
401
+ handler(frame)
402
+ } catch {
403
+ // A listener is a consumer; it must never be able to break the transport.
404
+ }
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Tell every listener that the current connection is gone.
410
+ *
411
+ * Deliberately separate from wire events: a broker that is SIGKILLed cannot
412
+ * announce that it died, so a client that only listened to `EVENTS` would
413
+ * wait forever. Called exactly once per connection (the socket wrapper makes
414
+ * its close notification once-only) and never for an intentional `close()`.
415
+ */
416
+ function notifyReset() {
417
+ for (const handler of [...resetHandlers]) {
418
+ try {
419
+ handler()
420
+ } catch {
421
+ // A listener is a consumer; it must never break the transport.
422
+ }
423
+ }
424
+ }
425
+
426
+ /** @param {string} line */
427
+ function handleFrame(line) {
428
+ /** @type {any} */
429
+ let frame
430
+ try {
431
+ frame = JSON.parse(line)
432
+ } catch {
433
+ // An undecodable line cannot be matched to anything; the waiter's own
434
+ // deadline is what reports it.
435
+ return
436
+ }
437
+ if (frame === null || typeof frame !== 'object') return
438
+ if (typeof frame.event === 'string') {
439
+ dispatchEvent(frame)
440
+ return
441
+ }
442
+ const reqId = typeof frame.reqId === 'string' ? frame.reqId : undefined
443
+ if (reqId === undefined) return
444
+ const waiter = waiters.get(reqId)
445
+ // Correlation is exact: a reply nobody is waiting for is dropped. The
446
+ // alternative — handing it to the next waiter in line — would let one
447
+ // request satisfy another and silently mis-report ownership.
448
+ if (waiter === undefined) return
449
+ waiters.delete(reqId)
450
+ clearTimeout(waiter.timer)
451
+ waiter.resolve(frame)
452
+ }
453
+
454
+ /**
455
+ * @param {any} error
456
+ */
457
+ function failWaiters(error) {
458
+ for (const [reqId, waiter] of [...waiters]) {
459
+ waiters.delete(reqId)
460
+ clearTimeout(waiter.timer)
461
+ waiter.reject(error)
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Answer every wait the close latch owns. A close is terminal for the whole
467
+ * transport lifetime, so a caller parked on a dial is told now rather than
468
+ * inheriting a dial that may never return.
469
+ */
470
+ function notifyCloseWaiters() {
471
+ for (const waiter of [...closeWaiters]) waiter()
472
+ }
473
+
474
+ /**
475
+ * The ONE connection-invalidation outlet (D5).
476
+ *
477
+ * A connection ends in three ways — the socket closed under us, we dropped it
478
+ * to retry, or the client is closing — and all three must leave the same state
479
+ * behind: no current connection, no handshake, no advertised capabilities, no
480
+ * waiter still believing an answer is coming, and exactly one reset
481
+ * notification for the connection that just went away.
482
+ *
483
+ * Only the CURRENT target may do that. A stale target — a handshake that lost
484
+ * a race, or a socket already replaced by a retry — is a no-op: its late close
485
+ * must not clear the live connection's state or notify a second time.
486
+ *
487
+ * @param {LeaseConnection | null} target
488
+ * @param {boolean} [closing] true for an intentional client close: the ledger is
489
+ * already gone, so nothing is queued for replay
490
+ * @returns {boolean} whether this target was the current connection
491
+ */
492
+ function invalidateConnection(target, closing = false) {
493
+ if (target === null || connection !== target) return false
494
+ connection = null
495
+ greeted = false
496
+ // The advertised capabilities belonged to that connection.
497
+ eventCapabilities = new Set()
498
+ // D6: a reset connection means the daemon may have died and come back with
499
+ // an empty table, so retained leases have to be replayed on the next dial.
500
+ if (!closing && ledger.size > 0) replayPending = true
501
+ failWaiters(transportError(closing ? 'lease client closed' : 'lease broker connection closed'))
502
+ // D5: a reset is a terminal state for anything waiting on this connection,
503
+ // and the only moment a vanished broker can be reported at all.
504
+ notifyReset()
505
+ return true
506
+ }
507
+
508
+ /** @param {LeaseConnection} target */
509
+ function handleDisconnect(target) {
510
+ invalidateConnection(target)
511
+ }
512
+
513
+ /**
514
+ * Close a socket without touching client state. Used where the connection is
515
+ * already invalidated (or was never adopted) and only the transport is left.
516
+ * @param {LeaseConnection | null} target
517
+ */
518
+ function closeSocket(target) {
519
+ if (target === null) return
520
+ try {
521
+ target.close()
522
+ } catch {
523
+ // Already gone.
524
+ }
525
+ }
526
+
527
+ /**
528
+ * @param {LeaseConnection} target
529
+ * @returns {boolean} whether this target was the current connection
530
+ */
531
+ function dropConnection(target) {
532
+ // Invalidate first: closing the socket reports its close synchronously, and
533
+ // that report must find nothing left to invalidate (exactly-once reset).
534
+ const invalidated = invalidateConnection(target)
535
+ closeSocket(target)
536
+ return invalidated
537
+ }
538
+
539
+ /**
540
+ * Wait for `work`, but never past `close()`.
541
+ *
542
+ * The transport has no abort contract, so a close answers the wait itself:
543
+ * the dial is left to whatever it does, and nobody inherits a promise that
544
+ * may never settle. The waiter is removed as soon as either side answers, and
545
+ * `work` is always consumed — an abandoned dial that fails later can never
546
+ * surface as an unhandled rejection.
547
+ *
548
+ * @template T
549
+ * @param {Promise<T>} work
550
+ * @returns {Promise<T>}
551
+ */
552
+ function untilClosed(work) {
553
+ return new Promise((resolve, reject) => {
554
+ let waiting = true
555
+ /** @param {() => void} settle */
556
+ const finish = (settle) => {
557
+ if (!waiting) return
558
+ waiting = false
559
+ closeWaiters.delete(onClose)
560
+ settle()
561
+ }
562
+ const onClose = () => finish(() => reject(transportError('lease client is closed')))
563
+ closeWaiters.add(onClose)
564
+ // Attach first: a close may already have fired, and `work`'s outcome must
565
+ // still be handled when this wait is answered by the latch instead.
566
+ Promise.resolve(work).then(
567
+ (value) => finish(() => resolve(value)),
568
+ (error) => finish(() => reject(error)),
569
+ )
570
+ if (closed) onClose()
571
+ })
572
+ }
573
+
574
+ /**
575
+ * Dial once, but not past a close.
576
+ *
577
+ * A socket that only arrives after the close is closed on arrival: it can
578
+ * never be adopted, greeted, replayed or used to serve a request, and the
579
+ * caller was already answered by the latch.
580
+ * @returns {Promise<{ connection: LeaseConnection | null, error: any }>}
581
+ */
582
+ async function attemptConnect() {
583
+ /** @type {LeaseConnection | Promise<LeaseConnection>} */
584
+ let started
585
+ try {
586
+ // Started synchronously: the dial is the caller's, not a later task's.
587
+ started = connect(endpoint)
588
+ } catch (error) {
589
+ return { connection: null, error }
590
+ }
591
+ /** @type {Promise<LeaseConnection>} */
592
+ const dial = Promise.resolve(started).then((connection) => {
593
+ if (closed) {
594
+ closeSocket(connection)
595
+ throw transportError('lease client is closed')
596
+ }
597
+ return connection
598
+ })
599
+ try {
600
+ return { connection: await untilClosed(dial), error: null }
601
+ } catch (error) {
602
+ return { connection: null, error }
603
+ }
604
+ }
605
+
606
+ /** @param {unknown} error @returns {boolean} */
607
+ function isAbsentEndpoint(error) {
608
+ const code = error && typeof error === 'object' ? /** @type {any} */ (error).code : undefined
609
+ return typeof code === 'string' && ABSENT_ENDPOINT_CODES.has(code)
610
+ }
611
+
612
+ function spawnOnce() {
613
+ if (typeof spawnDaemon !== 'function') return
614
+ try {
615
+ spawnDaemon()
616
+ } catch {
617
+ // Spawning is best effort — a `null` daemon degrades, it never refuses.
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Dial the daemon, spawning it once when the endpoint is simply not there.
623
+ *
624
+ * Spawning is per attempt, not per process: after a failure this client must
625
+ * be able to start the daemon again on the next call, because "the daemon is
626
+ * gone" is a transient condition, not a decision to stop arbitrating.
627
+ *
628
+ * The wait is bounded by this client's lifetime, never by a dial timeout: a
629
+ * close answers it through the latch, and `connect` is left to settle on its
630
+ * own. A dial that never returns therefore cannot park a caller.
631
+ *
632
+ * @returns {Promise<LeaseConnection>}
633
+ */
634
+ async function openConnection() {
635
+ // A close requested before or during the dial is terminal: never spawn,
636
+ // adopt or keep waiting for an arbiter on behalf of a client that is gone.
637
+ if (closed) throw transportError('lease client closed')
638
+ const first = await attemptConnect()
639
+ if (closed) {
640
+ closeSocket(first.connection)
641
+ throw transportError('lease client closed')
642
+ }
643
+ if (first.connection !== null) return first.connection
644
+ if (!isAbsentEndpoint(first.error)) {
645
+ throw transportError(`lease broker is unreachable at ${endpoint}`, first.error)
646
+ }
647
+ spawnOnce()
648
+ const deadline = now() + connectRetryBudgetMs
649
+ // A frozen or absent clock must not turn this into an endless loop: the
650
+ // attempt count is what actually bounds the wait.
651
+ const maxAttempts = Math.max(1, Math.ceil(connectRetryBudgetMs / Math.max(1, connectRetryMs))) + 2
652
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
653
+ if (closed) throw transportError('lease client closed')
654
+ if (now() >= deadline) break
655
+ await untilClosed(delay(connectRetryMs))
656
+ if (closed) throw transportError('lease client closed')
657
+ const retry = await attemptConnect()
658
+ if (closed) {
659
+ closeSocket(retry.connection)
660
+ throw transportError('lease client closed')
661
+ }
662
+ if (retry.connection !== null) return retry.connection
663
+ if (!isAbsentEndpoint(retry.error)) {
664
+ throw transportError(`lease broker is unreachable at ${endpoint}`, retry.error)
665
+ }
666
+ }
667
+ throw transportError(`lease broker did not come up at ${endpoint} within ${connectRetryBudgetMs}ms`)
668
+ }
669
+
670
+ /**
671
+ * Establish the connection and complete the handshake, replaying retained
672
+ * leases when the daemon turns out to be a different incarnation (D6).
673
+ * @returns {Promise<LeaseConnection>}
674
+ */
675
+ async function establish() {
676
+ const fresh = await openConnection()
677
+ // A close during the dial owns the socket now: it is closed instead of
678
+ // installed, so no handshake, replay or lease can happen after close.
679
+ if (closed) {
680
+ closeSocket(fresh)
681
+ throw transportError('lease client closed')
682
+ }
683
+ const previousStartedAt = startedAt
684
+ connection = fresh
685
+ greeted = false
686
+ let buffered = ''
687
+ fresh.onData((chunk) => {
688
+ buffered += chunk
689
+ let index = buffered.indexOf('\n')
690
+ while (index >= 0) {
691
+ const line = buffered.slice(0, index)
692
+ buffered = buffered.slice(index + 1)
693
+ if (line.trim() !== '') handleFrame(line)
694
+ index = buffered.indexOf('\n')
695
+ }
696
+ })
697
+ fresh.onClose(() => handleDisconnect(fresh))
698
+ /** @type {any} */
699
+ let hello
700
+ try {
701
+ hello = await sendFrame(fresh, { op: 'hello', protocol })
702
+ } catch (error) {
703
+ dropConnection(fresh)
704
+ throw transportError(`lease broker handshake failed at ${endpoint}`, error)
705
+ }
706
+ // A close may have invalidated this connection while the hello was in
707
+ // flight; even an already-delivered reply must not resurrect it.
708
+ if (closed) {
709
+ dropConnection(fresh)
710
+ throw transportError('lease client closed')
711
+ }
712
+ if (hello.ok !== true) {
713
+ // A protocol mismatch is infrastructure, not a conflict: the write goes
714
+ // through and the audit records why (D7). Nobody is asked to fix anything.
715
+ const code = typeof hello.error?.code === 'string' ? hello.error.code : 'unknown'
716
+ dropConnection(fresh)
717
+ throw transportError(`lease broker refused the handshake (${code})`)
718
+ }
719
+ greeted = true
720
+ // The handshake is the ONLY source of the push-event list, and an older
721
+ // broker simply does not carry the field: empty means "not advertised".
722
+ eventCapabilities = new Set(
723
+ Array.isArray(hello.events) ? hello.events.filter((entry) => typeof entry === 'string' && entry.length > 0) : [],
724
+ )
725
+ const reported = typeof hello.startedAt === 'number' ? hello.startedAt : previousStartedAt
726
+ startedAt = reported
727
+ const restarted = replayPending || (previousStartedAt !== undefined && previousStartedAt !== reported)
728
+ replayPending = false
729
+ // Arm the replay instead of running it here: the connection is usable, but
730
+ // replaying from inside `establish` would re-enter the request path. The next
731
+ // request flushes it (or `replayHolds()` does, without replaying twice).
732
+ if (restarted && ledger.size > 0) replayQueued = true
733
+ return fresh
734
+ }
735
+
736
+ /** @returns {Promise<LeaseConnection>} */
737
+ async function ensureConnection() {
738
+ if (closed) throw transportError('lease client is closed')
739
+ /** @type {LeaseConnection} */
740
+ let target
741
+ if (connection !== null && greeted) {
742
+ target = connection
743
+ } else if (connecting !== null) {
744
+ target = await connecting
745
+ } else {
746
+ /** @type {Promise<LeaseConnection>} */
747
+ const attempt = establish().finally(() => {
748
+ if (connecting === attempt) connecting = null
749
+ })
750
+ connecting = attempt
751
+ target = await attempt
752
+ }
753
+ // A close that landed while this dial was in flight is terminal: a caller
754
+ // resuming afterwards must never speak on the abandoned connection.
755
+ if (closed) throw transportError('lease client is closed')
756
+ return target
757
+ }
758
+
759
+ /**
760
+ * Send one protocol request on a live connection.
761
+ * @param {string} op
762
+ * @param {Record<string, any>} [payload]
763
+ * @returns {Promise<any>}
764
+ */
765
+ async function request(op, payload = {}) {
766
+ if (typeof op !== 'string' || op.length === 0) throw new TypeError('lease request requires an operation name')
767
+ const target = await ensureConnection()
768
+ if (closed) throw transportError('lease client is closed')
769
+ if (replayQueued) {
770
+ // Flush before the caller's own frame so an observe-after-reconnect call
771
+ // reports the world the replay has already restored.
772
+ replayQueued = false
773
+ await runReplay()
774
+ // The replay is wire I/O too: a close during it stops this request as well.
775
+ if (closed) throw transportError('lease client is closed')
776
+ }
777
+ return await exchange(target, op, payload)
778
+ }
779
+
780
+ // ---------------------------------------------------------------------
781
+ // Ledger: what we hold, what a turn boundary releases, what a reconnect replays
782
+ // ---------------------------------------------------------------------
783
+
784
+ /**
785
+ * @param {any} owner
786
+ * @returns {{ id: string, session: string, label: string }}
787
+ */
788
+ function normalizeOwner(owner) {
789
+ const rawId = typeof owner === 'string' ? owner : owner?.id
790
+ const id = typeof rawId === 'string' && rawId.length > 0 ? rawId : undefined
791
+ if (id === undefined) throw new TypeError('lease owner requires a non-empty id (the lineage)')
792
+ const session = typeof owner?.session === 'string' && owner.session.length > 0 ? owner.session : id
793
+ const label = typeof owner?.label === 'string' && owner.label.length > 0 ? owner.label : session
794
+ return { id, session, label }
795
+ }
796
+
797
+ /**
798
+ * Normalise and validate the path batch. A relative path is a wiring bug, and
799
+ * the broker would only answer `invalid-path`, which this client would have to
800
+ * degrade — so it is refused loudly here instead of silently losing
801
+ * arbitration for every write of that tool.
802
+ * @param {unknown} paths
803
+ * @returns {string[]}
804
+ */
805
+ function normalizePaths(paths) {
806
+ if (!Array.isArray(paths)) throw new TypeError('lease paths must be an array')
807
+ /** @type {string[]} */
808
+ const out = []
809
+ for (const path of paths) {
810
+ if (typeof path !== 'string' || path.length === 0) throw new TypeError('lease paths must be non-empty strings')
811
+ if (path.includes('\u0000')) throw new TypeError('lease paths may not contain NUL bytes')
812
+ if (!isAbsolute(path)) throw new TypeError(`lease paths must be absolute: ${path}`)
813
+ if (!out.includes(path)) out.push(path)
814
+ }
815
+ return out
816
+ }
817
+
818
+ /** @param {unknown} scope @returns {string} */
819
+ function requireScope(scope) {
820
+ if (typeof scope !== 'string' || scope.length === 0) {
821
+ throw new TypeError('lease scope must be a non-empty string (the project root)')
822
+ }
823
+ return scope
824
+ }
825
+
826
+ /**
827
+ * @param {string} lineage
828
+ * @param {string} session
829
+ * @returns {{ id: string, session: string, label: string }}
830
+ */
831
+ function ownerOf(lineage, session) {
832
+ return { id: lineage, session, label: labels.get(lineage) ?? session }
833
+ }
834
+
835
+ /**
836
+ * The owner to speak as for paths this client already holds. `hold` extends
837
+ * ownership, so an owner can only be inferred from something already owned; a
838
+ * batch we hold nothing of is refused locally as `not-owned` (a closed-vocabulary
839
+ * code) instead of sending a frame the broker would reject anyway.
840
+ * @param {string[]} paths
841
+ * @returns {{ id: string, session: string, label: string }}
842
+ */
843
+ function ownerForPaths(paths) {
844
+ for (const path of paths) {
845
+ const record = ledger.get(path)
846
+ if (record !== undefined) return ownerOf(record.lineage, record.session)
847
+ }
848
+ throw leaseError('not-owned', 'this client holds none of the requested paths, so there is no owner to extend', {
849
+ paths,
850
+ })
851
+ }
852
+
853
+ /**
854
+ * Record a granted batch. Only the fields the ledger exists for are kept: who
855
+ * to release as, and whether a retention must be replayed.
856
+ * @param {any[]} granted
857
+ * @param {{ id: string, session: string, label: string }} owner
858
+ * @param {string} scope
859
+ */
860
+ function remember(granted, owner, scope) {
861
+ lastScope = scope
862
+ labels.set(owner.id, owner.label)
863
+ for (const lease of granted) {
864
+ const path = typeof lease?.path === 'string' ? lease.path : undefined
865
+ if (path === undefined) continue
866
+ ledger.set(path, {
867
+ session: owner.session,
868
+ lineage: owner.id,
869
+ holdUntil: typeof lease.holdUntil === 'number' ? lease.holdUntil : null,
870
+ })
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Record a successful retention.
876
+ * @param {any} reply
877
+ * @param {{ id: string, session: string, label: string }} owner
878
+ */
879
+ function applyHold(reply, owner) {
880
+ const held = Array.isArray(reply?.held) ? reply.held : []
881
+ labels.set(owner.id, owner.label)
882
+ for (const entry of held) {
883
+ const path = typeof entry?.path === 'string' ? entry.path : undefined
884
+ if (path === undefined) continue
885
+ const holdUntil = typeof entry.holdUntil === 'number' ? entry.holdUntil : null
886
+ ledger.set(path, { session: owner.session, lineage: owner.id, holdUntil })
887
+ }
888
+ }
889
+
890
+ // ---------------------------------------------------------------------
891
+ // Arbitration-facing operations
892
+ // ---------------------------------------------------------------------
893
+
894
+ /**
895
+ * Acquire a batch up front (the explicit `edit_lock_acquire` form of what every
896
+ * managed write does implicitly).
897
+ * @param {any} owner
898
+ * @param {string[]} paths
899
+ * @param {string} scope
900
+ * @returns {Promise<any>}
901
+ */
902
+ async function acquire(owner, paths, scope) {
903
+ const holder = normalizeOwner(owner)
904
+ const list = normalizePaths(paths)
905
+ const leaseScope = requireScope(scope)
906
+ const reply = await request('acquire', { owner: holder, scope: leaseScope, paths: list })
907
+ if (Array.isArray(reply.granted)) remember(reply.granted, holder, leaseScope)
908
+ return reply
909
+ }
910
+
911
+ /**
912
+ * Attempt the batch again on a brand-new connection (the single retry D7
913
+ * allows). Returns `false` when the retry failed too; a real conflict is
914
+ * rethrown, because conflicts are never degraded.
915
+ * @param {{ id: string, session: string, label: string }} owner
916
+ * @param {string[]} paths
917
+ * @param {string} scope
918
+ * @returns {Promise<boolean>}
919
+ */
920
+ async function retryAcquire(owner, paths, scope) {
921
+ const stale = connection
922
+ if (stale !== null) dropConnection(stale)
923
+ try {
924
+ await acquire(owner, paths, scope)
925
+ return true
926
+ } catch (error) {
927
+ if (isLeaseConflict(error)) throw error
928
+ const broken = connection
929
+ if (broken !== null) dropConnection(broken)
930
+ return false
931
+ }
932
+ }
933
+
934
+ /**
935
+ * Acquire the batch, or run the write anyway (D7).
936
+ *
937
+ * One retry on a fresh connection at most; when that fails too, a single audit
938
+ * record explains the silent loss of mutual exclusion and `fn` proceeds with no
939
+ * lease. A real conflict is rethrown on every path — including out of the retry.
940
+ *
941
+ * @param {{ id: string, session: string, label: string }} owner
942
+ * @param {string[]} paths
943
+ * @param {string} scope
944
+ * @returns {Promise<void>}
945
+ */
946
+ async function acquireOrDegrade(owner, paths, scope) {
947
+ try {
948
+ await acquire(owner, paths, scope)
949
+ return
950
+ } catch (error) {
951
+ if (isLeaseConflict(error)) throw error
952
+ if (await retryAcquire(owner, paths, scope)) return
953
+ auditDegraded({
954
+ code: errorCode(error),
955
+ session: owner.session,
956
+ lineage: owner.id,
957
+ scope,
958
+ paths: paths.length,
959
+ })
960
+ }
961
+ }
962
+
963
+ /**
964
+ * Hold the leases for `owner` for the duration of `fn` (design D2): the leases
965
+ * are acquired once and NOT released in a `finally` — ownership lasts until the
966
+ * turn ends (`releaseSession`) or the process does (`releaseAll`/connection
967
+ * close). `fn` runs exactly once on every path that is not a real conflict.
968
+ *
969
+ * @template T
970
+ * @param {any} owner `{id, session, label}` (or a bare lineage id)
971
+ * @param {string[]} paths absolute paths
972
+ * @param {string} scope opaque project root, used only for `status` filtering
973
+ * @param {() => T | Promise<T>} fn the managed operation
974
+ * @returns {Promise<T>}
975
+ */
976
+ async function withLeases(owner, paths, scope, fn) {
977
+ if (typeof fn !== 'function') throw new TypeError('withLeases requires a callback')
978
+ const holder = normalizeOwner(owner)
979
+ const list = normalizePaths(paths)
980
+ const leaseScope = requireScope(scope)
981
+ if (list.length > 0) await acquireOrDegrade(holder, list, leaseScope)
982
+ return await fn()
983
+ }
984
+
985
+ /**
986
+ * Extend the leases for `paths` (only ever later, ≤ 60 minutes; the broker
987
+ * owns that rule). The owner is inferred from the ledger when omitted, which is
988
+ * the only legitimate case: `hold` applies to something already held.
989
+ * @param {string[]} paths
990
+ * @param {number} minutes
991
+ * @param {any} [owner]
992
+ * @returns {Promise<any>}
993
+ */
994
+ async function hold(paths, minutes, owner) {
995
+ const list = normalizePaths(paths)
996
+ const holder = owner === undefined ? ownerForPaths(list) : normalizeOwner(owner)
997
+ const reply = await request('hold', { owner: holder, paths: list, minutes })
998
+ applyHold(reply, holder)
999
+ return reply
1000
+ }
1001
+
1002
+ /**
1003
+ * Read-only projection. Filters stack; only the protocol's three filter keys
1004
+ * are forwarded (the frame grammar rejects extras).
1005
+ * @param {{ scope?: string, session?: string, path?: string }} [filter]
1006
+ * @returns {Promise<any>}
1007
+ */
1008
+ function status(filter = {}) {
1009
+ /** @type {Record<string, string>} */
1010
+ const payload = {}
1011
+ if (typeof filter?.scope === 'string' && filter.scope.length > 0) payload.scope = filter.scope
1012
+ if (typeof filter?.session === 'string' && filter.session.length > 0) payload.session = filter.session
1013
+ if (typeof filter?.path === 'string' && filter.path.length > 0) payload.path = filter.path
1014
+ return request('status', payload)
1015
+ }
1016
+
1017
+ /**
1018
+ * Read-only connection health for the status surface (design D13).
1019
+ *
1020
+ * `ok` means the handshake stands right now, so a `status` read reaches the
1021
+ * arbiter; `degraded` means this process HAS been arbitrated before but the
1022
+ * connection is gone at this instant (the next write would degrade and
1023
+ * proceed, D7); `unavailable` means it was never reached in this process.
1024
+ *
1025
+ * Reporting only: it opens nothing, closes nothing and retries nothing, so a
1026
+ * reader can never change an arbitration verdict by looking.
1027
+ *
1028
+ * @returns {'ok' | 'degraded' | 'unavailable'}
1029
+ */
1030
+ function connectionState() {
1031
+ if (closed) return 'unavailable'
1032
+ if (connection !== null && greeted) return 'ok'
1033
+ // `startedAt` is set by the first successful handshake and never cleared:
1034
+ // its presence is exactly "this process once had an arbiter".
1035
+ return startedAt === undefined ? 'unavailable' : 'degraded'
1036
+ }
1037
+
1038
+ /**
1039
+ * Ask the holder of `path` to hand it over (negotiation, D4).
1040
+ * @param {string} path
1041
+ * @param {any} from the requesting owner
1042
+ * @returns {Promise<any>}
1043
+ */
1044
+ function requestTransfer(path, from) {
1045
+ if (typeof path !== 'string' || path.length === 0) throw new TypeError('requestTransfer requires a path')
1046
+ return request('request', { from: normalizeOwner(from), path })
1047
+ }
1048
+
1049
+ /**
1050
+ * Answer a pending transfer request as the holder.
1051
+ * @param {{ requestId: string, owner: any, decision: 'release' | 'keep' }} input
1052
+ * @returns {Promise<any>}
1053
+ */
1054
+ function reply(input) {
1055
+ const requestId = input?.requestId
1056
+ if (typeof requestId !== 'string' || requestId.length === 0) throw new TypeError('reply requires a requestId')
1057
+ const decision = input?.decision
1058
+ if (decision !== 'release' && decision !== 'keep') throw new TypeError("reply decision must be 'release' or 'keep'")
1059
+ return request('reply', { owner: normalizeOwner(input?.owner), requestId, decision })
1060
+ }
1061
+
1062
+ // ---------------------------------------------------------------------
1063
+ // Release and replay
1064
+ // ---------------------------------------------------------------------
1065
+
1066
+ /**
1067
+ * Release everything this session holds EXCEPT retentions (design D10): the
1068
+ * `turn/end` rule. A failure is swallowed — turn-boundary release is an
1069
+ * optimisation, and the daemon's disconnect rule plus the TTL already cover it.
1070
+ * @param {string} session
1071
+ * @returns {Promise<{ released: string[] }>}
1072
+ */
1073
+ async function releaseSession(session) {
1074
+ return await releaseMatching((record) => record.session === session && record.holdUntil === null)
1075
+ }
1076
+
1077
+ /**
1078
+ * Release everything this session holds, retentions included (design D10): the
1079
+ * `agent/disposed` rule — a disposed agent never comes back.
1080
+ * @param {string} session
1081
+ * @returns {Promise<{ released: string[] }>}
1082
+ */
1083
+ async function releaseAll(session) {
1084
+ return await releaseMatching((record) => record.session === session)
1085
+ }
1086
+
1087
+ /**
1088
+ * @param {(record: LeaseRecord) => boolean} match
1089
+ * @returns {Promise<{ released: string[] }>}
1090
+ */
1091
+ async function releaseMatching(match) {
1092
+ /** @type {Map<string, { session: string, paths: string[] }>} */
1093
+ const groups = new Map()
1094
+ for (const [path, record] of [...ledger]) {
1095
+ if (!match(record)) continue
1096
+ // The intent is "stop holding this", and a failed frame cannot undo the
1097
+ // intent: the entry goes, whatever the transport does next.
1098
+ ledger.delete(path)
1099
+ const group = groups.get(record.lineage) ?? { session: record.session, paths: [] }
1100
+ group.session = record.session
1101
+ group.paths.push(path)
1102
+ groups.set(record.lineage, group)
1103
+ }
1104
+ /** @type {string[]} */
1105
+ const released = []
1106
+ for (const [lineage, group] of groups) {
1107
+ try {
1108
+ const response = await request('release', { owner: ownerOf(lineage, group.session), paths: group.paths })
1109
+ if (Array.isArray(response.released)) released.push(...response.released)
1110
+ } catch {
1111
+ // See above: never throw at a turn boundary, never keep state to retry.
1112
+ }
1113
+ }
1114
+ return { released }
1115
+ }
1116
+
1117
+ /**
1118
+ * Replay every still-valid retention (`holdUntil > now`) so a daemon restart
1119
+ * does not silently drop a hold. An entry somebody else now owns is removed
1120
+ * without an error: the next write sees an ordinary conflict (D6).
1121
+ * @returns {Promise<{ replayed: number, dropped: number }>}
1122
+ */
1123
+ async function replayHolds() {
1124
+ await ensureConnection()
1125
+ if (closed) throw transportError('lease client is closed')
1126
+ // An explicit replay satisfies a queued one: never send the same retention
1127
+ // twice just because a reconnect happened to arm it.
1128
+ replayQueued = false
1129
+ return await runReplay()
1130
+ }
1131
+
1132
+ /**
1133
+ * @returns {Promise<{ replayed: number, dropped: number }>}
1134
+ */
1135
+ function runReplay() {
1136
+ if (replaying !== null) return replaying
1137
+ /** @type {Promise<{ replayed: number, dropped: number }>} */
1138
+ const attempt = replayOnce().finally(() => {
1139
+ if (replaying === attempt) replaying = null
1140
+ })
1141
+ replaying = attempt
1142
+ return attempt
1143
+ }
1144
+
1145
+ /**
1146
+ * @returns {Promise<{ replayed: number, dropped: number }>}
1147
+ */
1148
+ async function replayOnce() {
1149
+ let replayed = 0
1150
+ let dropped = 0
1151
+ const at = now()
1152
+ for (const [path, record] of [...ledger]) {
1153
+ if (record.holdUntil === null || record.holdUntil <= at) continue
1154
+ const owner = ownerOf(record.lineage, record.session)
1155
+ const scope = lastScope
1156
+ try {
1157
+ // Re-acquire first: after a daemon restart the table is empty, so a bare
1158
+ // `hold` would be `not-owned`. The pair is idempotent by protocol.
1159
+ await acquire(owner, [path], requireScope(scope))
1160
+ const response = await request('hold', { owner, paths: [path], minutes: holdMinutes(record.holdUntil - at) })
1161
+ applyHold(response, owner)
1162
+ replayed += 1
1163
+ } catch (error) {
1164
+ if (errorCode(error) === TRANSPORT_CODE) {
1165
+ // The connection died again: keep the rest for the next reconnect
1166
+ // rather than forgetting a retention nobody has seen fail.
1167
+ break
1168
+ }
1169
+ ledger.delete(path)
1170
+ dropped += 1
1171
+ }
1172
+ }
1173
+ return { replayed, dropped }
1174
+ }
1175
+
1176
+ /**
1177
+ * Remaining retention as `hold` minutes, clamped to the protocol's closed
1178
+ * interval `(0, 60]` and rounded UP so a replay never shortens a hold.
1179
+ * @param {number} remainingMs
1180
+ * @returns {number}
1181
+ */
1182
+ function holdMinutes(remainingMs) {
1183
+ const minutes = Math.ceil((remainingMs / 60_000) * 1000) / 1000
1184
+ return Math.min(HOLD_MAX_MINUTES, Math.max(0.001, minutes))
1185
+ }
1186
+
1187
+ // ---------------------------------------------------------------------
1188
+ // Degrade accounting, push events, shutdown
1189
+ // ---------------------------------------------------------------------
1190
+
1191
+ /**
1192
+ * Record one degraded write. Rate limited per process (D7) so a broken daemon
1193
+ * cannot flood the audit log, and never allowed to throw.
1194
+ * @param {Record<string, any>} detail
1195
+ */
1196
+ function auditDegraded(detail) {
1197
+ const at = now()
1198
+ if (lastDegradedAt !== null && at - lastDegradedAt < degradedAuditWindowMs) return
1199
+ lastDegradedAt = at
1200
+ try {
1201
+ audit('degraded', { ...detail, at })
1202
+ } catch {
1203
+ // Audit is log-only; it must never break a write.
1204
+ }
1205
+ }
1206
+
1207
+ /**
1208
+ * Subscribe to broker-pushed `request` / `settled` / `released` events.
1209
+ * @param {(event: any) => void} handler
1210
+ * @returns {() => void} unsubscribe
1211
+ */
1212
+ function onEvent(handler) {
1213
+ if (typeof handler !== 'function') throw new TypeError('onEvent requires a handler')
1214
+ eventHandlers.add(handler)
1215
+ return () => {
1216
+ eventHandlers.delete(handler)
1217
+ }
1218
+ }
1219
+
1220
+ /**
1221
+ * Subscribe to connection resets (D5). A reset means the current connection
1222
+ * is gone, so anything that was waiting on a push from it is over — there is
1223
+ * nothing to replay and no fact to infer about ownership.
1224
+ * @param {() => void} handler
1225
+ * @returns {() => void} unsubscribe
1226
+ */
1227
+ function onReset(handler) {
1228
+ if (typeof handler !== 'function') throw new TypeError('onReset requires a handler')
1229
+ resetHandlers.add(handler)
1230
+ return () => {
1231
+ resetHandlers.delete(handler)
1232
+ }
1233
+ }
1234
+
1235
+ /**
1236
+ * Read-only capability query: did the CURRENT handshake advertise this push
1237
+ * event? An older broker, a closed client and a dropped connection all answer
1238
+ * `false` — the caller must report the capability as unavailable instead of
1239
+ * waiting for an event that cannot arrive.
1240
+ * @param {string} name
1241
+ * @returns {boolean}
1242
+ */
1243
+ function supportsEvent(name) {
1244
+ return typeof name === 'string' && eventCapabilities.has(name)
1245
+ }
1246
+
1247
+ /**
1248
+ * Close the client. The daemon keeps running: it is shared with every other
1249
+ * client on this machine, and its own idle window is what ends it.
1250
+ *
1251
+ * A close is terminal for the whole transport lifetime, including a dial or
1252
+ * handshake that is still in flight: the callers waiting on that attempt are
1253
+ * settled with a transport failure the moment the close lands (even when the
1254
+ * underlying dial never returns), the late socket is closed instead of
1255
+ * adopted, and any subscription waiting on it is told with exactly one reset
1256
+ * notification.
1257
+ */
1258
+ function close() {
1259
+ if (closed) return
1260
+ closed = true
1261
+ ledger.clear()
1262
+ const target = connection
1263
+ if (target !== null) {
1264
+ // An intentional close ends the current connection like any other loss, so
1265
+ // a subscription waiting on it is told instead of waiting forever.
1266
+ invalidateConnection(target, true)
1267
+ closeSocket(target)
1268
+ notifyCloseWaiters()
1269
+ return
1270
+ }
1271
+ // No connection is installed yet, but a dial or handshake may still be in
1272
+ // flight. `establish` closes its late socket rather than adopting it and the
1273
+ // latch answers the callers now; the reset is reported here because no
1274
+ // connection exists to be invalidated later.
1275
+ const dialing = connecting !== null
1276
+ failWaiters(transportError('lease client closed'))
1277
+ if (dialing) notifyReset()
1278
+ notifyCloseWaiters()
1279
+ }
1280
+
1281
+ return Object.freeze({
1282
+ endpoint,
1283
+ request,
1284
+ acquire,
1285
+ withLeases,
1286
+ hold,
1287
+ status,
1288
+ connectionState,
1289
+ requestTransfer,
1290
+ reply,
1291
+ releaseSession,
1292
+ releaseAll,
1293
+ replayHolds,
1294
+ onEvent,
1295
+ onReset,
1296
+ supportsEvent,
1297
+ close,
1298
+ })
1299
+ }