@playola-radio/slipstream 0.1.0 → 0.3.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 (55) hide show
  1. package/README.md +427 -101
  2. package/SKILL.md +4 -1
  3. package/contracts/interface/v2/schema.json +3 -3
  4. package/dist/attach-workflow.js +557 -0
  5. package/dist/attribution-engine.js +2 -3
  6. package/dist/attribution-scoring.js +70 -13
  7. package/dist/boundary-registry.js +25 -0
  8. package/dist/capture-intent.js +70 -0
  9. package/dist/capture-scope.js +135 -0
  10. package/dist/cas.js +61 -14
  11. package/dist/claude-root-transcript.js +18 -11
  12. package/dist/cli.js +426 -26
  13. package/dist/clip-function-parser.js +9 -2
  14. package/dist/clip-projection-service.js +28 -0
  15. package/dist/clip-projection-worker.js +4 -0
  16. package/dist/clip-worker-pool.js +13 -1
  17. package/dist/codex-root-transcript.js +46 -0
  18. package/dist/conductor-settings.js +178 -0
  19. package/dist/conductor.js +313 -0
  20. package/dist/control-protocol.js +2 -1
  21. package/dist/daemon.js +1718 -470
  22. package/dist/engine.js +26 -10
  23. package/dist/ensure-daemon.js +30 -0
  24. package/dist/http-reader.js +20 -8
  25. package/dist/intent-store.js +196 -0
  26. package/dist/interface-range-resolver.js +28 -1
  27. package/dist/launch-agent.js +179 -0
  28. package/dist/lock.js +1 -1
  29. package/dist/public-events.js +26 -2
  30. package/dist/question-hook.js +11 -1
  31. package/dist/questions.js +102 -3
  32. package/dist/recovery.js +62 -6
  33. package/dist/session.js +604 -104
  34. package/dist/store-reader.js +112 -7
  35. package/dist/task-forwarder.js +8 -8
  36. package/dist/transcript/discovery.js +242 -111
  37. package/dist/transcript/fs-io.js +10 -1
  38. package/dist/transcript/scheduler.js +114 -0
  39. package/dist/transcript/watcher.js +5 -3
  40. package/dist/withdraw-workflow.js +106 -0
  41. package/dist/workspace.js +133 -0
  42. package/package.json +3 -2
  43. package/schemas/slipstream.agent.binding.v1.json +90 -0
  44. package/schemas/slipstream.agent.connection.v1.json +34 -0
  45. package/schemas/slipstream.agent.unlinked.v1.json +68 -0
  46. package/schemas/slipstream.capture.gap.v1.json +1 -0
  47. package/schemas/slipstream.capture.ready.v1.json +55 -0
  48. package/schemas/slipstream.capture.scope.v1.json +85 -0
  49. package/schemas/slipstream.capture.stopped.v1.json +65 -0
  50. package/schemas/slipstream.question.dispatch_attempted.v1.json +11 -1
  51. package/schemas/slipstream.question.exchange_recorded.v1.json +65 -0
  52. package/schemas/slipstream.question.relay_updated.v1.json +64 -0
  53. package/schemas/slipstream.session.resumed.v1.json +4 -1
  54. package/schemas/slipstream.session.started.v1.json +4 -1
  55. package/dist/transcript/runner.js +0 -62
package/dist/daemon.js CHANGED
@@ -1,79 +1,66 @@
1
1
  import { createServer, connect } from 'node:net';
2
- import { chmod, lstat, unlink, realpath, open } from 'node:fs/promises';
3
- import { constants } from 'node:fs';
2
+ import { chmod, lstat, unlink, realpath, stat } from 'node:fs/promises';
4
3
  import { randomUUID } from 'node:crypto';
5
- import { join } from 'node:path';
6
- import { startCapture, InvalidTitleError } from "./session.js";
7
- import { QuestionError } from "./questions.js";
4
+ import { join, sep } from 'node:path';
5
+ import { startCapture, InvalidTitleError, RootChangedError } from "./session.js";
6
+ import { createTranscriptScheduler } from "./transcript/scheduler.js";
7
+ import { QuestionError, assertAnswerText, normalizeRelayUpdate } from "./questions.js";
8
8
  import { verifyClaudeRootTranscript } from "./claude-root-transcript.js";
9
+ import { verifyCodexRootTranscript } from "./codex-root-transcript.js";
9
10
  import { StorageError, mkdirpDurable, assertOwnerOnly } from "./storage.js";
10
- import { acquireSessionLock, SessionOwnedError } from "./lock.js";
11
+ import { acquireSessionLock, SessionOwnedError, STALE_MS } from "./lock.js";
11
12
  import { startReaderServer } from "./http-reader.js";
12
- import { isValidSessionId, listSessions } from "./store-reader.js";
13
+ import { isValidSessionId, listSessions, findWorkspaceSession, finalRecord, readTombstone, sessionLogPath, WorkspaceLookupError, } from "./store-reader.js";
14
+ import { CorruptLogError } from "./recovery.js";
15
+ import { LogCorruptError } from "./log-reader.js";
13
16
  import { publishTombstone, removeSessionHistory, sessionExists, reclaimUnreferencedBlobs, } from "./maintenance.js";
14
17
  import { createBoundaryRegistry } from "./boundary-registry.js";
18
+ import { createCas } from "./cas.js";
19
+ import { MAX_BINDINGS, MAX_CAPTURES, admitCapture, emptyIntent, findCapture, findLinked, forgetSession, linkBinding, linkedCount, revokeBinding, runningCount, withdrawCapture, } from "./capture-intent.js";
20
+ import { loadIntent, saveIntent } from "./intent-store.js";
15
21
  import { liveBoundary } from "./reader-runtime.js";
16
22
  import { createLineDecoder, encodeMessage, } from "./control-protocol.js";
17
23
  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;
24
+ const STORE_LOCK_WAIT_MS = 2_000;
25
+ const STORE_LOCK_POLL_MS = 50;
26
+ const RETRY_BACKOFF = { baseMs: 2_000, maxMs: 5 * 60_000 };
27
+ const SETUP_REOFFER_MS = 30_000;
28
+ const UNAVAILABLE_BY_CLAUDE_REASON = {
29
+ 'unavailable': 'transcript-unreadable', 'not-yet': 'transcript-unreadable', 'gap': 'transcript-unreadable',
30
+ 'mismatch': 'identity-changed',
31
+ };
20
32
  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');
33
+ reason;
34
+ constructor(reason, message) {
35
+ super(message);
36
+ this.reason = reason;
59
37
  }
60
- catch {
61
- throw new RootIdentityError('root Codex transcript is missing, mismatched, or from an unsupported runtime');
38
+ }
39
+ /** The intent record could not be written. Every later lifecycle change is
40
+ * refused: acknowledging one the record does not hold would be a lie after a
41
+ * restart. */
42
+ class IntentWriteError extends Error {
43
+ constructor(cause) {
44
+ super(`the capture intent record could not be written (${cause.message}); `
45
+ + 'this daemon refuses attach, detach and unlink until it is restarted', { cause });
46
+ this.name = 'IntentWriteError';
62
47
  }
63
48
  }
64
- /** Build the per-session transcript runtime from resolved config: only harnesses
65
- * the operator declared `configured` are read. */
66
- function transcriptRuntimeFrom(config) {
49
+ /** Build the daemon's one transcript scheduler from resolved config: only harnesses
50
+ * the operator declared `configured` are read, and with none there is no scheduler.
51
+ * Whether a chat is linked plays no part (spec unit 8). */
52
+ function transcriptSchedulerFrom(config) {
67
53
  if (!config)
68
54
  return undefined;
69
55
  const harnesses = HARNESSES.filter((h) => config.policy.sources[h] === 'configured');
70
56
  if (harnesses.length === 0)
71
57
  return undefined;
72
- return {
58
+ return createTranscriptScheduler({
73
59
  harnesses,
74
60
  homes: config.transcript.homes,
75
61
  codexScanLimit: config.transcript.codexScanLimit,
76
- };
62
+ onError: (err) => console.error(`slipstream: transcript watcher error: ${err.message}`),
63
+ });
77
64
  }
78
65
  export class DaemonAlreadyRunningError extends Error {
79
66
  constructor(socketPath) {
@@ -148,9 +135,33 @@ async function bindControl(server, socketPath) {
148
135
  return;
149
136
  }
150
137
  }
138
+ const END_MESSAGES = {
139
+ 'root-missing': 'the workspace root was deleted or replaced; a directory recreated at that path is a new capture',
140
+ 'watcher-failed': 'the watcher stopped delivering changes for this workspace',
141
+ 'maintenance': 'stopped while gc reclaims storage; it resumes on its own afterwards',
142
+ };
143
+ const CAPTURE_LIMIT_MESSAGE = `${MAX_CAPTURES}/${MAX_CAPTURES} capture slots in use. This workspace is NOT recording. `
144
+ + 'Pause one first.';
145
+ const CAPTURE_PAUSED_MESSAGE = 'This workspace is paused. Record it in Slipstream first, then attach again.';
146
+ const BINDING_LIMIT_MESSAGE = `${MAX_BINDINGS}/${MAX_BINDINGS} chats linked. This workspace is still recording without `
147
+ + 'a linked chat. Unlink another chat to connect this one.';
148
+ const WATCH_ONLY_MESSAGE = 'this capture is watch-only: no agent chat is linked to it. Run `slipstream attach` from '
149
+ + 'the agent chat to link one';
150
+ const SETUP_PENDING_MESSAGE = 'the agent chat for this capture has not completed setup; finish the steps slipstream '
151
+ + 'attach printed';
152
+ function isHarness(v) {
153
+ return HARNESSES.includes(v);
154
+ }
155
+ /** One root contains the other, by whole path components. */
156
+ function overlaps(a, b) {
157
+ const within = (child, parent) => child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
158
+ return within(a, b) || within(b, a);
159
+ }
151
160
  export async function startDaemon(opts) {
152
161
  const storeDir = opts.storeDir;
153
162
  const identityRealpath = opts.identityRealpath ?? realpath;
163
+ const workspaceLookup = opts.workspaceLookup ?? findWorkspaceSession;
164
+ const transcripts = opts.transcriptScheduler ?? transcriptSchedulerFrom(opts.config);
154
165
  const socketPath = join(storeDir, 'control.sock');
155
166
  await mkdirpDurable(storeDir);
156
167
  await assertOwnerOnly(storeDir, 'dir');
@@ -172,21 +183,45 @@ export async function startDaemon(opts) {
172
183
  // an onCompromised callback that fires mid-startup never hits a temporal dead
173
184
  // zone (it may run with `reader` still undefined — teardown null-checks it).
174
185
  const registry = createBoundaryRegistry();
175
- let state = 'detached';
176
- let current;
177
- const inflightTasks = new Set();
186
+ const backoff = opts.retryBackoff ?? RETRY_BACKOFF;
187
+ const reofferMs = opts.setupReofferMs ?? SETUP_REOFFER_MS;
188
+ let intent = emptyIntent();
189
+ /** The next intent write not yet started; joining it covers every mutation made
190
+ * before it begins. Writes run one at a time. */
191
+ let intentFlush;
192
+ let intentTail = Promise.resolve();
193
+ let intentBroken;
194
+ /** Every capture this daemon has admitted, by session id; withdrawn ones stay
195
+ * for history until their session is deleted. */
196
+ const captures = new Map();
197
+ /** Stops that settled with their capture `failed`: they recorded no stop. Read
198
+ * by the stop, since a retry waiting on it may requeue the capture first. */
199
+ const unrecordedStops = new WeakSet();
200
+ /** Roots between their canonicalization and their intent write. A second attach
201
+ * of the same root joins the first instead of admitting a duplicate. */
202
+ const admitting = new Map();
203
+ /** Baselines run one at a time, in admission order. */
204
+ let baselines = Promise.resolve();
205
+ /** The store's blob store, shared by every capture (see CaptureOptions.cas).
206
+ * Recreated after maintenance, which may delete blobs it trusts. */
207
+ let cas;
178
208
  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.
209
+ // At most one maintenance op (delete_session / gc) runs at a time. The slot is
210
+ // claimed synchronously in runMaintenance before its first await.
184
211
  let maintenanceInFlight;
212
+ /** A gc is stopping, sweeping or resuming: no capture is admitted or started
213
+ * and no chat is linked or unlinked until it is done. */
214
+ let quiescing = false;
215
+ /** The session a delete_session is removing, and the count of delete_sessions
216
+ * finished so far with the count at which each session's last one finished. An
217
+ * attach never resumes a session being deleted, or one whose delete finished
218
+ * after its lookup began; a later lookup reads the tombstone itself. */
219
+ let deleting;
220
+ let deletesDone = 0;
221
+ const deleteDoneAt = new Map();
185
222
  let reader;
186
223
  let torn = false;
187
224
  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
225
  // Gates DISPATCH, not acceptance: connections are tracked and given a deadline
191
226
  // from the moment they arrive (so bind/chmod-window sockets can never orphan or
192
227
  // hang shutdown), but no verb runs until startup is fully ready — which is also
@@ -216,42 +251,46 @@ export async function startDaemon(opts) {
216
251
  }
217
252
  async function doTeardown() {
218
253
  torn = true;
254
+ for (const c of captures.values()) {
255
+ clearTimeout(c.retryTimer);
256
+ if (c.binding)
257
+ cancelRearm(c.binding);
258
+ }
219
259
  // Drop live control connections so server.close() cannot block on an idle or
220
260
  // half-sent request.
221
261
  for (const sock of connections)
222
262
  sock.destroy();
223
263
  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(() => { });
264
+ // Admissions and baselines in flight observe `torn` and start nothing new; wait
265
+ // for them so every session they opened is in the table and stopped below.
266
+ await Promise.allSettled([...admitting.values()]);
267
+ await baselines;
229
268
  // A maintenance op in flight has already seen `torn` (gc aborts its sweep); wait
230
269
  // for it to settle so a durable tombstone or blob unlink is not racing shutdown.
231
270
  if (maintenanceInFlight)
232
271
  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]);
272
+ let stopErr;
273
+ for (const c of captures.values()) {
274
+ await c.closing;
275
+ stopErr ??= c.closeError;
276
+ const session = c.session;
277
+ if (!session)
278
+ continue;
279
+ // Drain admitted appends before stopping: `torn` is already set, so no new
280
+ // work is admitted, and an append in flight must not race the log closing.
281
+ await Promise.allSettled([...c.inflight]);
244
282
  // Do NOT swallow a failed stop: if capture could not be confirmed stopped,
245
283
  // shutdown must not report success. session.stop() still releases the session
246
284
  // 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.
285
+ // lock below is safe; we surface the failure once cleanup is complete. No
286
+ // stop reason: shutdown is not a withdrawal, so no capture.stopped is written.
248
287
  try {
249
- await current.session.stop();
288
+ await session.stop();
250
289
  }
251
290
  catch (err) {
252
- stopErr = err;
291
+ stopErr ??= err;
253
292
  }
254
- current = undefined;
293
+ c.session = undefined;
255
294
  }
256
295
  await reader?.close().catch(() => { });
257
296
  // Unlink the socket only while we still hold the store lock. On the compromise
@@ -260,31 +299,54 @@ export async function startDaemon(opts) {
260
299
  // successor's socket. Leave it for the new owner instead.
261
300
  if (!compromised)
262
301
  await unlinkOwnSocket();
302
+ await intentTail;
263
303
  await lock.release();
264
304
  if (stopErr)
265
305
  throw stopErr;
266
306
  }
267
307
  let lock;
308
+ // A CLI recording a withdrawal while no daemon ran holds the store lock for
309
+ // milliseconds; wait it out. A lock still held after that, or a socket that
310
+ // starts answering, is another daemon.
311
+ const lockDeadline = Date.now() + STORE_LOCK_WAIT_MS;
312
+ for (;;) {
313
+ try {
314
+ lock = await acquireSessionLock(storeDir, {
315
+ onCompromised: (reason) => {
316
+ compromised = true;
317
+ console.error(`slipstream daemon: store lock lost: ${reason}`);
318
+ void teardown().catch((teardownErr) => {
319
+ console.error('slipstream daemon: teardown after store-lock loss failed:', teardownErr);
320
+ });
321
+ },
322
+ });
323
+ break;
324
+ }
325
+ catch (err) {
326
+ if (!(err instanceof SessionOwnedError))
327
+ throw err;
328
+ if (Date.now() >= lockDeadline || await probeSocket(socketPath, PROBE_TIMEOUT_MS) !== 'stale') {
329
+ throw new DaemonAlreadyRunningError(socketPath);
330
+ }
331
+ await new Promise((resolve) => setTimeout(resolve, STORE_LOCK_POLL_MS));
332
+ }
333
+ }
334
+ // Recovery reads intent only while holding the store lock, so a withdrawal a
335
+ // CLI recorded while no daemon ran is always seen, and pins every session it
336
+ // will resume before the reader can serve one.
337
+ let restored;
268
338
  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
- });
339
+ intent = await loadIntent(storeDir);
340
+ restored = await restoreCaptures();
278
341
  }
279
342
  catch (err) {
280
- if (err instanceof SessionOwnedError)
281
- throw new DaemonAlreadyRunningError(socketPath);
343
+ await lock.release();
282
344
  throw err;
283
345
  }
284
346
  // Bring the reader up before accepting control traffic, so a verb can never be
285
347
  // dispatched against a reader that is not yet serving the public view.
286
348
  try {
287
- reader = await startReaderServer({ storeDir, registry });
349
+ reader = await startReaderServer({ storeDir, registry, captures: capturesProjection });
288
350
  }
289
351
  catch (err) {
290
352
  await lock.release();
@@ -324,198 +386,1181 @@ export async function startDaemon(opts) {
324
386
  throw new Error('slipstream daemon lost its store lock during startup');
325
387
  }
326
388
  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';
389
+ // ---- capture state ------------------------------------------------------
390
+ /** The public state of a capture (contracts.md §6), folded from its intent and
391
+ * its runtime. A withdrawn capture is `stopped` only once its stop is durable. */
392
+ function publicState(c) {
393
+ const entry = findCapture(intent, c.id);
394
+ if (!entry || entry.intent === 'withdrawn') {
395
+ return c.lifecycle === 'stopped' ? { state: 'stopped', reason: entry?.withdrawn_reason ?? 'detached' }
396
+ : { state: 'not_recording', reason: 'stop-unconfirmed', message: 'detach was requested, but the capture could not be confirmed stopped' };
397
+ }
398
+ if (c.ended && ['queued', 'failed', 'stopped'].includes(c.lifecycle))
399
+ return { state: 'stopped', reason: c.ended, message: END_MESSAGES[c.ended] };
400
+ if (entry.last_failure)
401
+ return { state: 'not_recording', reason: entry.last_failure.reason, message: entry.last_failure.message };
402
+ const health = c.session?.health.snapshot().state;
403
+ if (health === 'failing' || health === 'recovering') {
404
+ return { state: 'not_recording', reason: 'storage-unavailable', message: `the store cannot accept events (${health}); capture resumes when it recovers` };
405
+ }
406
+ if (c.lifecycle === 'recording' || c.lifecycle === 'baselining')
407
+ return { state: c.lifecycle, reason: null };
408
+ return { state: 'queued', reason: null };
409
+ }
410
+ /** When recovery will next try a failed capture, while one is scheduled. */
411
+ function retryOf(c) {
412
+ const entry = findCapture(intent, c.id);
413
+ const at = entry?.intent === 'running' ? entry.last_failure?.next_attempt_at_ms : null;
414
+ return typeof at === 'number' ? { next_attempt_at_ms: at } : null;
415
+ }
416
+ function isRunning(c) {
417
+ return findCapture(intent, c.id)?.intent === 'running';
418
+ }
419
+ function isLive(c) {
420
+ const entry = findCapture(intent, c.id);
421
+ return entry !== undefined && entry.intent !== 'withdrawn';
422
+ }
423
+ /** The latest binding intent for a capture, linked or revoked. */
424
+ function bindingRow(c) {
425
+ const entry = intent.bindings.findLast((b) => b.session_id === c.id);
426
+ if (!entry)
427
+ return null;
428
+ const live = entry.intent === 'linked' ? c.binding : undefined;
429
+ const row = { harness: entry.harness, harness_session_id: entry.harness_session_id, binding_generation: entry.binding_generation };
430
+ if (entry.intent === 'revoked')
431
+ return { ...row, state: 'unlinked', reason: null };
432
+ if (entry.unavailable)
433
+ return { ...row, state: 'unavailable', reason: entry.unavailable.reason, message: entry.unavailable.message };
434
+ return { ...row, state: live?.check.state ?? 'setup_pending', reason: null };
435
+ }
436
+ async function statusRows(asOf, includeRuntimeDetails = false) {
437
+ const heartbeatOf = async (lockPath) => {
438
+ try {
439
+ return Math.trunc((await stat(lockPath)).mtimeMs);
440
+ }
441
+ catch {
442
+ return null; /* no owner */
443
+ }
444
+ };
445
+ const heartbeatLive = (heartbeat) => heartbeat !== null && asOf - heartbeat <= STALE_MS;
446
+ const daemonLive = heartbeatLive(await heartbeatOf(join(storeDir, 'owner.lock')));
447
+ return Promise.all([...captures.values()].map(async (c) => {
448
+ let { state, reason, message } = publicState(c);
449
+ let binding = bindingRow(c);
450
+ const heartbeat = await heartbeatOf(join(storeDir, 'sessions', c.id, 'owner.lock'));
451
+ // The public liveness rule (contracts.md §6, §7): a capture with no live
452
+ // session owner is waiting on a live daemon, or not confirmed live at all.
453
+ // Waiting, it has no current activation, so no chat is connected in it.
454
+ if (['queued', 'baselining', 'recording'].includes(state) && !heartbeatLive(heartbeat)) {
455
+ [state, reason] = daemonLive ? ['queued', null] : ['not_recording', 'daemon-unavailable'];
456
+ message = undefined;
457
+ if (daemonLive && binding?.state === 'connected')
458
+ binding = { ...binding, state: 'setup_pending' };
459
+ }
460
+ const row = { session_id: c.id, root: c.root, state, reason, retry: retryOf(c), capture_mode: 'watch-only' };
461
+ if (includeRuntimeDetails && message)
462
+ row.message = message;
463
+ if (includeRuntimeDetails && c.session)
464
+ row.durable_seq = c.session.health.snapshot().durable_seq;
465
+ if (!includeRuntimeDetails)
466
+ row.heartbeat_ms = heartbeat;
467
+ row.binding = binding;
468
+ if (includeRuntimeDetails && c.binding)
469
+ row.binding = { ...row.binding, root_transcript: c.binding.transcript };
470
+ return row;
471
+ }));
472
+ }
473
+ /** `GET /v1/captures` (contracts.md §9). Heartbeats are read from the store and
474
+ * session ownership locks, exactly as an offline client would. */
475
+ async function capturesProjection() {
476
+ const asOf = Date.now();
477
+ const rows = await statusRows(asOf);
478
+ return { as_of_ms: asOf, binding_slots: { used: linkedCount(intent), limit: MAX_BINDINGS }, captures: rows };
479
+ }
480
+ async function statusFields() {
481
+ return {
482
+ reader_url: readerRef.url, attach_features: ['agent-connection-v1', 'capture-table-v1'],
483
+ binding_slots: { used: linkedCount(intent), limit: MAX_BINDINGS },
484
+ captures: await statusRows(Date.now(), true),
485
+ };
486
+ }
487
+ async function status(req) {
488
+ if (req.target === undefined)
489
+ return statusFields();
490
+ const c = await resolveTarget(req.target);
491
+ if (isErrorFields(c))
492
+ return c;
493
+ const capture = (await statusRows(Date.now(), true)).find((row) => row.session_id === c.id);
494
+ return { reader_url: readerRef.url, attach_features: ['agent-connection-v1', 'capture-table-v1'], capture };
495
+ }
496
+ /** A target is a session id or a root path. The id is authoritative; a path
497
+ * matches a stored canonical root by realpath equality, or literally when it can
498
+ * no longer be resolved, never by prefix. */
499
+ async function resolveTarget(target) {
500
+ if (!nonEmptyString(target))
501
+ return errFields('TARGET_NOT_FOUND', 'a target session id or workspace path is required');
502
+ const byId = captures.get(target);
503
+ if (byId)
504
+ return byId;
505
+ let root;
506
+ try {
507
+ root = await realpath(target);
508
+ }
509
+ catch {
510
+ root = target;
511
+ }
512
+ const atRoot = [...captures.values()].filter((c) => c.root === root);
513
+ // A root recreated at the same path is a newer capture than the one it replaced.
514
+ const found = atRoot.findLast(isLive) ?? atRoot.at(-1);
515
+ return found ?? errFields('TARGET_NOT_FOUND', `no capture of ${target}`);
516
+ }
517
+ /** The live capture of the directory now at `root`. A capture of a directory
518
+ * that was deleted or replaced there is a different workspace, not a match. */
519
+ function liveCaptureAt(root, identity) {
520
+ return [...captures.values()].find((c) => c.root === root && isLive(c) && sameRoot(c, identity));
521
+ }
522
+ function rootGone(c) {
523
+ return c.ended === 'root-missing' || findCapture(intent, c.id)?.last_failure?.reason === 'root-missing';
524
+ }
525
+ function sameRoot(c, identity) {
526
+ return sameDirectory(findCapture(intent, c.id)?.root_identity, identity);
527
+ }
528
+ function sameDirectory(known, identity) {
529
+ return !known || !identity || (known.dev === identity.dev && known.ino === identity.ino);
530
+ }
531
+ /** The capture a chat identity is linked to, store-wide (at most one). */
532
+ function captureForChat(harness, harnessSessionId) {
533
+ const entry = typeof harness === 'string' && typeof harnessSessionId === 'string'
534
+ ? findLinked(intent, harness, harnessSessionId) : undefined;
535
+ return entry ? captures.get(entry.session_id) : undefined;
536
+ }
537
+ /** Record a capture as not recording. A transient failure is retried with
538
+ * backoff, as a new activation. */
539
+ /** A stop is recorded only while the daemon holds the store lock. A shutdown
540
+ * keeps it, so only its loss makes a stop unrecordable. */
541
+ function assertStoreOwned() {
542
+ if (compromised)
543
+ throw new Error('the daemon lost its store lock');
544
+ }
545
+ function fail(c, reason, message, opts) {
546
+ c.lifecycle = 'failed';
547
+ const entry = findCapture(intent, c.id);
548
+ const next = opts.retry && entry?.intent === 'running' && !torn && !compromised ? scheduleRetry(c) : null;
549
+ if (entry)
550
+ entry.last_failure = { reason, message, at_ms: Date.now(), next_attempt_at_ms: next };
551
+ console.error(`slipstream daemon: capture ${c.id} of ${c.root} is not recording: ${message}`
552
+ + (next === null ? '' : ` (retrying at ${new Date(next).toISOString()})`));
553
+ persistQuietly();
554
+ }
555
+ function scheduleRetry(c) {
556
+ clearTimeout(c.retryTimer);
557
+ c.attempts++;
558
+ const delay = Math.min(backoff.baseMs * 2 ** (c.attempts - 1), backoff.maxMs);
559
+ c.retryTimer = setTimeout(() => {
560
+ c.retryTimer = undefined;
561
+ void retryScheduled(c);
562
+ }, delay);
563
+ return Date.now() + delay;
564
+ }
565
+ /** Not recording, and another activation may fix that. */
566
+ function retryable(c) {
567
+ return c.lifecycle === 'failed' || (c.lifecycle === 'stopped' && c.ended !== undefined);
568
+ }
569
+ async function retryScheduled(c) {
570
+ await c.closing;
571
+ const resume = c.resume || await hasHistory(c.id);
572
+ const entry = findCapture(intent, c.id);
573
+ if (torn || compromised || !retryable(c) || entry?.intent !== 'running' || !entry.last_failure)
350
574
  return;
575
+ entry.last_failure.next_attempt_at_ms = null;
576
+ persistQuietly();
577
+ c.lifecycle = 'queued';
578
+ c.resume = resume;
579
+ enqueueStart(c);
580
+ }
581
+ /** Whether a session's log holds a complete record to resume from. A log a
582
+ * failed first start left without one starts afresh under the same id; an
583
+ * unreadable log is resumed, so it fails visibly. */
584
+ async function hasHistory(id) {
585
+ try {
586
+ return await finalRecord(sessionLogPath(storeDir, id)) !== undefined;
587
+ }
588
+ catch {
589
+ return true;
351
590
  }
352
- if (state === 'attaching')
353
- sessionCompromised = true; // startup aborts below
354
591
  }
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
- }
592
+ /** Rebuild the capture table from the intent record after a restart. Every
593
+ * session recovery will resume is pinned at its durable high-water, and every
594
+ * fresh one reserved, before the reader is exposed or anything is written.
595
+ * Returns the running captures, in admission order, to start once ready. */
596
+ async function restoreCaptures() {
597
+ const toStart = [];
598
+ let forgot = false;
599
+ for (const entry of [...intent.captures]) {
600
+ const id = entry.session_id;
601
+ // A delete that crashed after its durable tombstone: finish forgetting it.
602
+ if (await readTombstone(storeDir, id) !== null) {
603
+ forgetSession(intent, id);
604
+ forgot = true;
605
+ continue;
606
+ }
607
+ let last;
608
+ let unreadable = false;
609
+ try {
610
+ last = await finalRecord(sessionLogPath(storeDir, id));
611
+ }
612
+ catch {
613
+ unreadable = true;
614
+ }
615
+ const c = { id, root: entry.root, lifecycle: 'queued', resume: last !== undefined || unreadable,
616
+ inflight: new Set(), attempts: 0 };
617
+ captures.set(id, c);
618
+ if (entry.intent === 'withdrawn') {
619
+ // `stopped` only when the last activation's stop is durable (§6).
620
+ c.lifecycle = c.resume && last?.type !== 'slipstream.capture.stopped.v1' ? 'failed' : 'stopped';
621
+ continue;
622
+ }
623
+ // An unreadable log is pinned at 0; its resume fails visibly below.
624
+ if (c.resume)
625
+ registry.freeze(id, last?.seq ?? 0n);
626
+ else
627
+ registry.reserve(id);
628
+ const stoppedFor = last?.type === 'slipstream.capture.stopped.v1' ? last.data?.reason : undefined;
629
+ if (stoppedFor === 'root-missing' || stoppedFor === 'watcher-failed' || stoppedFor === 'maintenance')
630
+ c.ended = stoppedFor;
631
+ // Connection state is not persisted: a linked chat comes back pending and is
632
+ // revalidated when its capture activates (contracts.md §8).
633
+ const linked = intent.bindings.find((b) => b.session_id === id && b.intent === 'linked');
634
+ if (linked) {
635
+ c.binding = { harness: linked.harness, harnessSessionId: linked.harness_session_id,
636
+ transcript: linked.verified_transcript_path, generation: linked.binding_generation,
637
+ check: { state: 'setup_pending', checkId: randomUUID() } };
638
+ }
639
+ toStart.push(c);
640
+ }
641
+ if (forgot)
642
+ await persistIntent();
643
+ return toStart;
644
+ }
645
+ /** Make the in-memory intent durable (contracts.md §8). Mutations are synchronous,
646
+ * so slot counts and the write they back cannot interleave; a lifecycle verb
647
+ * awaits this before it acts or replies. */
648
+ function persistIntent(opts = {}) {
649
+ const refuseOnFailure = opts.refuseOnFailure ?? true;
650
+ if (intentBroken)
651
+ return Promise.reject(intentBroken);
652
+ if (compromised)
653
+ return Promise.reject(new Error('the store lock was lost; the intent record belongs to another daemon'));
654
+ intentFlush ??= intentTail.then(async () => {
655
+ intentFlush = undefined;
656
+ if (intentBroken)
657
+ throw intentBroken;
658
+ if (compromised)
659
+ throw new Error('the store lock was lost; the intent record belongs to another daemon');
660
+ try {
661
+ await saveIntent(storeDir, intent);
662
+ }
663
+ catch (err) {
664
+ throw new IntentWriteError(err);
665
+ }
666
+ });
667
+ const flush = intentFlush;
668
+ intentTail = flush.catch(() => { });
669
+ return flush.catch((err) => {
670
+ if (!refuseOnFailure) {
671
+ console.error(`slipstream daemon: background capture-state update was not recorded; it will retry after storage recovers (${err.message})`);
672
+ throw err;
673
+ }
674
+ intentBroken ??= err instanceof IntentWriteError ? err : new IntentWriteError(err);
675
+ console.error(`slipstream daemon: ${intentBroken.message}`);
676
+ throw intentBroken;
677
+ });
678
+ }
679
+ /** Persist a change no acknowledgement depends on (a recorded failure). */
680
+ function persistQuietly() {
681
+ persistIntent({ refuseOnFailure: false }).catch(() => { });
682
+ }
683
+ function intentRefusal() {
684
+ return intentBroken ? errFields('STORAGE_UNAVAILABLE', intentBroken.message) : undefined;
685
+ }
686
+ async function rootIdentityOf(root) {
687
+ try {
688
+ const st = await stat(root, { bigint: true });
689
+ return { dev: st.dev.toString(), ino: st.ino.toString() };
690
+ }
691
+ catch {
692
+ return null;
693
+ }
694
+ }
695
+ /** Find or admit the capture of a canonical root. Only `resumePaused` restarts a
696
+ * withdrawn capture of this root. */
697
+ async function admit(root, opts) {
698
+ if (torn || compromised)
699
+ return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
700
+ const refused = intentRefusal();
701
+ if (refused)
702
+ return refused;
703
+ const identity = await rootIdentityOf(root);
704
+ if (quiescing)
705
+ return errFields('CAPTURE_NOT_READY', 'gc is reclaiming storage; retry shortly');
706
+ const live = liveCaptureAt(root, identity);
707
+ if (live)
708
+ return retryIfFailed(live);
709
+ const pending = admitting.get(root);
710
+ if (pending) {
711
+ const joined = await pending;
712
+ if (isErrorFields(joined) && joined.code === 'CAPTURE_PAUSED' && opts.resumePaused) {
713
+ if (admitting.get(root) === pending)
714
+ admitting.delete(root);
715
+ return admit(root, opts);
716
+ }
717
+ return isErrorFields(joined) ? joined : { capture: joined.capture, created: false };
718
+ }
719
+ // A withdrawn capture still watches its root until its stop completes.
720
+ // A capture whose root is gone watches nothing there any more.
721
+ const watching = [...captures.values()].filter((c) => (isLive(c) || c.lifecycle === 'stopping') && !rootGone(c));
722
+ const other = [...watching.map((c) => c.root), ...admitting.keys()].find((r) => overlaps(r, root));
723
+ if (other) {
724
+ return errFields('ROOT_OVERLAP', `${root} overlaps the capture of ${other}: one contains the other. Nothing was changed`);
725
+ }
726
+ const p = admitNew(root, identity, opts);
727
+ admitting.set(root, p);
728
+ try {
729
+ return await p;
730
+ }
731
+ finally {
732
+ if (admitting.get(root) === p)
733
+ admitting.delete(root);
734
+ }
735
+ }
736
+ async function admitNew(root, rootIdentity, opts) {
737
+ const lookedUpAt = deletesDone;
738
+ let lookup;
739
+ try {
740
+ lookup = await workspaceLookup(storeDir, root);
741
+ }
742
+ catch (err) {
743
+ if (err instanceof WorkspaceLookupError) {
744
+ const remedy = err.corrupt ? corruptRemedy(err.sessionId) : RETRY_REMEDY;
745
+ return errFields('SESSION_RESUME_FAILED', `${err.message}. ${remedy}`);
746
+ }
747
+ return errFields('STORAGE_UNAVAILABLE', `could not look up this workspace's session: ${err.message}`);
748
+ }
749
+ for (const s of lookup.skipped) {
750
+ console.error(`slipstream daemon: skipped session ${s.id} while looking for ${root}'s session: ${s.reason}`);
751
+ }
752
+ // A session of a directory since deleted or replaced at this path is never
753
+ // resumed: the recreated root starts a session of its own.
754
+ const prev = lookup.match && findCapture(intent, lookup.match.id)?.root_identity;
755
+ const match = prev && rootIdentity && (prev.dev !== rootIdentity.dev || prev.ino !== rootIdentity.ino)
756
+ ? undefined : lookup.match;
757
+ const id = match?.id ?? randomUUID();
758
+ // A detach of this same session may still be stopping it; its lock must be
759
+ // released before the session can start again.
760
+ const prior = captures.get(id);
761
+ await prior?.closing;
762
+ if (torn || compromised) {
763
+ if (match) {
764
+ return errFields('SESSION_RESUME_FAILED', `could not resume session ${id}: the daemon is shutting down or lost its store lock. ${RETRY_REMEDY}`);
389
765
  }
390
- const transcript = transcriptRuntimeFrom(opts.config);
766
+ return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
767
+ }
768
+ // Checked in the same turn as the intent entry below, which a delete checks.
769
+ if (match && (deleting === id || (deleteDoneAt.get(id) ?? 0) > lookedUpAt)) {
770
+ return errFields('CAPTURE_NOT_READY', `session ${id} was deleted while this attach looked it up; retry attach`);
771
+ }
772
+ // Checked in the same turn as the intent entry below, so a pause or a
773
+ // concurrent admission cannot slip between them. By root, not by log: a
774
+ // capture paused before it ever started has no log to find.
775
+ const latest = intent.captures.findLast((e) => e.root === root);
776
+ if (!opts.resumePaused && latest?.intent === 'withdrawn' && sameDirectory(latest.root_identity, rootIdentity)) {
777
+ return errFields('CAPTURE_PAUSED', CAPTURE_PAUSED_MESSAGE);
778
+ }
779
+ if (runningCount(intent) >= MAX_CAPTURES)
780
+ return errFields('CAPTURE_LIMIT_REACHED', CAPTURE_LIMIT_MESSAGE);
781
+ if (match) {
782
+ // Pin the boundary at the durable high-water BEFORE the log can grow, and
783
+ // hold it until activation: readers never see past it mid-resume.
784
+ registry.freeze(id, registry.get(id)?.boundary.current() ?? match.durableSeq);
785
+ }
786
+ else {
787
+ // Reserve the registry entry (boundary 0) before startCapture creates the log
788
+ // dir on disk, so the reader never over-publishes an uncommitted record.
789
+ registry.reserve(id);
790
+ }
791
+ const entry = admitCapture(intent, { session_id: id, root, root_identity: rootIdentity });
792
+ try {
793
+ await persistIntent();
794
+ }
795
+ catch (err) {
796
+ intent.captures = intent.captures.filter((e) => e !== entry);
797
+ return errFields('STORAGE_UNAVAILABLE', err.message);
798
+ }
799
+ const c = prior ?? { id, root, lifecycle: 'queued', resume: false, inflight: new Set(), attempts: 0 };
800
+ Object.assign(c, { root, lifecycle: 'queued', resume: match !== undefined, closeError: undefined });
801
+ captures.set(id, c);
802
+ enqueueStart(c);
803
+ return { capture: c, created: true };
804
+ }
805
+ /** Re-attaching a capture whose start or storage failed, or whose activation
806
+ * ended on its own, tries it again. */
807
+ async function retryIfFailed(c) {
808
+ if (!retryable(c))
809
+ return { capture: c, created: false };
810
+ await c.closing;
811
+ const resume = c.resume || await hasHistory(c.id);
812
+ const entry = findCapture(intent, c.id);
813
+ if (!retryable(c) || entry?.intent !== 'running' || torn || compromised)
814
+ return { capture: c, created: false };
815
+ clearTimeout(c.retryTimer);
816
+ c.retryTimer = undefined;
817
+ entry.last_failure = null;
818
+ persistQuietly();
819
+ c.lifecycle = 'queued';
820
+ c.resume = resume;
821
+ enqueueStart(c);
822
+ return { capture: c, created: false };
823
+ }
824
+ function enqueueStart(c) {
825
+ const job = baselines.then(() => startOne(c)).catch((err) => {
826
+ console.error(`slipstream daemon: capture ${c.id} start failed unexpectedly:`, err);
827
+ });
828
+ c.starting = job;
829
+ baselines = job;
830
+ }
831
+ /** Start (or resume) one capture. Never rejects: a failure is recorded on the
832
+ * capture, and the other captures are untouched. */
833
+ async function startOne(c) {
834
+ if (torn || compromised || c.lifecycle !== 'queued' || !isRunning(c))
835
+ return;
836
+ c.lifecycle = 'baselining';
837
+ const moved = await rootProblem(c);
838
+ if (c.lifecycle !== 'baselining')
839
+ return;
840
+ if (moved) {
841
+ if (!c.resume)
842
+ registry.freeze(c.id, 0n);
843
+ fail(c, 'root-missing', moved, { retry: false });
844
+ return;
845
+ }
846
+ const identity = findCapture(intent, c.id)?.root_identity;
847
+ const activation = { lost: false };
848
+ c.activation = activation;
849
+ c.ended = undefined;
850
+ let session;
851
+ try {
852
+ cas ??= await createCas(join(storeDir, 'blobs'));
391
853
  session = await startCapture({
392
- root: resolvedWorktree,
854
+ root: c.root,
393
855
  storeDir,
394
- sessionId: id,
395
- onCompromised: (reason) => handleSessionCompromise(id, reason),
856
+ ...(c.resume ? { resumeSessionId: c.id } : { sessionId: c.id }),
857
+ ...(identity ? { rootIdentity: identity } : {}),
858
+ captureMode: 'watch-only',
859
+ cas,
860
+ onCompromised: (reason) => onSessionLockLost(c, activation, reason),
861
+ onTerminal: (reason, message) => onActivationEnded(c, activation, reason, message),
862
+ onStorage: (state, failure) => onStorageChanged(c, activation, state, failure),
396
863
  ...(opts.config ? { enrichmentPolicy: opts.config.policy } : {}),
397
- ...(transcript ? { transcript } : {}),
864
+ ...(transcripts ? { transcript: transcripts } : {}),
398
865
  }, opts.captureDependencies);
399
866
  }
400
867
  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);
868
+ // A fresh session never activated: freeze at 0, never delete the entry, so
869
+ // the reader cannot fall back to disk for a half-written log. A resumed
870
+ // session keeps the boundary pinned at its pre-resume high-water.
871
+ if (!c.resume)
872
+ registry.freeze(c.id, 0n);
873
+ if (activation.lost || c.lifecycle !== 'baselining')
874
+ return;
875
+ if (err instanceof RootChangedError) {
876
+ fail(c, 'root-missing', err.message, { retry: false });
877
+ return;
878
+ }
879
+ if (c.resume) {
880
+ const corrupt = err instanceof CorruptLogError || err instanceof LogCorruptError;
881
+ fail(c, 'resume-failed', `could not resume session ${c.id}: ${err.message}. `
882
+ + (corrupt ? corruptRemedy(c.id) : RETRY_REMEDY), { retry: !corrupt });
883
+ }
884
+ else {
885
+ fail(c, err instanceof StorageError ? 'storage-unavailable' : 'baseline-failed', err.message, { retry: true });
886
+ }
887
+ return;
888
+ }
889
+ activation.session = session;
890
+ c.session = session;
891
+ c.resume = true;
892
+ if (activation.lost) {
893
+ halt(c, session);
894
+ return;
895
+ }
896
+ // Shutdown or a detach arrived while starting: whoever is stopping it owns the
897
+ // session now.
898
+ if (torn || compromised || c.lifecycle !== 'baselining')
899
+ return;
900
+ c.lifecycle = 'recording';
901
+ c.attempts = 0;
902
+ const entry = findCapture(intent, c.id);
903
+ if (entry?.last_failure) {
904
+ entry.last_failure = null;
905
+ persistQuietly();
906
+ }
907
+ registry.activate(c.id, liveBoundary(session.health));
908
+ registry.setAgentConnection(c.id, 'none');
909
+ if (activation.ended) {
910
+ endActivation(c, session, activation.ended);
911
+ return;
912
+ }
913
+ // A chat stays linked across a restart of its capture, but `connected` is
914
+ // re-earned only by a fresh round trip in the new activation.
915
+ if (c.binding) {
916
+ c.binding.check = { state: 'setup_pending', checkId: randomUUID() };
917
+ c.binding.revalidations = 0;
918
+ startRearm(c, c.binding, session);
919
+ }
920
+ }
921
+ function startRearm(c, b, session) {
922
+ const rearm = (async () => {
923
+ for (;;) {
924
+ let failed;
925
+ try {
926
+ failed = await rearmBinding(c, b, session);
927
+ }
928
+ catch (err) {
929
+ console.error(`slipstream daemon: revalidating the chat linked to ${c.id} failed:`, err);
930
+ failed = controlFailure(err);
931
+ }
932
+ if (!failed || quiescing)
933
+ return;
934
+ const delay = Math.min(backoff.baseMs * 2 ** (b.revalidations ?? 0), backoff.maxMs);
935
+ b.revalidations = (b.revalidations ?? 0) + 1;
936
+ if (!await waitForRearmDelay(b, delay))
937
+ return;
938
+ if (c.binding !== b || c.session !== session || c.lifecycle !== 'recording' || torn || compromised)
939
+ return;
940
+ }
941
+ })();
942
+ b.publishing = rearm;
943
+ const clearPublishing = () => {
944
+ if (b.publishing === rearm)
945
+ b.publishing = undefined;
946
+ };
947
+ void rearm.then(clearPublishing, clearPublishing);
948
+ }
949
+ function waitForRearmDelay(b, delay) {
950
+ return new Promise((resolve) => {
951
+ const timer = setTimeout(() => {
952
+ if (b.cancelRearmDelay === cancel)
953
+ b.cancelRearmDelay = undefined;
954
+ resolve(true);
955
+ }, delay);
956
+ const cancel = () => {
957
+ clearTimeout(timer);
958
+ if (b.cancelRearmDelay === cancel)
959
+ b.cancelRearmDelay = undefined;
960
+ resolve(false);
961
+ };
962
+ b.cancelRearmDelay = cancel;
963
+ });
964
+ }
965
+ function cancelRearm(b) {
966
+ clearTimeout(b.rearmTimer);
967
+ b.rearmTimer = undefined;
968
+ b.cancelRearmDelay?.();
969
+ }
970
+ /** Revalidate a linked chat's transcript in a new activation, then publish it
971
+ * pending. A link whose `agent.binding` never became durable (a crash after its
972
+ * intent) is recorded now, at its stored generation (contracts.md §6). */
973
+ async function rearmBinding(c, b, session) {
974
+ const entry = findLinked(intent, b.harness, b.harnessSessionId);
975
+ try {
976
+ b.transcript = await verifyRootTranscript(b.harness, b.harnessSessionId, c.root, b.transcript);
977
+ }
978
+ catch (err) {
979
+ if (!(err instanceof RootIdentityError))
980
+ throw err;
981
+ b.unavailable = true;
982
+ if (entry) {
983
+ entry.unavailable = { reason: err.reason, message: err.message };
984
+ persistQuietly();
985
+ }
986
+ console.error(`slipstream daemon: the chat linked to ${c.id} is unavailable: ${err.message}`);
987
+ // A transcript that comes back reconnects without another attach, while
988
+ // this binding is still the capture's in this activation.
989
+ const delay = Math.min(backoff.baseMs * 2 ** (b.revalidations ?? 0), backoff.maxMs);
990
+ b.revalidations = (b.revalidations ?? 0) + 1;
991
+ const timer = setTimeout(() => {
992
+ if (b.rearmTimer === timer)
993
+ b.rearmTimer = undefined;
994
+ if (c.binding === b && b.unavailable && c.session === session && c.lifecycle === 'recording' && !quiescing && !torn && !compromised) {
995
+ startRearm(c, b, session);
996
+ }
997
+ }, delay);
998
+ b.rearmTimer = timer;
999
+ timer.unref();
1000
+ return undefined;
1001
+ }
1002
+ b.unavailable = false;
1003
+ if (entry?.unavailable) {
1004
+ entry.unavailable = null;
1005
+ persistQuietly();
1006
+ }
1007
+ return publishBinding(c, b, { record: !session.bindingRecorded(b.generation), revokeOnFailure: false });
1008
+ }
1009
+ /** Whether a capture's root is still the directory it admitted. A root that is
1010
+ * gone, or whose device or inode changed, is never captured as the same session. */
1011
+ async function rootProblem(c) {
1012
+ const known = findCapture(intent, c.id)?.root_identity;
1013
+ let st;
1014
+ try {
1015
+ st = await stat(c.root, { bigint: true });
1016
+ }
1017
+ catch (err) {
1018
+ const code = err.code;
1019
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
1020
+ return undefined;
1021
+ return `the workspace root ${c.root} no longer exists`;
1022
+ }
1023
+ if (st.isDirectory() && (!known || (st.dev.toString() === known.dev && st.ino.toString() === known.ino)))
1024
+ return undefined;
1025
+ return `the workspace root ${c.root} was replaced; a directory recreated there is captured under a new session`;
1026
+ }
1027
+ function onActivationEnded(c, activation, reason, message) {
1028
+ if (c.activation !== activation || activation.lost || activation.ended)
1029
+ return;
1030
+ activation.ended = { reason, message };
1031
+ console.error(`slipstream daemon: capture ${c.id} of ${c.root} stopped: ${message}`);
1032
+ if (activation.session && c.lifecycle === 'recording')
1033
+ endActivation(c, activation.session, activation.ended);
1034
+ }
1035
+ /** Stop an activation that ended on its own, recording why. Its intent stays
1036
+ * running: a failed watcher is retried with backoff as a new activation; a
1037
+ * missing root is not, since it cannot come back as the same directory. */
1038
+ function endActivation(c, session, { reason, message }) {
1039
+ c.lifecycle = 'stopping';
1040
+ const prior = c.closing;
1041
+ setClosing(c, (async () => {
1042
+ await prior;
1043
+ while (c.inflight.size)
1044
+ await Promise.allSettled([...c.inflight]);
1045
+ if (registry.get(c.id))
1046
+ registry.setAgentConnection(c.id, 'none');
1047
+ if (c.session === session)
1048
+ c.session = undefined;
1049
+ try {
1050
+ await session.stop({ reason, assertOwnership: assertStoreOwned });
1051
+ }
1052
+ catch (err) {
1053
+ registry.freeze(c.id, BigInt(session.health.snapshot().durable_seq));
1054
+ fail(c, 'storage-unavailable', `the capture stopped (${message}), but the stop could not be recorded: ${err.message}`, { retry: true });
1055
+ return;
1056
+ }
1057
+ registry.freeze(c.id, BigInt(session.health.snapshot().durable_seq));
1058
+ c.ended = reason;
1059
+ c.lifecycle = 'stopped';
1060
+ const entry = findCapture(intent, c.id);
1061
+ if (reason === 'watcher-failed' && entry?.intent === 'running' && !torn && !compromised) {
1062
+ entry.last_failure = { reason, message, at_ms: Date.now(), next_attempt_at_ms: scheduleRetry(c) };
1063
+ persistQuietly();
1064
+ }
1065
+ })());
1066
+ }
1067
+ /** Record a live activation's storage outage in the intent, and clear it on
1068
+ * recovery, so offline readers agree with this daemon (contracts.md §6). */
1069
+ function onStorageChanged(c, activation, state, failure) {
1070
+ const entry = findCapture(intent, c.id);
1071
+ if (c.activation !== activation || activation.lost || entry?.intent !== 'running')
1072
+ return;
1073
+ if (state === 'failing' && !entry.last_failure) {
1074
+ entry.last_failure = { reason: 'storage-unavailable', at_ms: Date.now(), next_attempt_at_ms: null,
1075
+ message: `the store cannot accept events (${failure?.operation ?? 'append'}: ${failure?.code ?? 'error'}); capture resumes when it recovers` };
1076
+ persistQuietly();
1077
+ return;
411
1078
  }
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.
1079
+ // A recovery that lands while the capture is stopping keeps the outage until
1080
+ // the stop replaces it. Clear synchronously so another outage records a
1081
+ // fresh marker while this clear is still being written.
1082
+ const outage = entry.last_failure;
1083
+ if (state !== 'healthy' || c.lifecycle !== 'recording' || outage?.reason !== 'storage-unavailable'
1084
+ || outage.next_attempt_at_ms !== null)
1085
+ return;
1086
+ entry.last_failure = null;
1087
+ // If the clear cannot reach disk, retain the outage for both readers. A
1088
+ // newer outage wins over this recovery if it arrives before the write fails.
1089
+ persistIntent({ refuseOnFailure: false }).catch(() => { entry.last_failure ??= outage; });
1090
+ }
1091
+ function onSessionLockLost(c, activation, reason) {
1092
+ console.error(`slipstream daemon: session ${c.id} lost its lock: ${reason}`);
1093
+ if (c.activation !== activation || activation.lost)
1094
+ return;
1095
+ activation.lost = true;
1096
+ fail(c, 'storage-unavailable', `capture lost its session lock: ${reason}`, { retry: true });
1097
+ if (activation.session)
1098
+ halt(c, activation.session);
1099
+ }
1100
+ /** Stop a capture whose storage can no longer be trusted, without recording a
1101
+ * stop it cannot make durable. Its boundary stays at the last durable seq. */
1102
+ function halt(c, session) {
1103
+ registry.freeze(c.id, BigInt(session.health.snapshot().durable_seq));
1104
+ const prior = c.closing;
1105
+ setClosing(c, (async () => {
1106
+ await prior;
1107
+ await Promise.allSettled([...c.inflight]);
416
1108
  try {
417
1109
  await session.stop();
418
1110
  }
419
1111
  catch (err) {
420
- abortStopError ??= err;
1112
+ console.error(`slipstream daemon: session ${c.id} did not stop cleanly after losing its lock:`, err);
421
1113
  }
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');
1114
+ if (c.session === session)
1115
+ c.session = undefined;
1116
+ })());
1117
+ }
1118
+ /** Make `closing` the capture's stop in flight until it settles. Its outcome is
1119
+ * read here, before any later waiter on it can run. */
1120
+ function setClosing(c, closing) {
1121
+ c.closing = closing;
1122
+ const settle = () => {
1123
+ if (c.lifecycle === 'failed')
1124
+ unrecordedStops.add(closing);
1125
+ if (c.closing === closing)
1126
+ c.closing = undefined;
1127
+ };
1128
+ closing.then(settle, settle);
1129
+ }
1130
+ // ---- attach / detach / unlink -------------------------------------------
1131
+ function admissionReply({ capture, created }) {
1132
+ const { state, reason, message } = publicState(capture);
1133
+ return { session_id: capture.id, state, reason, ...(message ? { message } : {}), created };
1134
+ }
1135
+ async function attachWatchOnly(req) {
1136
+ if (!nonEmptyString(req.worktree))
1137
+ return errFields('PROTOCOL', 'attach --watch-only requires a worktree');
1138
+ let root;
1139
+ try {
1140
+ root = await realpath(req.worktree);
426
1141
  }
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 };
1142
+ catch (err) {
1143
+ return errFields('CAPTURE_NOT_READY', err.message);
1144
+ }
1145
+ const admitted = await admit(root, { resumePaused: true });
1146
+ return isErrorFields(admitted) ? admitted : admissionReply(admitted);
431
1147
  }
432
- async function attach(req) {
1148
+ /** Verify a root chat transcript names this chat in this worktree. */
1149
+ async function verifyRootTranscript(harness, harnessSessionId, root, path) {
1150
+ let resolved;
1151
+ try {
1152
+ resolved = await realpath(path);
1153
+ }
1154
+ catch {
1155
+ throw new RootIdentityError('transcript-missing', harness === 'claude-code'
1156
+ ? 'root Claude transcript is unavailable; check its path or retry attach after the first root tool call'
1157
+ : 'root Codex transcript cannot be resolved');
1158
+ }
1159
+ if (harness === 'codex') {
1160
+ if (!await verifyCodexRootTranscript(resolved, harnessSessionId, root)) {
1161
+ throw new RootIdentityError('identity-changed', 'root Codex transcript is missing, mismatched, or from an unsupported runtime');
1162
+ }
1163
+ return resolved;
1164
+ }
1165
+ const verified = await verifyClaudeRootTranscript(resolved, harnessSessionId, root);
1166
+ if (!verified.ok) {
1167
+ throw new RootIdentityError(UNAVAILABLE_BY_CLAUDE_REASON[verified.reason], verified.reason === 'not-yet'
1168
+ ? 'root Claude transcript head is not complete yet; retry attach after the first root tool call'
1169
+ : verified.reason === 'gap'
1170
+ ? 'root Claude transcript head exceeds the verification bounds; select a new root early in its session'
1171
+ : `root Claude transcript identity is ${verified.reason}`);
1172
+ }
1173
+ return resolved;
1174
+ }
1175
+ /** Attach from a chat. The chat's transcript is verified first, so a chat that
1176
+ * cannot be linked records nothing. The workspace's capture is found or
1177
+ * admitted; once it is recording the chat is linked in place. */
1178
+ async function attachChat(req) {
433
1179
  if (!nonEmptyString(req.worktree) || !nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id)) {
434
1180
  // 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.
1181
+ // identity refuses attachment rather than guessing.
437
1182
  return errFields('IDENTITY_UNRESOLVED', 'attach requires non-empty worktree, harness, and harness_session_id');
438
1183
  }
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;
1184
+ if (!nonEmptyString(req.root_transcript)) {
1185
+ return errFields('IDENTITY_UNRESOLVED', 'linking a chat requires its root transcript; to record this workspace '
1186
+ + 'without a chat, run `slipstream attach --watch-only`');
1187
+ }
1188
+ if (!isHarness(req.harness))
1189
+ return errFields('IDENTITY_UNRESOLVED', 'root_transcript requires a supported harness');
1190
+ const harness = req.harness;
1191
+ const harnessSessionId = req.harness_session_id;
1192
+ let root;
466
1193
  try {
467
- return await p;
1194
+ root = await realpath(req.worktree);
1195
+ }
1196
+ catch (err) {
1197
+ return errFields('CAPTURE_NOT_READY', err.message);
1198
+ }
1199
+ let transcript;
1200
+ try {
1201
+ transcript = await verifyRootTranscript(harness, harnessSessionId, root, req.root_transcript);
1202
+ }
1203
+ catch (err) {
1204
+ if (err instanceof RootIdentityError)
1205
+ return errFields('IDENTITY_UNRESOLVED', err.message);
1206
+ throw err;
1207
+ }
1208
+ const admitted = await admit(root, { resumePaused: false });
1209
+ if (isErrorFields(admitted))
1210
+ return admitted;
1211
+ const c = admitted.capture;
1212
+ if (c.lifecycle !== 'recording' || !isRunning(c))
1213
+ return admissionReply(admitted);
1214
+ return link(c, { harness, harnessSessionId, transcript }, admitted.created);
1215
+ }
1216
+ function bindingExists(harness, harnessSessionId, c) {
1217
+ return errFields('BINDING_EXISTS', `${harness} chat ${harnessSessionId} is already linked to the capture of ${c.root} `
1218
+ + `(session ${c.id}). That link was kept. To link this chat here instead, run: slipstream unlink --session ${c.id}, `
1219
+ + 'then run slipstream attach again from this chat.');
1220
+ }
1221
+ function bindReply(c, b, extra) {
1222
+ return { session_id: c.id, state: 'recording', binding_generation: b.generation, agent_connection: b.check.state, ...extra };
1223
+ }
1224
+ /** Link a chat to a recording capture in place: same session, no baseline, no
1225
+ * resume, no gap. The slot and identity are claimed synchronously, so the tenth
1226
+ * slot and a chat racing itself onto two captures each resolve to one link. */
1227
+ async function link(c, chat, created) {
1228
+ // Finish an admitted unlink before publishing a replacement binding.
1229
+ if (!c.binding)
1230
+ await Promise.allSettled([...c.inflight]);
1231
+ if (torn || compromised || quiescing || c.lifecycle !== 'recording' || !isRunning(c)) {
1232
+ return errFields('CAPTURE_NOT_READY', 'the capture is no longer recording');
1233
+ }
1234
+ const mine = c.binding;
1235
+ if (mine && mine.harness === chat.harness && mine.harnessSessionId === chat.harnessSessionId) {
1236
+ const failed = await mine.publishing;
1237
+ if (failed)
1238
+ return failed;
1239
+ if (c.binding !== mine)
1240
+ return errFields('CAPTURE_NOT_READY', 'the chat was unlinked while it was being linked; retry attach');
1241
+ if (mine.unavailable)
1242
+ return relink(c, mine, chat.transcript);
1243
+ if (mine.check.state === 'setup_pending')
1244
+ mine.check.offeredAtMs = undefined;
1245
+ return bindReply(c, mine, { already_active: true, created: false });
1246
+ }
1247
+ if (mine)
1248
+ return bindingExists(mine.harness, mine.harnessSessionId, c);
1249
+ const elsewhere = captureForChat(chat.harness, chat.harnessSessionId);
1250
+ if (elsewhere)
1251
+ return bindingExists(chat.harness, chat.harnessSessionId, elsewhere);
1252
+ const refused = intentRefusal();
1253
+ if (refused)
1254
+ return refused;
1255
+ if (linkedCount(intent) >= MAX_BINDINGS)
1256
+ return errFields('BINDING_LIMIT_REACHED', BINDING_LIMIT_MESSAGE);
1257
+ const entry = linkBinding(intent, {
1258
+ session_id: c.id, harness: chat.harness, harness_session_id: chat.harnessSessionId,
1259
+ canonical_worktree: c.root, verified_transcript_path: chat.transcript,
1260
+ });
1261
+ const b = { ...chat, generation: entry.binding_generation,
1262
+ check: { state: 'setup_pending', checkId: randomUUID() } };
1263
+ c.binding = b;
1264
+ // The binding is durable before any event names it.
1265
+ const publishing = persistIntent().then(() => publishBinding(c, b, { record: true, revokeOnFailure: true }), (err) => {
1266
+ if (c.binding === b)
1267
+ c.binding = undefined;
1268
+ revokeBinding(intent, c.id);
1269
+ return errFields('STORAGE_UNAVAILABLE', err.message);
1270
+ });
1271
+ b.publishing = publishing;
1272
+ c.inflight.add(publishing);
1273
+ let failed;
1274
+ try {
1275
+ failed = await publishing;
468
1276
  }
469
1277
  finally {
470
- if (attachInFlight === p)
471
- attachInFlight = undefined;
1278
+ c.inflight.delete(publishing);
1279
+ b.publishing = undefined;
472
1280
  }
1281
+ return failed ?? bindReply(c, b, { created });
473
1282
  }
474
- async function detach() {
475
- if (state !== 'active' || !current) {
476
- return errFields('SESSION_NOT_SELECTED', 'no active session to detach');
1283
+ /** The same chat attaching again to its unavailable link, with a transcript that
1284
+ * verifies now: the link keeps its generation and is published pending again. */
1285
+ async function relink(c, b, transcript) {
1286
+ const refused = intentRefusal();
1287
+ if (refused)
1288
+ return refused;
1289
+ const entry = findLinked(intent, b.harness, b.harnessSessionId);
1290
+ if (!entry || entry.session_id !== c.id)
1291
+ return errFields('CAPTURE_NOT_READY', 'the chat was unlinked while it was being linked; retry attach');
1292
+ entry.unavailable = null;
1293
+ entry.verified_transcript_path = transcript;
1294
+ b.transcript = transcript;
1295
+ b.unavailable = false;
1296
+ b.check = { state: 'setup_pending', checkId: randomUUID() };
1297
+ const publishing = persistIntent().then(() => publishBinding(c, b, { record: !c.session.bindingRecorded(b.generation), revokeOnFailure: false }), (err) => errFields('STORAGE_UNAVAILABLE', err.message));
1298
+ b.publishing = publishing;
1299
+ c.inflight.add(publishing);
1300
+ let failed;
1301
+ try {
1302
+ failed = await publishing;
477
1303
  }
478
- const { id, session } = current;
479
- state = 'detaching'; // close admission synchronously; in-flight tasks still settle
480
- await Promise.allSettled([...inflightTasks]);
481
- let stopErr;
1304
+ finally {
1305
+ c.inflight.delete(publishing);
1306
+ if (b.publishing === publishing)
1307
+ b.publishing = undefined;
1308
+ }
1309
+ return failed ?? bindReply(c, b, { created: false });
1310
+ }
1311
+ /** Append a binding's events in contract order, then publish it to hooks and
1312
+ * forwarders. A new link that cannot be recorded is revoked again. */
1313
+ async function publishBinding(c, b, how) {
1314
+ const session = c.session;
1315
+ const target = { harness: b.harness, harness_session_id: b.harnessSessionId };
1316
+ const own = () => {
1317
+ if (torn || compromised || c.binding !== b || c.lifecycle !== 'recording' || c.activation?.lost) {
1318
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'the capture stopped or the chat was unlinked while it was being linked');
1319
+ }
1320
+ };
1321
+ let recorded = false;
482
1322
  try {
483
- await session.stop();
1323
+ if (how.record) {
1324
+ const bindingRecord = session.recordAgent({ type: 'slipstream.agent.binding.v1', occurred_at_ms: Date.now(), data: {
1325
+ binding_generation: b.generation, target: { ...target, worktree: c.root }, verified_transcript_path: b.transcript
1326
+ } }, own);
1327
+ b.bindingRecord = bindingRecord;
1328
+ try {
1329
+ await bindingRecord;
1330
+ recorded = true;
1331
+ }
1332
+ finally {
1333
+ if (b.bindingRecord === bindingRecord)
1334
+ b.bindingRecord = undefined;
1335
+ }
1336
+ }
1337
+ await session.recordAgent({ type: 'slipstream.agent.connection.v1', occurred_at_ms: Date.now(), data: {
1338
+ state: 'setup_pending', binding_generation: b.generation, target
1339
+ } }, own);
484
1340
  }
485
1341
  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 };
1342
+ if (how.revokeOnFailure && c.binding === b) {
1343
+ c.binding = undefined;
1344
+ revokeBinding(intent, c.id);
1345
+ persistQuietly();
1346
+ // The log already says this chat is linked; say it no longer is.
1347
+ if (recorded) {
1348
+ await session.recordAgent({ type: 'slipstream.agent.unlinked.v1', occurred_at_ms: Date.now(),
1349
+ data: { binding_generation: b.generation, reason: 'unlinked' } }).catch(() => { });
1350
+ }
1351
+ }
1352
+ return controlFailure(err);
1353
+ }
1354
+ registry.setAgentConnection(c.id, 'setup_pending');
1355
+ return undefined;
1356
+ }
1357
+ async function detach(req) {
1358
+ if (req.reason !== undefined && req.reason !== 'archived')
1359
+ return errFields('PROTOCOL', `unknown detach reason: ${String(req.reason)}`);
1360
+ const why = req.reason === 'archived' ? 'archived' : 'detached';
1361
+ const c = await resolveTarget(req.target);
1362
+ if (isErrorFields(c))
1363
+ return c;
1364
+ if (torn || compromised)
1365
+ return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
1366
+ // A path withdraws every capture recorded at it, as the offline path does: an
1367
+ // older capture of a replaced root would otherwise keep its slot.
1368
+ const targets = captures.has(req.target) ? [c] : [...captures.values()].filter((x) => x.root === c.root);
1369
+ const pending = targets.filter((t) => {
1370
+ const entry = findCapture(intent, t.id);
1371
+ return entry !== undefined && entry.intent !== 'withdrawn';
1372
+ });
1373
+ if (pending.length > 0) {
1374
+ const refused = intentRefusal();
1375
+ if (refused)
1376
+ return refused;
1377
+ for (const t of pending) {
1378
+ withdrawCapture(intent, t.id, why);
1379
+ revokeBinding(intent, t.id);
1380
+ }
1381
+ const durable = persistIntent();
1382
+ for (const t of pending) {
1383
+ const prior = t.closing;
1384
+ // Set in this turn, so a concurrent detach waits for the same stop. A
1385
+ // withdrawal that is not durable stops nothing, and is never acknowledged.
1386
+ setClosing(t, durable.then(() => closeCapture(t, prior, why), (err) => { t.withdrawalError = err; }));
1387
+ }
1388
+ }
1389
+ const already = !pending.includes(c) && findCapture(intent, c.id)?.intent === 'withdrawn' && c.lifecycle === 'stopped';
1390
+ await Promise.all(targets.map((t) => t.closing));
1391
+ const undurable = targets.find((t) => t.withdrawalError);
1392
+ if (undurable)
1393
+ return errFields('STORAGE_UNAVAILABLE', `the withdrawal was not saved: ${undurable.withdrawalError.message}`);
1394
+ if (c.closeError) {
1395
+ return errFields('STORAGE_UNAVAILABLE', `detach did not stop cleanly: ${c.closeError.message}`);
1396
+ }
1397
+ const { state, reason } = publicState(c);
1398
+ return { session_id: c.id, state, reason, ...(already ? { already_stopped: true } : {}) };
1399
+ }
1400
+ /** Stop a withdrawn capture: unlink its chat, then record the stop. */
1401
+ async function closeCapture(c, prior, why) {
1402
+ clearTimeout(c.retryTimer);
1403
+ const from = c.lifecycle;
1404
+ if (from === 'queued') {
1405
+ // Not activated: the start job sees the withdrawal and starts nothing. It is
1406
+ // `stopped` only if its last activation, if any, ended in a durable stop (§6).
1407
+ const last = c.resume ? await finalRecord(sessionLogPath(storeDir, c.id)).catch(() => undefined) : undefined;
1408
+ c.lifecycle = !c.resume || last?.type === 'slipstream.capture.stopped.v1' ? 'stopped' : 'failed';
1409
+ return;
1410
+ }
1411
+ c.lifecycle = 'stopping';
1412
+ if (from === 'baselining')
1413
+ await c.starting;
1414
+ await prior;
1415
+ // An unlink can join while an earlier admitted task is draining.
1416
+ while (c.inflight.size)
1417
+ await Promise.allSettled([...c.inflight]);
1418
+ // Detach already revoked the binding's intent with the withdrawal.
1419
+ const b = c.binding;
1420
+ c.binding = undefined;
1421
+ if (b) {
1422
+ cancelRearm(b);
1423
+ await b.bindingRecord?.catch(() => undefined);
1424
+ }
1425
+ if (registry.get(c.id))
1426
+ registry.setAgentConnection(c.id, 'none');
1427
+ const session = c.session;
1428
+ if (!session) {
1429
+ // Its activation already ended in a durable stop, or its storage failed
1430
+ // earlier and it was halted without one.
1431
+ c.lifecycle = c.ended || !c.activation ? 'stopped' : 'failed';
1432
+ return;
1433
+ }
1434
+ c.session = undefined;
1435
+ try {
1436
+ // A link the log never named (a crash before its `agent.binding`) has no
1437
+ // unlink to record.
1438
+ const named = b && session.bindingRecorded(b.generation);
1439
+ await session.stop(named ? { reason: why, unlinkGeneration: b.generation, assertOwnership: assertStoreOwned }
1440
+ : { reason: why, assertOwnership: assertStoreOwned });
1441
+ c.lifecycle = 'stopped';
1442
+ }
1443
+ catch (err) {
1444
+ c.closeError = err;
1445
+ c.lifecycle = 'failed';
1446
+ }
1447
+ // Pin the reader at the final durable seq (honest even if stop() failed).
1448
+ registry.freeze(c.id, BigInt(session.health.snapshot().durable_seq));
1449
+ }
1450
+ /** Revoke a capture's chat link; the capture keeps recording. */
1451
+ async function unlinkChat(req) {
1452
+ const c = nonEmptyString(req.session_id) ? captures.get(req.session_id) : undefined;
1453
+ if (!c)
1454
+ return errFields('TARGET_NOT_FOUND', `no capture with session ${String(req.session_id)}`);
1455
+ if (torn || compromised)
1456
+ return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
1457
+ if (quiescing)
1458
+ return errFields('CAPTURE_NOT_READY', 'gc is reclaiming storage; retry shortly');
1459
+ const b = c.binding;
1460
+ if (!b) {
1461
+ // An unlink still being saved is not yet acknowledged to anyone.
1462
+ if (c.unlinking && !await c.unlinking.then(() => true, () => false))
1463
+ return intentRefusal();
1464
+ return { session_id: c.id, already_unlinked: true };
1465
+ }
1466
+ const refused = intentRefusal();
1467
+ if (refused)
1468
+ return refused;
1469
+ c.binding = undefined;
1470
+ cancelRearm(b);
1471
+ revokeBinding(intent, c.id);
1472
+ if (registry.get(c.id))
1473
+ registry.setAgentConnection(c.id, 'none');
1474
+ const persisted = persistIntent();
1475
+ c.unlinking = persisted;
1476
+ const session = c.session;
1477
+ if (!session || !['recording', 'stopping'].includes(c.lifecycle)) {
1478
+ await persisted.catch(() => { });
1479
+ return errFields('STORAGE_UNAVAILABLE', 'the chat was unlinked, but its capture cannot record the unlink');
1480
+ }
1481
+ const admitted = [...c.inflight];
1482
+ const append = (async () => {
1483
+ // Revoke immediately, but end task selection only after old admitted tasks
1484
+ // have committed and the revocation is durable. Detach also drains this
1485
+ // promise before closing the log.
1486
+ await persisted;
1487
+ await Promise.allSettled(admitted);
1488
+ await b.bindingRecord?.catch(() => undefined);
1489
+ if (!session.bindingRecorded(b.generation))
1490
+ return;
1491
+ await session.recordAgent({ type: 'slipstream.agent.unlinked.v1', occurred_at_ms: Date.now(),
1492
+ data: { binding_generation: b.generation, reason: 'unlinked' } });
1493
+ })();
1494
+ c.inflight.add(append);
1495
+ try {
1496
+ await append;
1497
+ }
1498
+ catch (err) {
1499
+ return errFields('STORAGE_UNAVAILABLE', `the chat was unlinked, but the unlink could not be recorded: ${err.message}`);
1500
+ }
1501
+ finally {
1502
+ c.inflight.delete(append);
1503
+ }
1504
+ return { session_id: c.id, binding_generation: b.generation };
1505
+ }
1506
+ // ---- chat-facing verbs --------------------------------------------------
1507
+ /** Capture-level ownership check run immediately before an append: nothing
1508
+ * about any chat, only that this capture still holds its storage. */
1509
+ function ownsCapture(c) {
1510
+ return () => {
1511
+ const live = c.lifecycle === 'recording' || c.lifecycle === 'stopping';
1512
+ if (compromised || torn || !live || c.activation?.lost) {
1513
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
1514
+ }
1515
+ };
1516
+ }
1517
+ /** Ownership check run immediately before an append on a chat's behalf. A
1518
+ * stopping capture still accepts work it admitted: the stop drains it before
1519
+ * revoking the binding. */
1520
+ function owns(c, b) {
1521
+ const capture = ownsCapture(c);
1522
+ return () => {
1523
+ capture();
1524
+ if (c.binding !== b)
1525
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
1526
+ };
1527
+ }
1528
+ /** The chat's published binding and its capture, if it may act now. */
1529
+ function chatTarget(c) {
1530
+ const b = c?.binding;
1531
+ return c && b && !b.publishing && !b.unavailable && c.lifecycle === 'recording' ? { c, b } : undefined;
1532
+ }
1533
+ /** The chat's binding when a request arrives. A request that awaits before it
1534
+ * acts must still find this same binding afterwards: an unlink and relink in
1535
+ * between is a different binding, even at the same capture. */
1536
+ function bindingAtEntry(req) {
1537
+ return captureForChat(req.harness, req.harness_session_id)?.binding;
1538
+ }
1539
+ function staleBinding(req, c, b) {
1540
+ if (req.session_id !== undefined && req.session_id !== c.id) {
1541
+ return errFields('STALE_BINDING', `session_id ${String(req.session_id)} is not the capture this chat is linked to`);
1542
+ }
1543
+ if (req.binding_generation !== undefined && req.binding_generation !== b.generation) {
1544
+ return errFields('STALE_BINDING', `binding_generation ${String(req.binding_generation)} is no longer current`);
1545
+ }
1546
+ return undefined;
1547
+ }
1548
+ async function track(c, promise, ok) {
1549
+ c.inflight.add(promise);
1550
+ try {
1551
+ return ok(await promise);
1552
+ }
1553
+ catch (err) {
1554
+ if (err instanceof InvalidTitleError)
1555
+ return errFields('INVALID_TITLE', err.message);
1556
+ return controlFailure(err);
1557
+ }
1558
+ finally {
1559
+ c.inflight.delete(promise);
1560
+ }
500
1561
  }
501
1562
  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
1563
+ // The caller ships its verified identity triple; a missing one is a contract
519
1564
  // violation, so fail closed rather than declare into whatever is selected.
520
1565
  if (!nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)) {
521
1566
  return errFields('IDENTITY_UNRESOLVED', 'begin_task requires harness, harness_session_id, and worktree');
@@ -524,123 +1569,120 @@ export async function startDaemon(opts) {
524
1569
  return errFields('INVALID_TITLE', 'begin_task requires a non-empty title');
525
1570
  if (!nonEmptyString(req.request_id))
526
1571
  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;
1572
+ // Canonicalize the declared worktree with the same realpath policy attach
1573
+ // uses, so a symlinked or relative declared path matches the capture's root.
1574
+ const atEntry = bindingAtEntry(req);
1575
+ let worktree;
531
1576
  try {
532
- declaredWorktree = await realpath(req.worktree);
1577
+ worktree = await identityRealpath(req.worktree);
533
1578
  }
534
1579
  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);
1580
+ worktree = req.worktree;
581
1581
  }
582
- finally {
583
- inflightTasks.delete(promise);
1582
+ // Everything below runs without an await until the append is admitted, so a
1583
+ // concurrent unlink or detach either sees it in flight or is seen here.
1584
+ if (compromised || torn)
1585
+ return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
1586
+ const linked = captureForChat(req.harness, req.harness_session_id);
1587
+ if (!linked || linked.root !== worktree) {
1588
+ const here = liveCaptureAt(worktree);
1589
+ if (here && !here.binding)
1590
+ return errFields('AGENT_NOT_CONNECTED', WATCH_ONLY_MESSAGE);
1591
+ return errFields('SESSION_NOT_SELECTED', `this chat is not linked to a capture of ${worktree}`);
584
1592
  }
1593
+ if (linked.activation?.lost)
1594
+ return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
1595
+ const target = chatTarget(linked);
1596
+ if (!target)
1597
+ return errFields('CAPTURE_NOT_READY', 'the linked capture is not recording yet');
1598
+ if (target.b !== atEntry)
1599
+ return errFields('SESSION_NOT_SELECTED', 'this chat was unlinked or relinked while the request was in flight');
1600
+ const { c, b } = target;
1601
+ const stale = staleBinding(req, c, b);
1602
+ if (stale)
1603
+ return stale;
1604
+ if (b.check.state !== 'connected')
1605
+ return errFields('AGENT_NOT_CONNECTED', SETUP_PENDING_MESSAGE);
1606
+ return track(c, c.session.beginTask({ title: req.title, requestId: req.request_id }), (r) => c.binding !== b
1607
+ ? errFields('STALE_BINDING', `task ${r.task_id} was recorded, but this chat was unlinked before it could be acknowledged`)
1608
+ : { session_id: r.session_id, task_id: r.task_id, event_id: r.event_id, seq: r.seq });
585
1609
  }
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. */
1610
+ /** Queue a question on the capture the app names. `askQuestion` installs its own
1611
+ * request reservation synchronously; the daemon-level lifetime promise is
1612
+ * installed in the same turn so detach and shutdown drain it. */
589
1613
  async function ask(req) {
590
1614
  if (compromised || torn)
591
1615
  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);
1616
+ const c = nonEmptyString(req.session_id) ? captures.get(req.session_id) : undefined;
1617
+ if (!c || !isLive(c))
1618
+ return errFields('SESSION_NOT_SELECTED', 'no capture with that session id is running');
1619
+ if (c.activation?.lost)
1620
+ return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
1621
+ if (!c.binding)
1622
+ return errFields('AGENT_NOT_CONNECTED', WATCH_ONLY_MESSAGE);
1623
+ if (req.binding_generation !== undefined && req.binding_generation !== c.binding.generation) {
1624
+ return errFields('STALE_BINDING', `binding_generation ${String(req.binding_generation)} is no longer current`);
1625
+ }
1626
+ if (c.lifecycle !== 'recording' || !isRunning(c))
1627
+ return errFields('CAPTURE_NOT_READY', 'the capture is not recording yet');
1628
+ const target = chatTarget(c);
1629
+ if (!target)
1630
+ return errFields('AGENT_NOT_CONNECTED', SETUP_PENDING_MESSAGE);
1631
+ const { b } = target;
1632
+ if (b.check.state !== 'connected')
1633
+ return errFields('AGENT_NOT_CONNECTED', SETUP_PENDING_MESSAGE);
1634
+ return track(c, c.session.askQuestion(req, {
1635
+ harness: b.harness, harness_session_id: b.harnessSessionId, worktree: c.root,
1636
+ }, owns(c, b)), (r) => ({ ...r }));
1637
+ }
1638
+ /** Record a question and the answer the client's own forked chat produced. The
1639
+ * client is the writer, so there is no binding, chat or connection to check:
1640
+ * the capture only has to be recording. The conversation ids it reports are
1641
+ * stored as client-reported and are never used to bind or attribute. */
1642
+ async function recordExchange(req) {
1643
+ if (compromised || torn)
1644
+ return errFields('STORAGE_UNAVAILABLE', 'daemon storage ownership is unavailable');
1645
+ const c = nonEmptyString(req.session_id) ? captures.get(req.session_id) : undefined;
1646
+ if (!c || !isLive(c))
1647
+ return errFields('SESSION_NOT_SELECTED', 'no capture with that session id is running');
1648
+ if (c.activation?.lost)
1649
+ return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
1650
+ if (c.lifecycle !== 'recording' || !isRunning(c))
1651
+ return errFields('CAPTURE_NOT_READY', 'the capture is not recording yet');
1652
+ return track(c, c.session.recordQuestionExchange(req, ownsCapture(c)), (r) => ({ ...r }));
1653
+ }
1654
+ /** Record one later step of a change request sent from a recorded exchange.
1655
+ * The body is checked first so the bodiless capability probe is PROTOCOL
1656
+ * whatever state the capture is in. */
1657
+ async function recordRelayUpdate(req) {
1658
+ if (compromised || torn)
1659
+ return errFields('STORAGE_UNAVAILABLE', 'daemon storage ownership is unavailable');
616
1660
  try {
617
- const result = await promise;
618
- return { ...result };
1661
+ normalizeRelayUpdate(req);
619
1662
  }
620
1663
  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);
1664
+ return errFields(err.code, err.message);
629
1665
  }
1666
+ const c = captures.get(req.session_id);
1667
+ if (!c || !isLive(c))
1668
+ return errFields('SESSION_NOT_SELECTED', 'no capture with that session id is running');
1669
+ if (c.activation?.lost)
1670
+ return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
1671
+ if (c.lifecycle !== 'recording' || !isRunning(c))
1672
+ return errFields('CAPTURE_NOT_READY', 'the capture is not recording yet');
1673
+ return track(c, c.session.recordQuestionRelayUpdate(req, ownsCapture(c)), (r) => ({ ...r }));
630
1674
  }
1675
+ /** The question hook asks for this chat's next question (or its setup check).
1676
+ * Only a linked root chat may claim: subagents and other chats are refused. */
631
1677
  async function claimQuestion(req) {
632
- if (compromised || torn || state === 'wedged')
1678
+ if (compromised || torn)
633
1679
  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)
1680
+ if (!isHarness(req.harness) || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)
639
1681
  || !nonEmptyString(req.transcript_path)
640
1682
  || Object.hasOwn(req, 'agent_id') || Object.hasOwn(req, 'agent_type')) {
641
1683
  return errFields('IDENTITY_UNRESOLVED', 'unsupported or incomplete root identity');
642
1684
  }
643
- const binding = current;
1685
+ const atEntry = bindingAtEntry(req);
644
1686
  let worktree;
645
1687
  let transcript;
646
1688
  try {
@@ -649,46 +1691,39 @@ export async function startDaemon(opts) {
649
1691
  catch {
650
1692
  return errFields('IDENTITY_UNRESOLVED', 'identity path cannot be resolved');
651
1693
  }
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);
1694
+ if (compromised || torn)
1695
+ return errFields('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
1696
+ const linked = captureForChat(req.harness, req.harness_session_id);
1697
+ if (linked?.activation?.lost)
1698
+ return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
1699
+ const target = chatTarget(linked);
1700
+ if (!target || target.b !== atEntry || target.c.root !== worktree || target.b.transcript !== transcript) {
1701
+ return errFields('SESSION_NOT_SELECTED', 'the callback is not a linked root chat');
674
1702
  }
675
- finally {
676
- inflightTasks.delete(promise);
1703
+ const { c, b } = target;
1704
+ const stale = staleBinding(req, c, b);
1705
+ if (stale)
1706
+ return stale;
1707
+ if (b.check.state === 'setup_pending') {
1708
+ const now = Date.now();
1709
+ if (b.check.offeredAtMs !== undefined && now - b.check.offeredAtMs < reofferMs)
1710
+ return { question: null };
1711
+ b.check.offeredAtMs = now;
1712
+ return { question: null, setup_check: { check_id: b.check.checkId } };
677
1713
  }
1714
+ return track(c, c.session.claimQuestion({ harness: b.harness, harness_session_id: b.harnessSessionId, worktree: c.root }, owns(c, b)), (r) => (r === null ? { question: null } : { question: r }));
678
1715
  }
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. */
1716
+ /** Record an answer returned through a linked chat's MCP tool. The caller's
1717
+ * identity routes it to that chat's capture; with no transcript or agent id this
1718
+ * proves the call came through the linked chat, not which agent in it wrote the
1719
+ * answer. A question queued for another chat is not found here. */
683
1720
  async function answerQuestion(req) {
684
1721
  if (!nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)) {
685
1722
  return errFields('IDENTITY_UNRESOLVED', 'answer_question requires harness, harness_session_id, and worktree');
686
1723
  }
687
- if (compromised || torn || state === 'wedged')
1724
+ if (compromised || torn)
688
1725
  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;
1726
+ const atEntry = bindingAtEntry(req);
692
1727
  let worktree;
693
1728
  try {
694
1729
  worktree = await identityRealpath(req.worktree);
@@ -696,45 +1731,66 @@ export async function startDaemon(opts) {
696
1731
  catch {
697
1732
  worktree = req.worktree;
698
1733
  }
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);
1734
+ if (compromised || torn)
1735
+ return errFields('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
1736
+ const linked = captureForChat(req.harness, req.harness_session_id);
1737
+ if (linked?.activation?.lost)
1738
+ return errFields('STORAGE_UNAVAILABLE', 'capture storage is no longer trustworthy');
1739
+ const target = chatTarget(linked);
1740
+ if (!target || target.b !== atEntry || target.c.root !== worktree)
1741
+ return errFields('SESSION_NOT_SELECTED', 'the caller is not a linked chat');
1742
+ const { c, b } = target;
1743
+ const stale = staleBinding(req, c, b);
1744
+ if (stale)
1745
+ return stale;
1746
+ if (req.question_id === b.check.checkId)
1747
+ return confirmAgent(c, b, req.text);
1748
+ return track(c, c.session.answerQuestion({ question_id: req.question_id, text: req.text }, { harness: b.harness, harness_session_id: b.harnessSessionId, worktree: c.root }, owns(c, b)), (r) => ({ ...r }));
1749
+ }
1750
+ /** The linked chat returned its setup check: record it connected, once. */
1751
+ async function confirmAgent(c, b, text) {
1752
+ const check = b.check;
709
1753
  try {
710
- return { ...await promise };
1754
+ assertAnswerText(text);
711
1755
  }
712
1756
  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);
1757
+ return errFields(err.code, err.message);
1758
+ }
1759
+ if (check.ack)
1760
+ return { ...check.ack, duplicate: true };
1761
+ if (check.confirming) {
1762
+ const settled = await check.confirming;
1763
+ return isErrorFields(settled) ? settled : { ...settled, duplicate: true };
1764
+ }
1765
+ const promise = c.session.recordAgent({ type: 'slipstream.agent.connection.v1', occurred_at_ms: Date.now(), data: {
1766
+ state: 'connected', binding_generation: b.generation, target: { harness: b.harness, harness_session_id: b.harnessSessionId }
1767
+ } }, () => {
1768
+ owns(c, b)();
1769
+ if (b.check !== check)
1770
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'the setup check was re-armed by a restart');
1771
+ }).then((event) => {
1772
+ check.state = 'connected';
1773
+ registry.setAgentConnection(c.id, 'connected');
1774
+ check.ack = { session_id: event.data.session_id, question_id: check.checkId, event_id: event.seq, seq: event.seq,
1775
+ answered_at_ms: Date.parse(event.time), duplicate: false };
1776
+ return check.ack;
1777
+ }, controlFailure);
1778
+ check.confirming = promise;
1779
+ c.inflight.add(promise);
1780
+ try {
1781
+ return await promise;
718
1782
  }
719
1783
  finally {
720
- inflightTasks.delete(promise);
1784
+ c.inflight.delete(promise);
1785
+ check.confirming = undefined;
721
1786
  }
722
1787
  }
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. */
1788
+ // ---- maintenance --------------------------------------------------------
1789
+ /** Run one maintenance op at a time. The slot is claimed synchronously through
1790
+ * `maintenanceInFlight`, and `op` runs up to its first await in the same turn. */
728
1791
  async function runMaintenance(op) {
729
1792
  if (compromised || torn)
730
1793
  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
1794
  if (maintenanceInFlight)
739
1795
  return errFields('CAPTURE_NOT_READY', 'another maintenance operation is running; retry shortly');
740
1796
  let release;
@@ -757,75 +1813,243 @@ export async function startDaemon(opts) {
757
1813
  }
758
1814
  const id = req.session_id;
759
1815
  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);
1816
+ // Checked in the same turn `deleting` is set, which admission checks in the
1817
+ // same turn as its intent entry: neither can slip past the other.
1818
+ const target = captures.get(id);
1819
+ const entry = findCapture(intent, id);
1820
+ if ((entry && entry.intent !== 'withdrawn') || target?.session || (target && !['stopped', 'failed'].includes(target.lifecycle))) {
1821
+ const state = target ? publicState(target).state : entry.intent;
1822
+ return errFields('SESSION_ACTIVE', `session ${id} is ${state}; detach it and let it stop first`);
775
1823
  }
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);
1824
+ deleting = id;
790
1825
  try {
791
- await removeSessionHistory(storeDir, id);
1826
+ return await deleteInactive(id);
792
1827
  }
793
- catch (err) {
794
- return errFields('STORAGE_UNAVAILABLE', `${committed}: ${err.message}`);
1828
+ finally {
1829
+ deleting = undefined;
1830
+ deleteDoneAt.set(id, ++deletesDone);
795
1831
  }
796
- return { session_id: id };
797
1832
  });
798
1833
  }
1834
+ async function deleteInactive(id) {
1835
+ let present;
1836
+ try {
1837
+ present = await sessionExists(storeDir, id);
1838
+ }
1839
+ catch (err) {
1840
+ return errFields('STORAGE_UNAVAILABLE', err.message);
1841
+ }
1842
+ if (!present)
1843
+ return errFields('SESSION_NOT_FOUND', `no session ${id} to delete`);
1844
+ // Durable-tombstone-first: publish the marker (fsynced) BEFORE removing any
1845
+ // history, so a crash in between leaves a session the reader already serves as
1846
+ // gone and a later gc finishes the cleanup. Nothing is committed yet, so a
1847
+ // failure here is a plain STORAGE_UNAVAILABLE — no rollback needed.
1848
+ try {
1849
+ await publishTombstone(storeDir, id);
1850
+ }
1851
+ catch (err) {
1852
+ return errFields('STORAGE_UNAVAILABLE', `could not durably record deletion of ${id}: ${err.message}`);
1853
+ }
1854
+ // Now that the tombstone is durable, abort any in-flight follower of this
1855
+ // session so it re-resolves, re-reads the tombstone, and gets 410 rather than
1856
+ // streaming a log we are about to delete. The reader's installIfAbsent gives a
1857
+ // followed retained session a registry entry this freeze can reach.
1858
+ registry.freeze(id, 0n);
1859
+ // Deletion removes the session's capture and binding entries (§8).
1860
+ forgetSession(intent, id);
1861
+ captures.delete(id);
1862
+ try {
1863
+ await persistIntent();
1864
+ }
1865
+ catch (err) {
1866
+ return errFields('STORAGE_UNAVAILABLE', `session ${id} is logically removed (tombstone durable), but ${err.message}`);
1867
+ }
1868
+ // The tombstone is durable → the session is logically removed and the marker is
1869
+ // never rolled back. If we have since lost the store lock, or history cleanup
1870
+ // fails, report that removal committed and cleanup is retryable via gc.
1871
+ const committed = `session ${id} is logically removed (tombstone durable); history cleanup is retryable via gc`;
1872
+ if (compromised || torn)
1873
+ return errFields('STORAGE_UNAVAILABLE', committed);
1874
+ try {
1875
+ await removeSessionHistory(storeDir, id);
1876
+ }
1877
+ catch (err) {
1878
+ return errFields('STORAGE_UNAVAILABLE', `${committed}: ${err.message}`);
1879
+ }
1880
+ return { session_id: id };
1881
+ }
1882
+ /** gc as one maintenance transaction (D9): close admission, stop every capture
1883
+ * with `capture.stopped{maintenance}`, sweep, then resume each one stopped here.
1884
+ * Intent is untouched throughout. */
799
1885
  async function gc() {
800
1886
  return runMaintenance(async () => {
1887
+ quiescing = true;
801
1888
  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)
1889
+ await Promise.allSettled([...admitting.values()]);
1890
+ // Starts already queued run first; none begins until this pass ends.
1891
+ const pass = baselines.then(maintenancePass);
1892
+ baselines = pass.then(() => { }, () => { });
1893
+ return await pass;
1894
+ }
1895
+ finally {
1896
+ quiescing = false;
1897
+ }
1898
+ });
1899
+ }
1900
+ async function maintenancePass() {
1901
+ const stopped = new Set();
1902
+ const unproven = new Map();
1903
+ try {
1904
+ for (const c of captures.values()) {
1905
+ if (c.session && c.lifecycle === 'recording')
1906
+ stopForMaintenance(c, stopped, unproven);
1907
+ }
1908
+ // A detach, a halt or an ended activation may own a stop too; every stop gc
1909
+ // waits for must finish and record its stop before any blob is swept.
1910
+ // Read as each stop settles: a retry may requeue a failed capture later.
1911
+ const settled = (c, closing) => {
1912
+ const failed = c.lifecycle === 'failed' || (closing !== undefined && unrecordedStops.has(closing));
1913
+ if (failed && !unproven.has(c.id))
1914
+ unproven.set(c.id, `capture ${c.id} stopped without recording its stop`);
1915
+ };
1916
+ // A stop still settling counts whatever the lifecycle reads: a lock lost
1917
+ // mid-detach marks the capture `failed` while its log is still draining.
1918
+ const writing = (c) => c.session || c.lifecycle === 'stopping' || c.closing;
1919
+ for (;;) {
1920
+ const busy = [...captures.values()].filter(writing);
1921
+ if (busy.length === 0)
1922
+ break;
1923
+ const closings = busy.map((c) => c.closing);
1924
+ await Promise.allSettled(busy.map((c, i) => Promise.resolve(closings[i]).then(() => settled(c, closings[i]), () => settled(c, closings[i]))));
1925
+ const stuck = busy.filter((c, i) => c.closing === closings[i] && writing(c));
1926
+ if (stuck.length === busy.length) {
1927
+ for (const c of stuck)
1928
+ unproven.set(c.id, `capture ${c.id} has not stopped`);
1929
+ break;
1930
+ }
1931
+ }
1932
+ if (torn || compromised)
1933
+ throw new StorageError('gc-aborted', new Error('the daemon is shutting down or lost its store lock'));
1934
+ if (unproven.size > 0)
1935
+ return errFields('STORAGE_UNAVAILABLE', `gc swept nothing: ${[...unproven.values()].join('; ')}`);
1936
+ const foreign = await freshSessionLock();
1937
+ if (foreign) {
1938
+ return errFields('STORAGE_UNAVAILABLE', `gc swept nothing: session ${foreign} is still being written by another process`);
1939
+ }
1940
+ return await sweep();
1941
+ }
1942
+ catch (err) {
1943
+ // A corrupt/missing retained log or an aborted sweep deletes nothing; report
1944
+ // the store as unavailable rather than a partial success.
1945
+ return errFields('STORAGE_UNAVAILABLE', err.message);
1946
+ }
1947
+ finally {
1948
+ // Resume even after a failed sweep: storage that refused a sweep may still
1949
+ // record, and a capture that cannot fails visibly on its own.
1950
+ quiescing = false;
1951
+ if (!torn && !compromised) {
1952
+ for (const entry of intent.captures) {
1953
+ const c = captures.get(entry.session_id);
1954
+ if (!c || !stopped.has(c) || entry.intent !== 'running' || c.lifecycle !== 'stopped')
810
1955
  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);
1956
+ c.lifecycle = 'queued';
1957
+ c.resume = true;
1958
+ enqueueStart(c);
817
1959
  }
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 };
1960
+ }
1961
+ }
1962
+ }
1963
+ /** Stop one recording capture for maintenance, keeping its capture and binding
1964
+ * intent. Admitted binding publication and chat work finish first, so a link in
1965
+ * flight is kept rather than revoked; a check issued before the stop never
1966
+ * connects afterwards. */
1967
+ function stopForMaintenance(c, stopped, unproven) {
1968
+ const prior = c.closing;
1969
+ setClosing(c, (async () => {
1970
+ await prior;
1971
+ while (c.binding?.publishing || c.inflight.size) {
1972
+ if (c.binding)
1973
+ cancelRearm(c.binding);
1974
+ await Promise.allSettled([c.binding?.publishing, ...c.inflight]);
1975
+ }
1976
+ const session = c.session;
1977
+ // A detach or an ended activation took it over meanwhile, and owns its stop.
1978
+ if (!session || c.lifecycle !== 'recording' || !isRunning(c) || torn || compromised) {
1979
+ if (session && (torn || compromised))
1980
+ unproven.set(c.id, `capture ${c.id} was not stopped`);
1981
+ return;
1982
+ }
1983
+ c.lifecycle = 'stopping';
1984
+ if (c.binding)
1985
+ c.binding.check = { state: 'setup_pending', checkId: randomUUID() };
1986
+ registry.setAgentConnection(c.id, 'none');
1987
+ c.session = undefined;
1988
+ const freeze = () => registry.freeze(c.id, BigInt(session.health.snapshot().durable_seq));
1989
+ try {
1990
+ await session.stop({ reason: 'maintenance', assertOwnership: assertStoreOwned });
822
1991
  }
823
1992
  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);
1993
+ freeze();
1994
+ const message = err.message;
1995
+ unproven.set(c.id, `capture ${c.id} could not record its maintenance stop: ${message}`);
1996
+ fail(c, 'storage-unavailable', `gc could not record this capture's maintenance stop: ${message}`, { retry: true });
1997
+ return;
827
1998
  }
828
- });
1999
+ freeze();
2000
+ c.ended = 'maintenance';
2001
+ c.lifecycle = 'stopped';
2002
+ stopped.add(c);
2003
+ })());
2004
+ }
2005
+ /** A session ownership lock some other writer refreshed recently: the daemon's
2006
+ * own captures are all stopped by now, and released theirs. */
2007
+ async function freshSessionLock() {
2008
+ for (const s of await listSessions(storeDir)) {
2009
+ let mtimeMs;
2010
+ try {
2011
+ mtimeMs = (await stat(join(storeDir, 'sessions', s.id, 'owner.lock'))).mtimeMs;
2012
+ }
2013
+ catch (err) {
2014
+ if (err.code === 'ENOENT')
2015
+ continue;
2016
+ throw err;
2017
+ }
2018
+ if (Date.now() - mtimeMs <= STALE_MS)
2019
+ return s.id;
2020
+ }
2021
+ return undefined;
2022
+ }
2023
+ /** Finish interrupted deletions, then reclaim unreferenced blobs. */
2024
+ async function sweep() {
2025
+ // A session tombstoned by a delete_session that crashed before cleanup still
2026
+ // carries residual history: re-establish the tombstone's durability, invalidate
2027
+ // any follower a partial delete left pinned, then complete the removal. Stop
2028
+ // before any mutation if we lose the store lock: a successor now owns this
2029
+ // store's files.
2030
+ for (const s of await listSessions(storeDir)) {
2031
+ if (!s.removed)
2032
+ continue;
2033
+ if (compromised || torn) {
2034
+ throw new StorageError('gc-aborted', new Error('store ownership lost mid-cleanup'));
2035
+ }
2036
+ await publishTombstone(storeDir, s.id);
2037
+ registry.freeze(s.id, 0n);
2038
+ await removeSessionHistory(storeDir, s.id);
2039
+ }
2040
+ // The shared blob store trusts blobs it published, some of which this sweep
2041
+ // may delete: every resumed capture starts with a fresh instance. The sweep
2042
+ // aborts if we lose the store lock, since a successor may own the blobs.
2043
+ cas = undefined;
2044
+ const removed = await reclaimUnreferencedBlobs(storeDir, () => torn || compromised);
2045
+ return { removed };
2046
+ }
2047
+ async function attach(req) {
2048
+ if (req.mode === 'watch-only')
2049
+ return attachWatchOnly(req);
2050
+ if (req.mode !== undefined)
2051
+ return errFields('PROTOCOL', `unknown attach mode: ${String(req.mode)}`);
2052
+ return attachChat(req);
829
2053
  }
830
2054
  async function dispatch(req) {
831
2055
  if (compromised)
@@ -833,13 +2057,16 @@ export async function startDaemon(opts) {
833
2057
  let outcome;
834
2058
  switch (req.verb) {
835
2059
  case 'status':
836
- outcome = statusFields();
2060
+ outcome = await status(req);
837
2061
  break;
838
2062
  case 'attach':
839
2063
  outcome = await attach(req);
840
2064
  break;
841
2065
  case 'detach':
842
- outcome = await detach();
2066
+ outcome = await detach(req);
2067
+ break;
2068
+ case 'unlink':
2069
+ outcome = await unlinkChat(req);
843
2070
  break;
844
2071
  case 'begin_task':
845
2072
  outcome = await beginTask(req);
@@ -847,6 +2074,12 @@ export async function startDaemon(opts) {
847
2074
  case 'ask':
848
2075
  outcome = await ask(req);
849
2076
  break;
2077
+ case 'record_question_exchange':
2078
+ outcome = await recordExchange(req);
2079
+ break;
2080
+ case 'record_question_relay_update':
2081
+ outcome = await recordRelayUpdate(req);
2082
+ break;
850
2083
  case 'claim_question':
851
2084
  outcome = await claimQuestion(req);
852
2085
  break;
@@ -936,6 +2169,8 @@ export async function startDaemon(opts) {
936
2169
  // Startup is fully ready: reader running, lock held, not torn, readerRef bound.
937
2170
  // Only now let the connection handler dispatch verbs (see the `ready` gate).
938
2171
  ready = true;
2172
+ for (const c of restored)
2173
+ enqueueStart(c);
939
2174
  return {
940
2175
  socketPath,
941
2176
  readerUrl: reader.url,
@@ -943,9 +2178,22 @@ export async function startDaemon(opts) {
943
2178
  stop: teardown,
944
2179
  };
945
2180
  }
2181
+ function controlFailure(err) {
2182
+ if (err instanceof QuestionError)
2183
+ return errFields(err.code, err.message);
2184
+ if (err instanceof StorageError)
2185
+ return errFields('STORAGE_UNAVAILABLE', err.message);
2186
+ return errFields('CAPTURE_NOT_READY', err.message);
2187
+ }
946
2188
  function errFields(code, message) {
947
2189
  return { __error: true, code, message };
948
2190
  }
2191
+ /** What to do when a session cannot be read or resumed. */
2192
+ function corruptRemedy(id) {
2193
+ return `Restore its data, or remove it with \`slipstream delete ${id}\` (this discards that history)`;
2194
+ }
2195
+ /** A lock, storage or shutdown failure says nothing about the session's data. */
2196
+ const RETRY_REMEDY = 'Retry attach';
949
2197
  function isErrorFields(v) {
950
2198
  return v.__error === true;
951
2199
  }