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,237 @@
1
+ // Wire protocol for `weir-editlockd`: constants plus the frame validator.
2
+ //
3
+ // Pure module — no IO, no clock, no DSH/cordis/@deepseek-ai imports. The same
4
+ // file is the single source of truth for what crosses the socket, and
5
+ // docs/features/edit-lock-protocol.md is kept in lockstep with it by
6
+ // test/lease-broker-protocol-doc.test.js (op names and error codes must match
7
+ // in BOTH directions: a doc-only or an implementation-only entry fails).
8
+ //
9
+ // Field discipline is exact: a frame carrying any field outside the per-op
10
+ // allowlist is rejected as `bad-request`. That keeps the accepted grammar
11
+ // readable from this file alone and prevents "tolerated extras" from drifting
12
+ // into a de-facto second protocol.
13
+
14
+ /** Protocol major version. Bump only for breaking wire changes; the value is
15
+ * part of the socket address so majors never arbitrate against each other. */
16
+ export const PROTOCOL = 1
17
+
18
+ /** Request operations accepted after the handshake (`hello` is the handshake). */
19
+ export const OPS = Object.freeze(['hello', 'acquire', 'release', 'hold', 'status', 'request', 'reply'])
20
+
21
+ /** Unsolicited events pushed by the broker. `request` and `settled` go to one
22
+ * connection; `released` is a fact offered to every connected client. */
23
+ export const EVENTS = Object.freeze(['request', 'settled', 'released'])
24
+
25
+ /** Closed error vocabulary. No code may describe an operator action: every
26
+ * refusal either resolves by negotiation or by validity elapsing. */
27
+ export const ERROR_CODES = Object.freeze([
28
+ 'protocol-mismatch',
29
+ 'bad-request',
30
+ 'invalid-path',
31
+ 'held',
32
+ 'not-owned',
33
+ 'free',
34
+ 'self',
35
+ 'limit',
36
+ 'expired',
37
+ 'stale',
38
+ 'not-holder',
39
+ 'internal',
40
+ ])
41
+
42
+ /** Reply decisions on a pending transfer request. */
43
+ export const DECISIONS = Object.freeze(['release', 'keep'])
44
+
45
+ /**
46
+ * @typedef {{ id: string, session: string, label: string }} Owner
47
+ * @typedef {{ op: 'hello', protocol: number, reqId?: string }} HelloFrame
48
+ * @typedef {{ op: 'acquire', owner: Owner, scope: string, paths: string[], ttlMs?: number, reqId?: string }} AcquireFrame
49
+ * @typedef {{ op: 'release', owner: Owner, paths: string[], reqId?: string }} ReleaseFrame
50
+ * @typedef {{ op: 'hold', owner: Owner, paths: string[], minutes: number, reqId?: string }} HoldFrame
51
+ * @typedef {{ op: 'status', scope?: string, session?: string, path?: string, reqId?: string }} StatusFrame
52
+ * @typedef {{ op: 'request', from: Owner, path: string, reqId?: string }} RequestFrame
53
+ * @typedef {{ op: 'reply', owner: Owner, requestId: string, decision: 'release' | 'keep', reqId?: string }} ReplyFrame
54
+ * @typedef {HelloFrame | AcquireFrame | ReleaseFrame | HoldFrame | StatusFrame | RequestFrame | ReplyFrame} Frame
55
+ * @typedef {'hello' | 'acquire' | 'release' | 'hold' | 'status' | 'request' | 'reply'} Op
56
+ * @typedef {{ ok: true, op: Op, value: Frame } | { ok: false, code: 'bad-request' | 'invalid-path' }} ValidationResult
57
+ */
58
+
59
+ const OP_SET = new Set(OPS)
60
+
61
+ /** Per-op field allowlist: the accepted grammar, exactly. `reqId` is the
62
+ * client's correlation token and is legal on every frame. */
63
+ const FIELDS = Object.freeze({
64
+ hello: ['op', 'protocol', 'reqId'],
65
+ acquire: ['op', 'owner', 'scope', 'paths', 'ttlMs', 'reqId'],
66
+ release: ['op', 'owner', 'paths', 'reqId'],
67
+ hold: ['op', 'owner', 'paths', 'minutes', 'reqId'],
68
+ status: ['op', 'scope', 'session', 'path', 'reqId'],
69
+ request: ['op', 'from', 'path', 'reqId'],
70
+ reply: ['op', 'owner', 'requestId', 'decision', 'reqId'],
71
+ })
72
+
73
+ /**
74
+ * @param {unknown} value
75
+ * @returns {value is Record<string, any>} true for a non-array object
76
+ */
77
+ function isRecord(value) {
78
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
79
+ }
80
+
81
+ /**
82
+ * Non-empty string without NUL.
83
+ * @param {unknown} value
84
+ * @returns {value is string}
85
+ */
86
+ function isText(value) {
87
+ return typeof value === 'string' && value.length > 0 && !value.includes('\u0000')
88
+ }
89
+
90
+ /**
91
+ * Every key of `record` is listed in `allowed` (no extras, no unknowns).
92
+ * @param {Record<string, any>} record
93
+ * @param {readonly string[]} allowed
94
+ */
95
+ function onlyFields(record, allowed) {
96
+ for (const key of Object.keys(record)) if (!allowed.includes(key)) return false
97
+ return true
98
+ }
99
+
100
+ /** `owner` is exactly `{id, session, label}` with three non-empty strings — the
101
+ * broker never interprets them (D8). @param {unknown} value */
102
+ function isOwner(value) {
103
+ if (!isRecord(value)) return false
104
+ if (!onlyFields(value, ['id', 'session', 'label'])) return false
105
+ return isText(value.id) && isText(value.session) && isText(value.label)
106
+ }
107
+
108
+ /** Absolute POSIX path, or a win32 drive/UNC path. @param {unknown} value */
109
+ function isAbsolutePath(value) {
110
+ if (!isText(value)) return false
111
+ if (value.startsWith('/')) return true
112
+ if (/^[A-Za-z]:[\\/]/.test(value)) return true
113
+ return value.startsWith('\\\\')
114
+ }
115
+
116
+ /** Non-empty array of absolute paths. @param {unknown} value */
117
+ function isPathList(value) {
118
+ if (!Array.isArray(value) || value.length === 0) return false
119
+ return value.every((path) => isAbsolutePath(path))
120
+ }
121
+
122
+ /** Finite positive number (ttl overrides and retention minutes). @param {unknown} value */
123
+ function isPositiveNumber(value) {
124
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
125
+ }
126
+
127
+ /** @returns {ValidationResult} */
128
+ function badRequest() {
129
+ return /** @type {ValidationResult} */ ({ ok: false, code: 'bad-request' })
130
+ }
131
+
132
+ /** @type {Record<string, (frame: Record<string, any>) => ValidationResult>} */
133
+ const BUILDERS = {
134
+ /** @returns {ValidationResult} */
135
+ hello(frame) {
136
+ if (!Number.isInteger(frame.protocol) || frame.protocol < 0) return badRequest()
137
+ /** @type {HelloFrame} */
138
+ const value = { op: 'hello', protocol: frame.protocol }
139
+ if (frame.reqId !== undefined) value.reqId = frame.reqId
140
+ return { ok: true, op: 'hello', value }
141
+ },
142
+
143
+ /** @returns {ValidationResult} */
144
+ acquire(frame) {
145
+ if (!isOwner(frame.owner)) return badRequest()
146
+ if (!isText(frame.scope)) return badRequest()
147
+ if (!isPathList(frame.paths)) return { ok: false, code: 'invalid-path' }
148
+ if (frame.ttlMs !== undefined && !isPositiveNumber(frame.ttlMs)) return badRequest()
149
+ /** @type {AcquireFrame} */
150
+ const value = { op: 'acquire', owner: { ...frame.owner }, scope: frame.scope, paths: [...frame.paths] }
151
+ if (frame.ttlMs !== undefined) value.ttlMs = frame.ttlMs
152
+ if (frame.reqId !== undefined) value.reqId = frame.reqId
153
+ return { ok: true, op: 'acquire', value }
154
+ },
155
+
156
+ /** @returns {ValidationResult} */
157
+ release(frame) {
158
+ if (!isOwner(frame.owner)) return badRequest()
159
+ if (!isPathList(frame.paths)) return { ok: false, code: 'invalid-path' }
160
+ /** @type {ReleaseFrame} */
161
+ const value = { op: 'release', owner: { ...frame.owner }, paths: [...frame.paths] }
162
+ if (frame.reqId !== undefined) value.reqId = frame.reqId
163
+ return { ok: true, op: 'release', value }
164
+ },
165
+
166
+ /** @returns {ValidationResult} */
167
+ hold(frame) {
168
+ if (!isOwner(frame.owner)) return badRequest()
169
+ if (!isPathList(frame.paths)) return { ok: false, code: 'invalid-path' }
170
+ if (!isPositiveNumber(frame.minutes)) return badRequest()
171
+ /** @type {HoldFrame} */
172
+ const value = { op: 'hold', owner: { ...frame.owner }, paths: [...frame.paths], minutes: frame.minutes }
173
+ if (frame.reqId !== undefined) value.reqId = frame.reqId
174
+ return { ok: true, op: 'hold', value }
175
+ },
176
+
177
+ /** @returns {ValidationResult} */
178
+ status(frame) {
179
+ /** @type {StatusFrame} */
180
+ const value = { op: 'status' }
181
+ if (frame.scope !== undefined) {
182
+ if (!isText(frame.scope)) return badRequest()
183
+ value.scope = frame.scope
184
+ }
185
+ if (frame.session !== undefined) {
186
+ if (!isText(frame.session)) return badRequest()
187
+ value.session = frame.session
188
+ }
189
+ if (frame.path !== undefined) {
190
+ if (!isAbsolutePath(frame.path)) return { ok: false, code: 'invalid-path' }
191
+ value.path = frame.path
192
+ }
193
+ if (frame.reqId !== undefined) value.reqId = frame.reqId
194
+ return { ok: true, op: 'status', value }
195
+ },
196
+
197
+ /** @returns {ValidationResult} */
198
+ request(frame) {
199
+ if (!isOwner(frame.from)) return badRequest()
200
+ if (!isAbsolutePath(frame.path)) return { ok: false, code: 'invalid-path' }
201
+ /** @type {RequestFrame} */
202
+ const value = { op: 'request', from: { ...frame.from }, path: frame.path }
203
+ if (frame.reqId !== undefined) value.reqId = frame.reqId
204
+ return { ok: true, op: 'request', value }
205
+ },
206
+
207
+ /** @returns {ValidationResult} */
208
+ reply(frame) {
209
+ if (!isOwner(frame.owner)) return badRequest()
210
+ if (!isText(frame.requestId)) return badRequest()
211
+ if (!DECISIONS.includes(frame.decision)) return badRequest()
212
+ /** @type {ReplyFrame} */
213
+ const value = { op: 'reply', owner: { ...frame.owner }, requestId: frame.requestId, decision: frame.decision }
214
+ if (frame.reqId !== undefined) value.reqId = frame.reqId
215
+ return { ok: true, op: 'reply', value }
216
+ },
217
+ }
218
+
219
+ /**
220
+ * Validate one decoded frame against the per-op grammar.
221
+ *
222
+ * Returns `{ok:true, op, value}` with the normalized frame (copies of owner and
223
+ * paths, `reqId` carried through when present) or `{ok:false, code}` where code
224
+ * is `bad-request` for shape/identity/value problems and `invalid-path` for a
225
+ * non-absolute path, a NUL byte or an empty path.
226
+ *
227
+ * @param {unknown} frame decoded JSON value
228
+ * @returns {ValidationResult}
229
+ */
230
+ export function validateFrame(frame) {
231
+ if (!isRecord(frame)) return { ok: false, code: 'bad-request' }
232
+ const op = frame.op
233
+ if (typeof op !== 'string' || !OP_SET.has(op)) return { ok: false, code: 'bad-request' }
234
+ if (!onlyFields(frame, FIELDS[op])) return { ok: false, code: 'bad-request' }
235
+ if (frame.reqId !== undefined && !isText(frame.reqId)) return { ok: false, code: 'bad-request' }
236
+ return BUILDERS[op](frame)
237
+ }
@@ -0,0 +1,402 @@
1
+ // Socket transport for the lease broker daemon: framing, handshake, process
2
+ // lifetime. No business rules live here — every verdict comes from the kernel
3
+ // in ./state.js, and this file only decodes frames, routes them, and writes
4
+ // replies and events back.
5
+ //
6
+ // Wire shape: newline-delimited JSON, one frame per line, in both directions.
7
+ // Requests carry `op`; replies carry `ok` plus either the result fields or
8
+ // `error: {code, ...details}` and always echo the client's `reqId` when it sent
9
+ // one. Unsolicited events carry `event` ('request' | 'settled' | 'released').
10
+ //
11
+ // Process lifetime (design D1/D8): the daemon is a cache, so it is allowed to
12
+ // die. It exits after `idleMs` with no connected client, and stays alive as
13
+ // long as any client is connected. A new connection cancels the countdown.
14
+
15
+ import { createConnection, createServer } from 'node:net'
16
+ import { createHash } from 'node:crypto'
17
+ import { unlinkSync } from 'node:fs'
18
+ import { homedir as osHomedir, tmpdir as osTmpdir } from 'node:os'
19
+ import { join } from 'node:path'
20
+ import process from 'node:process'
21
+ import { EVENTS, PROTOCOL, validateFrame } from './protocol.js'
22
+
23
+ /** Idle window before a client-less daemon exits. */
24
+ export const DEFAULT_IDLE_MS = 60_000
25
+ /** Period of the memory-reclaiming sweep. */
26
+ export const DEFAULT_SWEEP_MS = 30_000
27
+
28
+ /**
29
+ * Default endpoint address for this protocol major.
30
+ *
31
+ * POSIX: `<tmpdir>/weir-editlockd-<uid>-v<protocol>.sock` — per user, so two
32
+ * users on one machine never share an arbiter.
33
+ * win32: `\\.\pipe\weir-editlockd-<sha256(homedir)[0..12]>-v<protocol>` — the
34
+ * pipe namespace is machine-global, so the home directory is folded into the
35
+ * name instead of the uid.
36
+ *
37
+ * The protocol major is part of the address: two majors never arbitrate against
38
+ * each other, and the older one simply idles out.
39
+ *
40
+ * @param {object} [options]
41
+ * @param {string} [options.platform] default `process.platform`
42
+ * @param {number} [options.protocol] default `PROTOCOL`
43
+ * @param {string} [options.tmpdir] default `os.tmpdir()`
44
+ * @param {string} [options.homedir] default `os.homedir()`
45
+ * @param {number | string} [options.uid] default `process.getuid()`
46
+ * @returns {string}
47
+ */
48
+ export function defaultSocketPath({ platform = process.platform, protocol = PROTOCOL, tmpdir, homedir, uid } = {}) {
49
+ if (platform === 'win32') {
50
+ const home = homedir ?? osHomedir()
51
+ const digest = createHash('sha256').update(home).digest('hex').slice(0, 12)
52
+ return `\\\\.\\pipe\\weir-editlockd-${digest}-v${protocol}`
53
+ }
54
+ const dir = tmpdir ?? osTmpdir()
55
+ const user = uid ?? (typeof process.getuid === 'function' ? process.getuid() : 'unknown')
56
+ return join(dir, `weir-editlockd-${user}-v${protocol}.sock`)
57
+ }
58
+
59
+ /**
60
+ * @typedef {object} LeaseBroker
61
+ * @property {(connectionId: string) => void} connect
62
+ * @property {(connectionId: string) => void} disconnect
63
+ * @property {(input: any) => any} acquire
64
+ * @property {(input: any) => any} release
65
+ * @property {(input: any) => any} hold
66
+ * @property {(filter?: any) => any} status
67
+ * @property {(input: any) => any} request
68
+ * @property {(input: any) => any} reply
69
+ * @property {() => any} sweep
70
+ */
71
+
72
+ /** Transport-level dispatch: request op → kernel call. Deliberately thin and
73
+ * untyped — the kernel is the only place that decides anything. */
74
+ const HANDLERS = {
75
+ acquire: (broker, connectionId, value) =>
76
+ broker.acquire({ connectionId, owner: value.owner, scope: value.scope, paths: value.paths, ttlMs: value.ttlMs }),
77
+ release: (broker, connectionId, value) => broker.release({ connectionId, owner: value.owner, paths: value.paths }),
78
+ hold: (broker, connectionId, value) =>
79
+ broker.hold({ connectionId, owner: value.owner, paths: value.paths, minutes: value.minutes }),
80
+ status: (broker, connectionId, value) => broker.status({ scope: value.scope, session: value.session, path: value.path }),
81
+ request: (broker, connectionId, value) => broker.request({ connectionId, from: value.from, path: value.path }),
82
+ reply: (broker, connectionId, value) =>
83
+ broker.reply({ connectionId, owner: value.owner, requestId: value.requestId, decision: value.decision }),
84
+ }
85
+
86
+ /**
87
+ * Map a kernel verdict onto the wire: `{ok:true, ...fields}` or
88
+ * `{ok:false, error:{code, ...details}}`, plus the echoed `reqId`.
89
+ * @param {any} result
90
+ * @param {string | undefined} reqId
91
+ */
92
+ function toWire(result, reqId) {
93
+ /** @type {Record<string, any>} */
94
+ const out = {}
95
+ if (reqId) out.reqId = reqId
96
+ if (result.ok) {
97
+ out.ok = true
98
+ for (const [key, value] of Object.entries(result)) if (key !== 'ok') out[key] = value
99
+ return out
100
+ }
101
+ /** @type {Record<string, any>} */
102
+ const error = { code: result.code }
103
+ for (const [key, value] of Object.entries(result)) if (key !== 'ok' && key !== 'code') error[key] = value
104
+ out.ok = false
105
+ out.error = error
106
+ return out
107
+ }
108
+
109
+ /** @param {any} error */
110
+ function errorCode(error) {
111
+ return error && typeof error === 'object' && typeof error.code === 'string' ? error.code : undefined
112
+ }
113
+
114
+ /**
115
+ * Start listening.
116
+ *
117
+ * @param {object} options
118
+ * @param {string} options.socketPath endpoint address (`defaultSocketPath()` by default at the entry)
119
+ * @param {LeaseBroker} options.broker arbitration kernel from `createBroker()`
120
+ * @param {number} [options.protocol] this instance's protocol major
121
+ * @param {string} [options.daemonVersion] reported by the handshake
122
+ * @param {number} [options.idleMs] exit after this long with no connected client
123
+ * @param {number} [options.sweepMs] period of `broker.sweep()`
124
+ * @returns {Promise<{ address: string, close: () => Promise<void>, send: (connectionId: string, event: any) => boolean }>}
125
+ */
126
+ export async function startServer({
127
+ socketPath,
128
+ broker,
129
+ protocol = PROTOCOL,
130
+ daemonVersion = '0.0.0',
131
+ idleMs = DEFAULT_IDLE_MS,
132
+ sweepMs = DEFAULT_SWEEP_MS,
133
+ }) {
134
+ const startedAt = Date.now()
135
+ /** @type {Map<string, import('node:net').Socket>} */
136
+ const clients = new Map()
137
+ let nextConnectionId = 0
138
+ /** @type {ReturnType<typeof setTimeout> | null} */
139
+ let idleTimer = null
140
+ let closed = false
141
+ /** @type {((error: any) => void) | null} */
142
+ let bindFailure = null
143
+
144
+ /**
145
+ * @param {import('node:net').Socket} socket
146
+ * @param {any} payload
147
+ */
148
+ function writeFrame(socket, payload) {
149
+ if (!socket || socket.destroyed) return false
150
+ try {
151
+ socket.write(`${JSON.stringify(payload)}\n`)
152
+ return true
153
+ } catch {
154
+ return false
155
+ }
156
+ }
157
+
158
+ /** Push one event to one connection; unknown connections are a silent drop. */
159
+ function send(connectionId, event) {
160
+ const socket = clients.get(connectionId)
161
+ if (!socket) return false
162
+ return writeFrame(socket, event)
163
+ }
164
+
165
+ function cancelIdle() {
166
+ if (idleTimer === null) return
167
+ clearTimeout(idleTimer)
168
+ idleTimer = null
169
+ }
170
+
171
+ function scheduleIdle() {
172
+ if (closed || idleTimer !== null || clients.size > 0) return
173
+ idleTimer = setTimeout(() => {
174
+ idleTimer = null
175
+ void close()
176
+ }, idleMs > 0 ? idleMs : 0)
177
+ }
178
+
179
+ async function close() {
180
+ if (closed) return
181
+ closed = true
182
+ cancelIdle()
183
+ if (sweepTimer !== null) {
184
+ clearInterval(sweepTimer)
185
+ sweepTimer = null
186
+ }
187
+ for (const socket of clients.values()) {
188
+ try {
189
+ socket.destroy()
190
+ } catch {
191
+ // Already gone; nothing to release here — the kernel drop happens in
192
+ // the connection's own cleanup.
193
+ }
194
+ }
195
+ clients.clear()
196
+ await new Promise((resolve) => {
197
+ try {
198
+ server.close(() => resolve(undefined))
199
+ } catch {
200
+ resolve(undefined)
201
+ }
202
+ })
203
+ }
204
+
205
+ /**
206
+ * Handle one decoded line for one connection.
207
+ * @param {import('node:net').Socket} socket
208
+ * @param {any} state
209
+ * @param {string} line
210
+ */
211
+ function handleLine(socket, state, line) {
212
+ let frame
213
+ try {
214
+ frame = JSON.parse(line)
215
+ } catch {
216
+ writeFrame(socket, toWire({ ok: false, code: 'bad-request' }, undefined))
217
+ return
218
+ }
219
+ const rawReqId = frame && typeof frame.reqId === 'string' && frame.reqId.length > 0 ? frame.reqId : undefined
220
+ const parsed = validateFrame(frame)
221
+ if (!state.greeted) {
222
+ // The first frame must be a hello. Anything else — including a frame that
223
+ // would have failed validation for a different reason — is bad-request:
224
+ // no other op has a defined meaning before the handshake.
225
+ if (!parsed.ok || parsed.op !== 'hello') {
226
+ writeFrame(socket, toWire({ ok: false, code: 'bad-request' }, rawReqId))
227
+ return
228
+ }
229
+ const hello = /** @type {import('./protocol.js').HelloFrame} */ (parsed.value)
230
+ if (hello.protocol !== protocol) {
231
+ // Different major: answer, then drop the connection. No compatibility
232
+ // negotiation exists by design (D8) — the client starts/uses its own
233
+ // endpoint for its own major.
234
+ writeFrame(socket, toWire({ ok: false, code: 'protocol-mismatch', supported: [protocol] }, hello.reqId))
235
+ state.closing = true
236
+ socket.end()
237
+ return
238
+ }
239
+ state.greeted = true
240
+ // The handshake advertises the push events this incarnation supports,
241
+ // inside major 1: a client that needs one (a release subscription) can
242
+ // fail explicitly instead of waiting for an event this broker never sends.
243
+ writeFrame(socket, toWire({ ok: true, protocol, daemonVersion, startedAt, events: [...EVENTS] }, hello.reqId))
244
+ return
245
+ }
246
+ if (!parsed.ok) {
247
+ writeFrame(socket, toWire({ ok: false, code: parsed.code }, rawReqId))
248
+ return
249
+ }
250
+ if (parsed.op === 'hello') {
251
+ // Already greeted: a second handshake is a malformed frame.
252
+ writeFrame(socket, toWire({ ok: false, code: 'bad-request' }, rawReqId))
253
+ return
254
+ }
255
+ const handler = HANDLERS[/** @type {keyof typeof HANDLERS} */ (parsed.op)]
256
+ let result
257
+ try {
258
+ result = handler(broker, state.connectionId, parsed.value)
259
+ } catch {
260
+ // Unexpected kernel fault: report `internal` rather than killing the
261
+ // connection (the closed vocabulary exists for exactly this case).
262
+ result = { ok: false, code: 'internal' }
263
+ }
264
+ writeFrame(socket, toWire(result, /** @type {any} */ (parsed.value).reqId))
265
+ }
266
+
267
+ /**
268
+ * Wire one accepted socket: framing, handshake state, cleanup.
269
+ * @param {import('node:net').Socket} socket
270
+ */
271
+ function handleConnection(socket) {
272
+ const connectionId = `c${++nextConnectionId}`
273
+ clients.set(connectionId, socket)
274
+ cancelIdle()
275
+ broker.connect(connectionId)
276
+ socket.setEncoding('utf8')
277
+ const state = { connectionId, greeted: false, closing: false }
278
+ let buffered = ''
279
+ let cleaned = false
280
+
281
+ function cleanup() {
282
+ if (cleaned) return
283
+ cleaned = true
284
+ clients.delete(connectionId)
285
+ try {
286
+ broker.disconnect(connectionId)
287
+ } catch {
288
+ // The kernel is pure; a fault here must not keep the daemon alive.
289
+ }
290
+ scheduleIdle()
291
+ }
292
+
293
+ socket.on('data', (chunk) => {
294
+ if (state.closing) return
295
+ buffered += chunk
296
+ let index = buffered.indexOf('\n')
297
+ while (index >= 0) {
298
+ const line = buffered.slice(0, index)
299
+ buffered = buffered.slice(index + 1)
300
+ if (line.trim() !== '') handleLine(socket, state, line)
301
+ if (state.closing) {
302
+ buffered = ''
303
+ return
304
+ }
305
+ index = buffered.indexOf('\n')
306
+ }
307
+ })
308
+ socket.on('error', cleanup)
309
+ socket.on('close', cleanup)
310
+ }
311
+
312
+ const server = createServer({ allowHalfOpen: false }, (socket) => handleConnection(socket))
313
+ // One permanent error listener: while binding it rejects the listen promise;
314
+ // afterwards it keeps a per-connection fault from crashing the daemon.
315
+ server.on('error', (error) => {
316
+ if (bindFailure === null) return
317
+ const fail = bindFailure
318
+ bindFailure = null
319
+ fail(error)
320
+ })
321
+
322
+ /** @type {ReturnType<typeof setInterval> | null} */
323
+ let sweepTimer = sweepMs > 0
324
+ ? setInterval(() => {
325
+ try {
326
+ broker.sweep()
327
+ } catch {
328
+ // Memory reclamation is not a correctness source; never die for it.
329
+ }
330
+ }, sweepMs)
331
+ : null
332
+
333
+ /** @returns {Promise<void>} */
334
+ function bind() {
335
+ return new Promise((resolve, reject) => {
336
+ bindFailure = reject
337
+ server.listen({ path: socketPath }, () => {
338
+ bindFailure = null
339
+ resolve(undefined)
340
+ })
341
+ })
342
+ }
343
+
344
+ /**
345
+ * POSIX leaves the socket file behind when the daemon dies. `EADDRINUSE` on a
346
+ * path nobody listens on means exactly that, so probe it: a live peer is a
347
+ * hard error (never silently take over an arbiter), a refused connect means
348
+ * the file is stale and may be unlinked before a single retry.
349
+ * @returns {Promise<void>}
350
+ */
351
+ function recoverStaleSocket() {
352
+ return new Promise((resolve, reject) => {
353
+ const probe = createConnection({ path: socketPath })
354
+ let settled = false
355
+ /** @param {() => void} finish */
356
+ const once = (finish) => {
357
+ if (settled) return
358
+ settled = true
359
+ try {
360
+ probe.destroy()
361
+ } catch {
362
+ // Probe already gone.
363
+ }
364
+ finish()
365
+ }
366
+ probe.once('connect', () =>
367
+ once(() => reject(new Error(`another lease broker is already listening on ${socketPath}`))),
368
+ )
369
+ probe.once('error', (error) => {
370
+ const code = errorCode(error)
371
+ if (code !== 'ECONNREFUSED' && code !== 'ENOENT') {
372
+ once(() => reject(error))
373
+ return
374
+ }
375
+ once(() => {
376
+ try {
377
+ unlinkSync(socketPath)
378
+ resolve(undefined)
379
+ } catch (unlinkError) {
380
+ // `ENOENT` here means a concurrent instance cleaned up first.
381
+ if (errorCode(unlinkError) === 'ENOENT') resolve(undefined)
382
+ else reject(unlinkError)
383
+ }
384
+ })
385
+ })
386
+ })
387
+ }
388
+
389
+ try {
390
+ await bind()
391
+ } catch (error) {
392
+ if (errorCode(error) !== 'EADDRINUSE') throw error
393
+ await recoverStaleSocket()
394
+ await bind()
395
+ }
396
+
397
+ // A broker nobody ever connects to is just as useless as one that lost its
398
+ // last client: both idle out on the same clock.
399
+ scheduleIdle()
400
+
401
+ return { address: socketPath, close, send }
402
+ }