@playola-radio/slipstream 0.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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +132 -0
  3. package/SKILL.md +63 -0
  4. package/THIRD-PARTY-NOTICES.md +36 -0
  5. package/contracts/interface/v2/schema.json +480 -0
  6. package/dist/answer-forwarder.js +58 -0
  7. package/dist/attribution-engine.js +164 -0
  8. package/dist/attribution-producer.js +153 -0
  9. package/dist/attribution-scoring.js +86 -0
  10. package/dist/attribution.js +120 -0
  11. package/dist/blob-source.js +100 -0
  12. package/dist/boundary-registry.js +47 -0
  13. package/dist/cas.js +99 -0
  14. package/dist/change-view.js +201 -0
  15. package/dist/claude-root-transcript.js +110 -0
  16. package/dist/cli.js +629 -0
  17. package/dist/clip-blob-reader.js +104 -0
  18. package/dist/clip-function-parser.js +83 -0
  19. package/dist/clip-language.js +15 -0
  20. package/dist/clip-projection-service.js +240 -0
  21. package/dist/clip-projection-worker.js +18 -0
  22. package/dist/clip-projection.js +585 -0
  23. package/dist/clip-worker-pool.js +118 -0
  24. package/dist/config.js +154 -0
  25. package/dist/control-client.js +98 -0
  26. package/dist/control-protocol.js +72 -0
  27. package/dist/daemon-location.js +33 -0
  28. package/dist/daemon.js +951 -0
  29. package/dist/display-fold.js +332 -0
  30. package/dist/engine.js +112 -0
  31. package/dist/entrypoint.js +17 -0
  32. package/dist/event.js +79 -0
  33. package/dist/evidence-ingest.js +60 -0
  34. package/dist/harness-context/claude.js +42 -0
  35. package/dist/harness-context/codex.js +49 -0
  36. package/dist/harness-context.js +49 -0
  37. package/dist/health.js +65 -0
  38. package/dist/http-reader.js +655 -0
  39. package/dist/http-security.js +50 -0
  40. package/dist/interface-projection.js +243 -0
  41. package/dist/interface-range-resolver.js +230 -0
  42. package/dist/interface-service.js +509 -0
  43. package/dist/interface-ts-pool.js +111 -0
  44. package/dist/interface-ts-worker.js +35 -0
  45. package/dist/interface-v2-comparison.js +135 -0
  46. package/dist/interface-v2-core.js +123 -0
  47. package/dist/interface-v2-typescript.js +448 -0
  48. package/dist/lock.js +197 -0
  49. package/dist/log-reader.js +148 -0
  50. package/dist/log.js +59 -0
  51. package/dist/maintenance.js +255 -0
  52. package/dist/mcp-forwarder.js +161 -0
  53. package/dist/mcp-protocol.js +92 -0
  54. package/dist/platform.js +21 -0
  55. package/dist/projection-admission.js +296 -0
  56. package/dist/projection-trace.js +17 -0
  57. package/dist/public-events.js +27 -0
  58. package/dist/question-hook.js +110 -0
  59. package/dist/questions.js +167 -0
  60. package/dist/reader-runtime.js +38 -0
  61. package/dist/reader.js +93 -0
  62. package/dist/recovery.js +303 -0
  63. package/dist/schema.js +108 -0
  64. package/dist/session.js +866 -0
  65. package/dist/sibling-module.js +10 -0
  66. package/dist/snapshot.js +15 -0
  67. package/dist/storage.js +111 -0
  68. package/dist/store-reader.js +185 -0
  69. package/dist/swift-grammar.js +268 -0
  70. package/dist/swift-interface-extract.js +344 -0
  71. package/dist/swift-interface.js +62 -0
  72. package/dist/swift-parse-host.js +174 -0
  73. package/dist/swift-parse-worker.js +34 -0
  74. package/dist/swift-parse.js +134 -0
  75. package/dist/swift-spans.js +62 -0
  76. package/dist/task-forwarder.js +152 -0
  77. package/dist/transcript/claude.js +199 -0
  78. package/dist/transcript/codex.js +132 -0
  79. package/dist/transcript/discovery.js +365 -0
  80. package/dist/transcript/file-reader.js +128 -0
  81. package/dist/transcript/fs-io.js +287 -0
  82. package/dist/transcript/runner.js +62 -0
  83. package/dist/transcript/steppers.js +30 -0
  84. package/dist/transcript/types.js +30 -0
  85. package/dist/transcript/watcher.js +133 -0
  86. package/dist/tui.js +168 -0
  87. package/package.json +60 -0
  88. package/schemas/projections/clip.v1.json +195 -0
  89. package/schemas/projections/clip.v2.json +212 -0
  90. package/schemas/projections/clip.v3.json +212 -0
  91. package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
  92. package/schemas/slipstream.capture.gap.v1.json +112 -0
  93. package/schemas/slipstream.change.attribution.v1.json +115 -0
  94. package/schemas/slipstream.enrichment.configured.v1.json +77 -0
  95. package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
  96. package/schemas/slipstream.file.baselined.v1.json +122 -0
  97. package/schemas/slipstream.file.changed.v1.json +179 -0
  98. package/schemas/slipstream.harness.evidence.v1.json +145 -0
  99. package/schemas/slipstream.question.answered.v1.json +29 -0
  100. package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
  101. package/schemas/slipstream.question.queued.v1.json +55 -0
  102. package/schemas/slipstream.session.resumed.v1.json +70 -0
  103. package/schemas/slipstream.session.started.v1.json +69 -0
  104. package/schemas/slipstream.task.started.v1.json +72 -0
package/dist/daemon.js ADDED
@@ -0,0 +1,951 @@
1
+ import { createServer, connect } from 'node:net';
2
+ import { chmod, lstat, unlink, realpath, open } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { join } from 'node:path';
6
+ import { startCapture, InvalidTitleError } from "./session.js";
7
+ import { QuestionError } from "./questions.js";
8
+ import { verifyClaudeRootTranscript } from "./claude-root-transcript.js";
9
+ import { StorageError, mkdirpDurable, assertOwnerOnly } from "./storage.js";
10
+ import { acquireSessionLock, SessionOwnedError } from "./lock.js";
11
+ import { startReaderServer } from "./http-reader.js";
12
+ import { isValidSessionId, listSessions } from "./store-reader.js";
13
+ import { publishTombstone, removeSessionHistory, sessionExists, reclaimUnreferencedBlobs, } from "./maintenance.js";
14
+ import { createBoundaryRegistry } from "./boundary-registry.js";
15
+ import { liveBoundary } from "./reader-runtime.js";
16
+ import { createLineDecoder, encodeMessage, } from "./control-protocol.js";
17
+ const HARNESSES = ['claude-code', 'codex'];
18
+ const SUPPORTED_CODEX_VERSIONS = new Set(['0.154.0', '0.155.1']);
19
+ const MAX_CODEX_META_BYTES = 64 * 1024;
20
+ class RootIdentityError extends Error {
21
+ }
22
+ /** Verify the selected transcript's own first record once at attach. It binds
23
+ * the reported session and worktree without treating either as authorship. */
24
+ async function verifyCodexRootTranscript(path, sessionId, worktree) {
25
+ try {
26
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
27
+ let bytes;
28
+ try {
29
+ if (!(await handle.stat()).isFile())
30
+ throw new Error('transcript is not a file');
31
+ const buffer = Buffer.alloc(MAX_CODEX_META_BYTES + 1);
32
+ let total = 0;
33
+ let end = -1;
34
+ while (total < buffer.length && end < 0) {
35
+ const { bytesRead } = await handle.read(buffer, total, buffer.length - total, total);
36
+ if (bytesRead === 0)
37
+ break;
38
+ end = buffer.subarray(total, total + bytesRead).indexOf(0x0a);
39
+ if (end >= 0)
40
+ end += total;
41
+ total += bytesRead;
42
+ }
43
+ if (end < 0 || end > MAX_CODEX_META_BYTES)
44
+ throw new Error('transcript metadata exceeds limit');
45
+ bytes = buffer.subarray(0, end);
46
+ }
47
+ finally {
48
+ await handle.close();
49
+ }
50
+ const record = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
51
+ const payload = record.payload;
52
+ if (record.type !== 'session_meta' || !payload
53
+ || payload.originator !== 'codex_sdk_ts'
54
+ || (payload.source !== 'exec' && payload.source !== 'vscode')
55
+ || !SUPPORTED_CODEX_VERSIONS.has(String(payload.cli_version))
56
+ || payload.session_id !== sessionId || typeof payload.cwd !== 'string'
57
+ || await realpath(payload.cwd) !== worktree)
58
+ throw new Error('transcript metadata does not match selected root');
59
+ }
60
+ catch {
61
+ throw new RootIdentityError('root Codex transcript is missing, mismatched, or from an unsupported runtime');
62
+ }
63
+ }
64
+ /** Build the per-session transcript runtime from resolved config: only harnesses
65
+ * the operator declared `configured` are read. */
66
+ function transcriptRuntimeFrom(config) {
67
+ if (!config)
68
+ return undefined;
69
+ const harnesses = HARNESSES.filter((h) => config.policy.sources[h] === 'configured');
70
+ if (harnesses.length === 0)
71
+ return undefined;
72
+ return {
73
+ harnesses,
74
+ homes: config.transcript.homes,
75
+ codexScanLimit: config.transcript.codexScanLimit,
76
+ };
77
+ }
78
+ export class DaemonAlreadyRunningError extends Error {
79
+ constructor(socketPath) {
80
+ super(`a slipstream daemon is already running at ${socketPath}`);
81
+ this.name = 'DaemonAlreadyRunningError';
82
+ }
83
+ }
84
+ export const PROBE_TIMEOUT_MS = 1000;
85
+ /** A connection must deliver one complete control request within this window;
86
+ * otherwise it is dropped so it can never wedge shutdown. */
87
+ const REQUEST_TIMEOUT_MS = 10_000;
88
+ function nonEmptyString(v) {
89
+ return typeof v === 'string' && v.length > 0;
90
+ }
91
+ /** Classify who, if anyone, holds a control socket that failed to bind. Only a
92
+ * refused connection or a missing path proves the socket is stale and reclaimable;
93
+ * every other error (EACCES, EMFILE, ...) is ambiguous and must fail closed, since
94
+ * it does not prove the owner is dead (locked design, decision 4). */
95
+ export function probeSocket(socketPath, timeoutMs) {
96
+ return new Promise((resolve) => {
97
+ const sock = connect(socketPath);
98
+ const cleanup = () => { clearTimeout(timer); sock.removeAllListeners(); sock.destroy(); };
99
+ const timer = setTimeout(() => { cleanup(); resolve('ambiguous'); }, timeoutMs);
100
+ sock.on('connect', () => { cleanup(); resolve('live'); });
101
+ sock.on('error', (err) => {
102
+ cleanup();
103
+ const code = err.code;
104
+ resolve(code === 'ECONNREFUSED' || code === 'ENOENT' ? 'stale' : 'ambiguous');
105
+ });
106
+ });
107
+ }
108
+ async function bindControl(server, socketPath) {
109
+ const listenOnce = () => new Promise((resolve, reject) => {
110
+ const onError = (err) => { server.off('listening', onListening); reject(err); };
111
+ const onListening = () => { server.off('error', onError); resolve(); };
112
+ server.once('error', onError);
113
+ server.once('listening', onListening);
114
+ server.listen(socketPath);
115
+ });
116
+ const closeServer = () => new Promise((resolve) => server.close(() => resolve()));
117
+ for (let attempt = 0; attempt < 2; attempt++) {
118
+ try {
119
+ await listenOnce();
120
+ }
121
+ catch (err) {
122
+ if (err.code !== 'EADDRINUSE' || attempt === 1)
123
+ throw err;
124
+ const verdict = await probeSocket(socketPath, PROBE_TIMEOUT_MS);
125
+ if (verdict === 'live')
126
+ throw new DaemonAlreadyRunningError(socketPath);
127
+ if (verdict === 'ambiguous') {
128
+ throw new Error(`control socket ${socketPath} is unresponsive; refusing to start (a paused daemon may still own it). Remove it manually only if you are sure no daemon is running.`);
129
+ }
130
+ // Stale: reclaim ONLY an actual socket file, never a regular file or directory
131
+ // squatting the path — deleting an arbitrary object would be a data-loss bug.
132
+ const st = await lstat(socketPath);
133
+ if (!st.isSocket()) {
134
+ throw new Error(`refusing to remove non-socket object at control path ${socketPath}`);
135
+ }
136
+ await unlink(socketPath);
137
+ continue; // retry listen on the freed path
138
+ }
139
+ // Listening succeeded. Lock down permissions; if that fails, do not leak the
140
+ // listener — close it before surfacing the error.
141
+ try {
142
+ await chmod(socketPath, 0o600); // owner-only; the parent dir was already asserted 0700
143
+ }
144
+ catch (err) {
145
+ await closeServer();
146
+ throw err;
147
+ }
148
+ return;
149
+ }
150
+ }
151
+ export async function startDaemon(opts) {
152
+ const storeDir = opts.storeDir;
153
+ const identityRealpath = opts.identityRealpath ?? realpath;
154
+ const socketPath = join(storeDir, 'control.sock');
155
+ await mkdirpDurable(storeDir);
156
+ await assertOwnerOnly(storeDir, 'dir');
157
+ // Guard the store lock's stale-reclaim against a paused-but-live owner: a lock
158
+ // unrefreshed for STALE_MS is reclaimable even if its owner is only paused, and
159
+ // reclaiming it makes that owner shut down when it resumes. The control socket
160
+ // distinguishes a paused owner (still answers via the listen backlog) from a
161
+ // dead one (connection refused). Probe before we can reclaim; abort without
162
+ // touching the lock if a daemon still answers.
163
+ const startupVerdict = await probeSocket(socketPath, PROBE_TIMEOUT_MS);
164
+ if (startupVerdict === 'live')
165
+ throw new DaemonAlreadyRunningError(socketPath);
166
+ if (startupVerdict === 'ambiguous') {
167
+ throw new Error(`control socket ${socketPath} is unresponsive; refusing to start (a paused daemon may still own it). Remove it manually only if you are sure no daemon is running.`);
168
+ }
169
+ // 'stale' (connection refused / socket absent): no live daemon; the store lock's
170
+ // own stale logic and bindControl may proceed.
171
+ // Everything teardown touches is declared before the store lock is acquired, so
172
+ // an onCompromised callback that fires mid-startup never hits a temporal dead
173
+ // zone (it may run with `reader` still undefined — teardown null-checks it).
174
+ const registry = createBoundaryRegistry();
175
+ let state = 'detached';
176
+ let current;
177
+ const inflightTasks = new Set();
178
+ const connections = new Set();
179
+ let attachInFlight;
180
+ // At most one detached-only maintenance op (delete_session / gc) runs at a time.
181
+ // The slot is claimed synchronously in runMaintenance before its first await, and
182
+ // attach refuses while it is held — so a single-threaded turn makes maintenance
183
+ // and attach mutually exclusive without a lock.
184
+ let maintenanceInFlight;
185
+ let reader;
186
+ let torn = false;
187
+ let compromised = false; // store lock lost
188
+ let sessionCompromised = false; // current session lock lost mid-attach
189
+ let abortStopError; // a failed stop of a session aborted mid-attach
190
+ // Gates DISPATCH, not acceptance: connections are tracked and given a deadline
191
+ // from the moment they arrive (so bind/chmod-window sockets can never orphan or
192
+ // hang shutdown), but no verb runs until startup is fully ready — which is also
193
+ // when `readerRef` is initialized, so an early request can never hit its TDZ.
194
+ let ready = false;
195
+ const server = createServer((sock) => handleConnection(sock));
196
+ const closeServer = () => new Promise((resolve) => server.close(() => resolve()));
197
+ /** Remove OUR control socket. Only ever called on the normal teardown path, while
198
+ * the store lock is still held, so no successor can have bound the path; the
199
+ * isSocket() guard is a belt-and-braces check against an unrelated file. On the
200
+ * compromise path the caller skips this entirely (ownership is already lost). */
201
+ async function unlinkOwnSocket() {
202
+ try {
203
+ const st = await lstat(socketPath);
204
+ if (st.isSocket())
205
+ await unlink(socketPath);
206
+ }
207
+ catch {
208
+ // best-effort cleanup; the path is gone or unreadable, nothing more to do
209
+ }
210
+ }
211
+ // Memoized so repeated stop() calls (and a compromise firing mid-shutdown) await
212
+ // the one in-flight teardown instead of returning early while it is still running.
213
+ let teardownPromise;
214
+ function teardown() {
215
+ return (teardownPromise ??= doTeardown());
216
+ }
217
+ async function doTeardown() {
218
+ torn = true;
219
+ // Drop live control connections so server.close() cannot block on an idle or
220
+ // half-sent request.
221
+ for (const sock of connections)
222
+ sock.destroy();
223
+ await closeServer();
224
+ // An attach whose capture is still starting must not outlive shutdown: wait for
225
+ // it to settle (it self-stops once it observes `torn`), then stop whatever it or
226
+ // an active session left installed.
227
+ if (attachInFlight)
228
+ await attachInFlight.catch(() => { });
229
+ // A maintenance op in flight has already seen `torn` (gc aborts its sweep); wait
230
+ // for it to settle so a durable tombstone or blob unlink is not racing shutdown.
231
+ if (maintenanceInFlight)
232
+ await maintenanceInFlight.catch(() => { });
233
+ // An attach aborted mid-startup may have failed to stop its half-built session
234
+ // (it runs in startAndActivate, which sets abortStopError). Surface that too so
235
+ // shutdown never reports success over a capture that could not be confirmed
236
+ // stopped.
237
+ let stopErr = abortStopError;
238
+ if (current) {
239
+ // Drain in-flight task declarations before stopping, exactly like detach:
240
+ // `torn` is already set, so beginTask admits no new work, and the append of
241
+ // any task that slipped in before shutdown must not race the session log
242
+ // closing under session.stop().
243
+ await Promise.allSettled([...inflightTasks]);
244
+ // Do NOT swallow a failed stop: if capture could not be confirmed stopped,
245
+ // shutdown must not report success. session.stop() still releases the session
246
+ // lock and halts the engine even when it rethrows, so releasing the store
247
+ // lock below is safe; we surface the failure once cleanup is complete.
248
+ try {
249
+ await current.session.stop();
250
+ }
251
+ catch (err) {
252
+ stopErr = err;
253
+ }
254
+ current = undefined;
255
+ }
256
+ await reader?.close().catch(() => { });
257
+ // Unlink the socket only while we still hold the store lock. On the compromise
258
+ // path the lock is already lost, so a successor may have bound the path — an
259
+ // isSocket() check proves type, not ownership, so deleting it could remove the
260
+ // successor's socket. Leave it for the new owner instead.
261
+ if (!compromised)
262
+ await unlinkOwnSocket();
263
+ await lock.release();
264
+ if (stopErr)
265
+ throw stopErr;
266
+ }
267
+ let lock;
268
+ try {
269
+ lock = await acquireSessionLock(storeDir, {
270
+ onCompromised: (reason) => {
271
+ compromised = true;
272
+ console.error(`slipstream daemon: store lock lost: ${reason}`);
273
+ void teardown().catch((teardownErr) => {
274
+ console.error('slipstream daemon: teardown after store-lock loss failed:', teardownErr);
275
+ });
276
+ },
277
+ });
278
+ }
279
+ catch (err) {
280
+ if (err instanceof SessionOwnedError)
281
+ throw new DaemonAlreadyRunningError(socketPath);
282
+ throw err;
283
+ }
284
+ // Bring the reader up before accepting control traffic, so a verb can never be
285
+ // dispatched against a reader that is not yet serving the public view.
286
+ try {
287
+ reader = await startReaderServer({ storeDir, registry });
288
+ }
289
+ catch (err) {
290
+ await lock.release();
291
+ throw err;
292
+ }
293
+ // A store-lock compromise during startReaderServer() already ran teardown while
294
+ // `reader` was still undefined, so teardown could not close this server. Close it
295
+ // here and abort before binding the control listener, so no listener leaks.
296
+ if (torn || compromised) {
297
+ await reader.close().catch(() => { });
298
+ await lock.release().catch(() => { });
299
+ throw new Error('slipstream daemon lost its store lock during startup');
300
+ }
301
+ try {
302
+ await bindControl(server, socketPath);
303
+ }
304
+ catch (err) {
305
+ await reader.close().catch(() => { });
306
+ await lock.release();
307
+ throw err;
308
+ }
309
+ // A compromise that landed during startup already triggered teardown; honor it
310
+ // rather than handing back a daemon that is shutting down. bindControl may have
311
+ // (re)bound the listener AFTER teardown ran — e.g. a compromise during its
312
+ // stale-socket probe, when the server was not yet listening for teardown to
313
+ // close — so close the listener explicitly here before awaiting the memoized
314
+ // teardown, which would otherwise no-op and leak it.
315
+ if (torn || compromised) {
316
+ // Destroy tracked connections before the explicit close: one accepted after
317
+ // teardown's own destruction sweep (e.g. during this retry's chmod window)
318
+ // could otherwise keep closeServer()'s promise from resolving if its peer
319
+ // holds the send side open, hanging startup rejection indefinitely.
320
+ for (const sock of connections)
321
+ sock.destroy();
322
+ await closeServer();
323
+ await teardown();
324
+ throw new Error('slipstream daemon lost its store lock during startup');
325
+ }
326
+ const readerRef = reader;
327
+ function statusFields() {
328
+ const fields = { state, reader_url: readerRef.url };
329
+ if (current) {
330
+ // session_id is capture SCOPE, never authorship. The declared identity is the
331
+ // exact context the caller bound; semantic verification is the P4 forwarder's.
332
+ fields.session_id = current.id;
333
+ fields.worktree = current.worktree;
334
+ fields.harness = current.harness;
335
+ fields.harness_session_id = current.harnessSessionId;
336
+ if (current.rootTranscript)
337
+ fields.root_transcript = current.rootTranscript;
338
+ fields.durable_seq = current.session.health.snapshot().durable_seq;
339
+ }
340
+ return fields;
341
+ }
342
+ /** The active session lost its own lock. Stop presenting healthy, preserve the
343
+ * boundary at the last durable seq, and refuse further mutations (decision 9).
344
+ * `current` is retained so teardown can still stop the wedged session. */
345
+ function handleSessionCompromise(id, reason) {
346
+ console.error(`slipstream daemon: session ${id} lost its lock: ${reason}`);
347
+ if (current?.id === id && state === 'active') {
348
+ registry.freeze(id, BigInt(current.session.health.snapshot().durable_seq));
349
+ state = 'wedged';
350
+ return;
351
+ }
352
+ if (state === 'attaching')
353
+ sessionCompromised = true; // startup aborts below
354
+ }
355
+ async function startAndActivate(id, worktree, harness, harnessSessionId, rootTranscript) {
356
+ let session;
357
+ let resolvedWorktree;
358
+ let resolvedTranscript;
359
+ try {
360
+ // Canonicalize before binding: capture resolves the root with realpath, so a
361
+ // symlinked or relative declared path must report the same durable root in
362
+ // status rather than the caller's raw string (locked design, decision 5).
363
+ resolvedWorktree = await realpath(worktree);
364
+ if (rootTranscript !== undefined) {
365
+ if (harness !== 'codex' && harness !== 'claude-code') {
366
+ throw new RootIdentityError('root_transcript requires a supported harness');
367
+ }
368
+ try {
369
+ resolvedTranscript = await realpath(rootTranscript);
370
+ }
371
+ catch {
372
+ throw new RootIdentityError(harness === 'claude-code'
373
+ ? 'root Claude transcript is unavailable; check its path or retry attach after the first root tool call'
374
+ : 'root Codex transcript cannot be resolved');
375
+ }
376
+ if (harness === 'codex') {
377
+ await verifyCodexRootTranscript(resolvedTranscript, harnessSessionId, resolvedWorktree);
378
+ }
379
+ else {
380
+ const verified = await verifyClaudeRootTranscript(resolvedTranscript, harnessSessionId, resolvedWorktree);
381
+ if (!verified.ok) {
382
+ throw new RootIdentityError(verified.reason === 'not-yet'
383
+ ? 'root Claude transcript head is not complete yet; retry attach after the first root tool call'
384
+ : verified.reason === 'gap'
385
+ ? 'root Claude transcript head exceeds the verification bounds; select a new root early in its session'
386
+ : `root Claude transcript identity is ${verified.reason}`);
387
+ }
388
+ }
389
+ }
390
+ const transcript = transcriptRuntimeFrom(opts.config);
391
+ session = await startCapture({
392
+ root: resolvedWorktree,
393
+ storeDir,
394
+ sessionId: id,
395
+ onCompromised: (reason) => handleSessionCompromise(id, reason),
396
+ ...(opts.config ? { enrichmentPolicy: opts.config.policy } : {}),
397
+ ...(transcript ? { transcript } : {}),
398
+ }, opts.captureDependencies);
399
+ }
400
+ catch (err) {
401
+ // Freeze at boundary 0 (capture never activated); never delete the entry,
402
+ // which would let the reader fall back to disk for a half-written session.
403
+ registry.freeze(id, 0n);
404
+ if (state === 'attaching')
405
+ state = 'detached';
406
+ if (err instanceof StorageError)
407
+ return errFields('STORAGE_UNAVAILABLE', err.message);
408
+ if (err instanceof RootIdentityError)
409
+ return errFields('IDENTITY_UNRESOLVED', err.message);
410
+ return errFields('CAPTURE_NOT_READY', err.message);
411
+ }
412
+ if (torn || compromised || sessionCompromised || state !== 'attaching') {
413
+ // Shutdown, store-lock loss, or a session compromise arrived while capture was
414
+ // starting: do not publish this session. Stop it and hold the boundary. A
415
+ // failed stop here is recorded (not swallowed) so teardown can surface it.
416
+ try {
417
+ await session.stop();
418
+ }
419
+ catch (err) {
420
+ abortStopError ??= err;
421
+ }
422
+ registry.freeze(id, BigInt(session.health.snapshot().durable_seq));
423
+ if (state === 'attaching')
424
+ state = 'detached';
425
+ return errFields('STORAGE_UNAVAILABLE', 'daemon could not complete the attach');
426
+ }
427
+ registry.activate(id, liveBoundary(session.health));
428
+ current = { id, session, worktree: resolvedWorktree, harness, harnessSessionId, rootTranscript: resolvedTranscript };
429
+ state = 'active';
430
+ return { session_id: id };
431
+ }
432
+ async function attach(req) {
433
+ if (!nonEmptyString(req.worktree) || !nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id)) {
434
+ // Structural fail-closed identity gate (honesty): an ambiguous or partial
435
+ // identity refuses attachment rather than guessing. Semantic verification
436
+ // that the declared harness is a live real process is the P4 forwarder's job.
437
+ return errFields('IDENTITY_UNRESOLVED', 'attach requires non-empty worktree, harness, and harness_session_id');
438
+ }
439
+ if (req.root_transcript !== undefined && !nonEmptyString(req.root_transcript)) {
440
+ return errFields('IDENTITY_UNRESOLVED', 'root_transcript must be a non-empty existing path');
441
+ }
442
+ if (state !== 'detached') {
443
+ if (state === 'active')
444
+ return errFields('SESSION_ACTIVE', 'a session is already attached; detach it first');
445
+ if (state === 'wedged')
446
+ return errFields('STORAGE_UNAVAILABLE', 'daemon is wedged; restart it');
447
+ return errFields('CAPTURE_NOT_READY', `cannot attach while ${state}`);
448
+ }
449
+ // Refuse to attach over an in-flight maintenance op. This check and the
450
+ // `state === 'detached'` guard in runMaintenance are the two halves of the same
451
+ // mutual exclusion: both are synchronous, so whichever claims its slot first in
452
+ // this turn locks the other out. (Kept before `state = 'attaching'` so a refused
453
+ // attach leaves the state machine untouched.)
454
+ if (maintenanceInFlight)
455
+ return errFields('CAPTURE_NOT_READY', 'a maintenance operation is running; retry shortly');
456
+ // Set attaching BEFORE the first await so a concurrent attach is refused.
457
+ state = 'attaching';
458
+ sessionCompromised = false;
459
+ const id = randomUUID();
460
+ // Reserve the registry entry (boundary 0) before startCapture creates the log
461
+ // dir on disk, so the reader never over-publishes an uncommitted record from a
462
+ // session that momentarily appears on disk mid-startup.
463
+ registry.reserve(id);
464
+ const p = startAndActivate(id, req.worktree, req.harness, req.harness_session_id, req.root_transcript);
465
+ attachInFlight = p;
466
+ try {
467
+ return await p;
468
+ }
469
+ finally {
470
+ if (attachInFlight === p)
471
+ attachInFlight = undefined;
472
+ }
473
+ }
474
+ async function detach() {
475
+ if (state !== 'active' || !current) {
476
+ return errFields('SESSION_NOT_SELECTED', 'no active session to detach');
477
+ }
478
+ const { id, session } = current;
479
+ state = 'detaching'; // close admission synchronously; in-flight tasks still settle
480
+ await Promise.allSettled([...inflightTasks]);
481
+ let stopErr;
482
+ try {
483
+ await session.stop();
484
+ }
485
+ catch (err) {
486
+ stopErr = err;
487
+ }
488
+ // Pin the reader at the final durable seq the session reached (honest even if
489
+ // stop() failed — health still holds the last durable value).
490
+ registry.freeze(id, BigInt(session.health.snapshot().durable_seq));
491
+ if (stopErr) {
492
+ // Fail closed: never claim a clean detach or admit new capture. Retain
493
+ // `current` so teardown can still attempt to release the session's resources.
494
+ state = 'wedged';
495
+ return errFields('STORAGE_UNAVAILABLE', `detach did not stop cleanly: ${stopErr.message}`);
496
+ }
497
+ current = undefined;
498
+ state = 'detached';
499
+ return { session_id: id };
500
+ }
501
+ async function beginTask(req) {
502
+ if (state === 'wedged')
503
+ return errFields('STORAGE_UNAVAILABLE', 'capture is wedged; its storage is no longer trustworthy');
504
+ if (state !== 'active' || !current) {
505
+ if (state === 'attaching')
506
+ return errFields('CAPTURE_NOT_READY', 'capture is still starting');
507
+ if (state === 'detaching')
508
+ return errFields('CAPTURE_NOT_READY', 'capture is detaching');
509
+ return errFields('SESSION_NOT_SELECTED', 'no active session');
510
+ }
511
+ if (req.session_id !== undefined && req.session_id !== current.id) {
512
+ // The optional target guards a retry after detach+attach-B from misrouting to
513
+ // a different session than the caller declared.
514
+ return errFields('SESSION_NOT_SELECTED', `session_id ${String(req.session_id)} is not the selected session`);
515
+ }
516
+ // Selection guard (locked design, decision X): the caller ships its verified
517
+ // identity triple and only the selected session's exact triple may declare.
518
+ // The P4 forwarder always sends the triple; a missing one is a contract
519
+ // violation, so fail closed rather than declare into whatever is selected.
520
+ if (!nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)) {
521
+ return errFields('IDENTITY_UNRESOLVED', 'begin_task requires harness, harness_session_id, and worktree');
522
+ }
523
+ if (!nonEmptyString(req.title))
524
+ return errFields('INVALID_TITLE', 'begin_task requires a non-empty title');
525
+ if (!nonEmptyString(req.request_id))
526
+ return errFields('CAPTURE_NOT_READY', 'begin_task requires a non-empty request_id');
527
+ // Canonicalize the declared worktree with the SAME realpath policy as attach
528
+ // (startAndActivate), so a symlinked or relative declared path matches the
529
+ // durable root capture actually watches.
530
+ let declaredWorktree;
531
+ try {
532
+ declaredWorktree = await realpath(req.worktree);
533
+ }
534
+ catch {
535
+ declaredWorktree = req.worktree; // an unresolvable path cannot match the canonical root
536
+ }
537
+ // Re-verify selection AFTER the realpath await: a concurrent detach, teardown,
538
+ // or store-lock loss may have changed state or swapped the session while we
539
+ // canonicalized. From here through inflightTasks.add there is no await, so
540
+ // neither a detach nor a teardown can slip between these checks and the append
541
+ // (they either observe the task in flight and drain it, or are observed here).
542
+ // Teardown fails closed without touching `state`, so check `torn`/`compromised`
543
+ // explicitly — otherwise a request could append after the daemon has already
544
+ // begun shutting down or lost its store lock.
545
+ if (compromised || torn) {
546
+ return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
547
+ }
548
+ if (state !== 'active' || !current) {
549
+ return errFields('SESSION_NOT_SELECTED', 'the selected session changed before the declaration committed');
550
+ }
551
+ // Re-apply the optional session_id target guard after the await, exactly like
552
+ // the pre-await check: a concurrent detach+attach may have swapped in a session
553
+ // sharing the triple, and a request explicitly addressed to the prior session
554
+ // must not commit into its replacement.
555
+ if (req.session_id !== undefined && req.session_id !== current.id) {
556
+ return errFields('SESSION_NOT_SELECTED', `session_id ${String(req.session_id)} is not the selected session`);
557
+ }
558
+ if (req.harness !== current.harness ||
559
+ req.harness_session_id !== current.harnessSessionId ||
560
+ declaredWorktree !== current.worktree) {
561
+ return errFields('SESSION_NOT_SELECTED', 'the declared identity is not the selected session');
562
+ }
563
+ const { session } = current;
564
+ const promise = session.beginTask({ title: req.title, requestId: req.request_id });
565
+ inflightTasks.add(promise);
566
+ try {
567
+ const result = await promise;
568
+ return {
569
+ session_id: result.session_id,
570
+ task_id: result.task_id,
571
+ event_id: result.event_id,
572
+ seq: result.seq,
573
+ };
574
+ }
575
+ catch (err) {
576
+ if (err instanceof InvalidTitleError)
577
+ return errFields('INVALID_TITLE', err.message);
578
+ if (err instanceof StorageError)
579
+ return errFields('STORAGE_UNAVAILABLE', err.message);
580
+ return errFields('CAPTURE_NOT_READY', err.message);
581
+ }
582
+ finally {
583
+ inflightTasks.delete(promise);
584
+ }
585
+ }
586
+ /** Queue a question on the selected capture. `askQuestion` installs its own
587
+ * request reservation synchronously; install the daemon-level lifetime promise
588
+ * in the same turn so detach and shutdown drain source reads and appends. */
589
+ async function ask(req) {
590
+ if (compromised || torn)
591
+ return errFields('STORAGE_UNAVAILABLE', 'daemon storage ownership is unavailable');
592
+ if (state === 'wedged')
593
+ return errFields('STORAGE_UNAVAILABLE', 'capture is wedged; its storage is no longer trustworthy');
594
+ if (state !== 'active' || !current) {
595
+ if (state === 'attaching' || state === 'detaching')
596
+ return errFields('CAPTURE_NOT_READY', 'capture is not ready to accept questions');
597
+ return errFields('SESSION_NOT_SELECTED', 'no active session');
598
+ }
599
+ if (current.harness !== 'claude-code' && current.harness !== 'codex') {
600
+ return errFields('SESSION_NOT_SELECTED', 'the selected capture has no supported question target');
601
+ }
602
+ // No await may appear before this call and registration: detach/shutdown must
603
+ // see any source verification admitted before they change state.
604
+ const binding = current;
605
+ const { session, harness, harnessSessionId, worktree } = binding;
606
+ const promise = session.askQuestion(req, {
607
+ harness: harness,
608
+ harness_session_id: harnessSessionId,
609
+ worktree,
610
+ }, () => {
611
+ if (compromised || sessionCompromised || current !== binding) {
612
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
613
+ }
614
+ });
615
+ inflightTasks.add(promise);
616
+ try {
617
+ const result = await promise;
618
+ return { ...result };
619
+ }
620
+ catch (err) {
621
+ if (err instanceof QuestionError)
622
+ return errFields(err.code, err.message);
623
+ if (err instanceof StorageError)
624
+ return errFields('STORAGE_UNAVAILABLE', err.message);
625
+ return errFields('CAPTURE_NOT_READY', err.message);
626
+ }
627
+ finally {
628
+ inflightTasks.delete(promise);
629
+ }
630
+ }
631
+ async function claimQuestion(req) {
632
+ if (compromised || torn || state === 'wedged')
633
+ return errFields('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
634
+ if (state !== 'active' || !current)
635
+ return errFields('SESSION_NOT_SELECTED', 'no active capture');
636
+ if ((current.harness !== 'codex' && current.harness !== 'claude-code')
637
+ || !current.rootTranscript || req.harness !== current.harness
638
+ || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)
639
+ || !nonEmptyString(req.transcript_path)
640
+ || Object.hasOwn(req, 'agent_id') || Object.hasOwn(req, 'agent_type')) {
641
+ return errFields('IDENTITY_UNRESOLVED', 'unsupported or incomplete root identity');
642
+ }
643
+ const binding = current;
644
+ let worktree;
645
+ let transcript;
646
+ try {
647
+ [worktree, transcript] = await Promise.all([identityRealpath(req.worktree), identityRealpath(req.transcript_path)]);
648
+ }
649
+ catch {
650
+ return errFields('IDENTITY_UNRESOLVED', 'identity path cannot be resolved');
651
+ }
652
+ if (compromised || torn || sessionCompromised || state !== 'active' || current !== binding
653
+ || req.harness_session_id !== binding.harnessSessionId
654
+ || worktree !== binding.worktree || transcript !== binding.rootTranscript) {
655
+ return errFields('SESSION_NOT_SELECTED', 'the callback is not the selected root session');
656
+ }
657
+ const promise = binding.session.claimQuestion({ harness: binding.harness, harness_session_id: binding.harnessSessionId,
658
+ worktree: binding.worktree }, () => {
659
+ if (compromised || sessionCompromised || current !== binding) {
660
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
661
+ }
662
+ });
663
+ inflightTasks.add(promise);
664
+ try {
665
+ const result = await promise;
666
+ return result === null ? { question: null } : { question: result };
667
+ }
668
+ catch (err) {
669
+ if (err instanceof QuestionError)
670
+ return errFields(err.code, err.message);
671
+ if (err instanceof StorageError)
672
+ return errFields('STORAGE_UNAVAILABLE', err.message);
673
+ return errFields('CAPTURE_NOT_READY', err.message);
674
+ }
675
+ finally {
676
+ inflightTasks.delete(promise);
677
+ }
678
+ }
679
+ /** Record an answer returned through the selected harness session's MCP tool.
680
+ * Like begin_task, the caller's triple must equal the binding after the realpath
681
+ * await; with no transcript or agent id this proves the call came through the
682
+ * selected harness session, not which agent in it wrote the answer. */
683
+ async function answerQuestion(req) {
684
+ if (!nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)) {
685
+ return errFields('IDENTITY_UNRESOLVED', 'answer_question requires harness, harness_session_id, and worktree');
686
+ }
687
+ if (compromised || torn || state === 'wedged')
688
+ return errFields('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
689
+ if (state !== 'active' || !current)
690
+ return errFields('SESSION_NOT_SELECTED', 'no active capture');
691
+ const binding = current;
692
+ let worktree;
693
+ try {
694
+ worktree = await identityRealpath(req.worktree);
695
+ }
696
+ catch {
697
+ worktree = req.worktree;
698
+ }
699
+ if (compromised || torn || sessionCompromised || state !== 'active' || current !== binding
700
+ || req.harness !== binding.harness || req.harness_session_id !== binding.harnessSessionId || worktree !== binding.worktree) {
701
+ return errFields('SESSION_NOT_SELECTED', 'the caller is not the selected harness session');
702
+ }
703
+ const promise = binding.session.answerQuestion({ question_id: req.question_id, text: req.text }, { harness: binding.harness, harness_session_id: binding.harnessSessionId, worktree: binding.worktree }, () => {
704
+ if (compromised || sessionCompromised || current !== binding) {
705
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
706
+ }
707
+ });
708
+ inflightTasks.add(promise);
709
+ try {
710
+ return { ...await promise };
711
+ }
712
+ catch (err) {
713
+ if (err instanceof QuestionError)
714
+ return errFields(err.code, err.message);
715
+ if (err instanceof StorageError)
716
+ return errFields('STORAGE_UNAVAILABLE', err.message);
717
+ return errFields('CAPTURE_NOT_READY', err.message);
718
+ }
719
+ finally {
720
+ inflightTasks.delete(promise);
721
+ }
722
+ }
723
+ /** Admit and run a detached-only maintenance op. Admission is synchronous through
724
+ * claiming `maintenanceInFlight`, so it cannot interleave with attach (which sets
725
+ * `state` synchronously and refuses while the slot is held). The op runs only in
726
+ * `detached`; anything else — an active/attaching/detaching session, a wedged or
727
+ * compromised store, an op already running — is refused without touching disk. */
728
+ async function runMaintenance(op) {
729
+ if (compromised || torn)
730
+ return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
731
+ if (state !== 'detached') {
732
+ // A capture in any stage — attaching, active, or detaching — is SESSION_ACTIVE;
733
+ // CAPTURE_NOT_READY is reserved for the maintenance-slot contention below.
734
+ if (state === 'wedged')
735
+ return errFields('STORAGE_UNAVAILABLE', 'daemon is wedged; restart it');
736
+ return errFields('SESSION_ACTIVE', `a capture session is ${state}; detach it before deleting or collecting`);
737
+ }
738
+ if (maintenanceInFlight)
739
+ return errFields('CAPTURE_NOT_READY', 'another maintenance operation is running; retry shortly');
740
+ let release;
741
+ const slot = new Promise((resolve) => { release = resolve; });
742
+ maintenanceInFlight = slot;
743
+ try {
744
+ return await op();
745
+ }
746
+ finally {
747
+ release();
748
+ if (maintenanceInFlight === slot)
749
+ maintenanceInFlight = undefined;
750
+ }
751
+ }
752
+ async function deleteSession(req) {
753
+ // Validate the id shape before admission: it names an on-disk path, so a
754
+ // malformed or traversing id is a protocol fault, never a lookup miss.
755
+ if (!nonEmptyString(req.session_id) || !isValidSessionId(req.session_id)) {
756
+ return errFields('PROTOCOL', 'delete_session requires a valid session_id');
757
+ }
758
+ const id = req.session_id;
759
+ return runMaintenance(async () => {
760
+ let present;
761
+ try {
762
+ present = await sessionExists(storeDir, id);
763
+ }
764
+ catch (err) {
765
+ return errFields('STORAGE_UNAVAILABLE', err.message);
766
+ }
767
+ if (!present)
768
+ return errFields('SESSION_NOT_FOUND', `no session ${id} to delete`);
769
+ // Durable-tombstone-first: publish the marker (fsynced) BEFORE removing any
770
+ // history, so a crash in between leaves a session the reader already serves as
771
+ // gone and a later gc finishes the cleanup. Nothing is committed yet, so a
772
+ // failure here is a plain STORAGE_UNAVAILABLE — no rollback needed.
773
+ try {
774
+ await publishTombstone(storeDir, id);
775
+ }
776
+ catch (err) {
777
+ return errFields('STORAGE_UNAVAILABLE', `could not durably record deletion of ${id}: ${err.message}`);
778
+ }
779
+ // Now that the tombstone is durable, abort any in-flight follower of this
780
+ // session so it re-resolves, re-reads the tombstone, and gets 410 rather than
781
+ // streaming a log we are about to delete. The reader's installIfAbsent gives a
782
+ // followed retained session a registry entry this freeze can reach.
783
+ registry.freeze(id, 0n);
784
+ // The tombstone is durable → the session is logically removed and the marker is
785
+ // never rolled back. If we have since lost the store lock, or history cleanup
786
+ // fails, report that removal committed and cleanup is retryable via gc.
787
+ const committed = `session ${id} is logically removed (tombstone durable); history cleanup is retryable via gc`;
788
+ if (compromised || torn)
789
+ return errFields('STORAGE_UNAVAILABLE', committed);
790
+ try {
791
+ await removeSessionHistory(storeDir, id);
792
+ }
793
+ catch (err) {
794
+ return errFields('STORAGE_UNAVAILABLE', `${committed}: ${err.message}`);
795
+ }
796
+ return { session_id: id };
797
+ });
798
+ }
799
+ async function gc() {
800
+ return runMaintenance(async () => {
801
+ try {
802
+ // Finish any interrupted deletion first: a session tombstoned by a
803
+ // delete_session that crashed before cleanup still carries residual history.
804
+ // Re-establish the tombstone's durability, invalidate any follower a partial
805
+ // delete left pinned, then complete the removal — the same publish -> freeze
806
+ // -> remove sequence delete_session runs. Stop before any mutation if we lose
807
+ // the store lock: a successor now owns this store's files.
808
+ for (const s of await listSessions(storeDir)) {
809
+ if (!s.removed)
810
+ continue;
811
+ if (compromised || torn) {
812
+ throw new StorageError('gc-aborted', new Error('store ownership lost mid-cleanup'));
813
+ }
814
+ await publishTombstone(storeDir, s.id);
815
+ registry.freeze(s.id, 0n);
816
+ await removeSessionHistory(storeDir, s.id);
817
+ }
818
+ // Abort mid-sweep if we lose the store lock: a successor may then own the
819
+ // shared blobs, and deleting one it still references would be data loss.
820
+ const removed = await reclaimUnreferencedBlobs(storeDir, () => torn || compromised);
821
+ return { removed };
822
+ }
823
+ catch (err) {
824
+ // A corrupt/missing retained log or an aborted sweep deletes nothing; report
825
+ // the store as unavailable rather than a partial success.
826
+ return errFields('STORAGE_UNAVAILABLE', err.message);
827
+ }
828
+ });
829
+ }
830
+ async function dispatch(req) {
831
+ if (compromised)
832
+ return { v: 1, ok: false, code: 'STORAGE_UNAVAILABLE', message: 'daemon lost its store lock' };
833
+ let outcome;
834
+ switch (req.verb) {
835
+ case 'status':
836
+ outcome = statusFields();
837
+ break;
838
+ case 'attach':
839
+ outcome = await attach(req);
840
+ break;
841
+ case 'detach':
842
+ outcome = await detach();
843
+ break;
844
+ case 'begin_task':
845
+ outcome = await beginTask(req);
846
+ break;
847
+ case 'ask':
848
+ outcome = await ask(req);
849
+ break;
850
+ case 'claim_question':
851
+ outcome = await claimQuestion(req);
852
+ break;
853
+ case 'answer_question':
854
+ outcome = await answerQuestion(req);
855
+ break;
856
+ case 'delete_session':
857
+ outcome = await deleteSession(req);
858
+ break;
859
+ case 'gc':
860
+ outcome = await gc();
861
+ break;
862
+ default: return { v: 1, ok: false, code: 'PROTOCOL', message: `unknown verb: ${String(req.verb)}` };
863
+ }
864
+ if (isErrorFields(outcome))
865
+ return { v: 1, ok: false, code: outcome.code, message: outcome.message };
866
+ return { v: 1, ok: true, ...outcome };
867
+ }
868
+ function handleConnection(sock) {
869
+ if (torn) {
870
+ // Shutting down: never take on a new connection that could linger and block
871
+ // server.close(); drop it so the client sees a closed connection and retries.
872
+ sock.destroy();
873
+ return;
874
+ }
875
+ connections.add(sock);
876
+ const decoder = createLineDecoder();
877
+ let handled = false;
878
+ const answer = (res) => {
879
+ handled = true;
880
+ if (!sock.destroyed)
881
+ sock.end(encodeMessage(res));
882
+ };
883
+ const deadline = setTimeout(() => { if (!handled)
884
+ sock.destroy(); }, REQUEST_TIMEOUT_MS);
885
+ sock.on('data', (chunk) => {
886
+ if (handled)
887
+ return; // one request per connection: stop feeding the decoder
888
+ let messages;
889
+ try {
890
+ messages = decoder.push(chunk);
891
+ }
892
+ catch (err) {
893
+ answer({ v: 1, ok: false, code: 'PROTOCOL', message: err.message });
894
+ return;
895
+ }
896
+ if (messages.length === 0)
897
+ return;
898
+ handled = true; // one request per connection; ignore anything further
899
+ const req = messages[0];
900
+ if (typeof req !== 'object' || req === null || typeof req.verb !== 'string') {
901
+ answer({ v: 1, ok: false, code: 'PROTOCOL', message: 'malformed control request' });
902
+ return;
903
+ }
904
+ if (req.v !== 1) {
905
+ // Reject an unknown protocol version before any mutation runs; a v:999 or a
906
+ // missing version must never execute and return a v1 success.
907
+ answer({ v: 1, ok: false, code: 'PROTOCOL', message: `unsupported control protocol version: ${String(req.v)}` });
908
+ return;
909
+ }
910
+ if (!ready) {
911
+ // A request that arrived during the bind/chmod window, before startup
912
+ // finished. Do not dispatch (the reader reference is not yet bound); answer
913
+ // a retryable error rather than orphaning the connection.
914
+ answer({ v: 1, ok: false, code: 'CAPTURE_NOT_READY', message: 'daemon is still starting' });
915
+ return;
916
+ }
917
+ void dispatch(req).then(answer).catch((err) => {
918
+ answer({ v: 1, ok: false, code: 'STORAGE_UNAVAILABLE', message: err.message });
919
+ });
920
+ });
921
+ sock.on('end', () => {
922
+ if (handled)
923
+ return;
924
+ // Peer finished sending: an unterminated final frame is a fault, not a
925
+ // silent drop.
926
+ try {
927
+ decoder.end();
928
+ }
929
+ catch (err) {
930
+ answer({ v: 1, ok: false, code: 'PROTOCOL', message: err.message });
931
+ }
932
+ });
933
+ sock.on('error', () => sock.destroy());
934
+ sock.on('close', () => { clearTimeout(deadline); connections.delete(sock); });
935
+ }
936
+ // Startup is fully ready: reader running, lock held, not torn, readerRef bound.
937
+ // Only now let the connection handler dispatch verbs (see the `ready` gate).
938
+ ready = true;
939
+ return {
940
+ socketPath,
941
+ readerUrl: reader.url,
942
+ readerToken: reader.token,
943
+ stop: teardown,
944
+ };
945
+ }
946
+ function errFields(code, message) {
947
+ return { __error: true, code, message };
948
+ }
949
+ function isErrorFields(v) {
950
+ return v.__error === true;
951
+ }