@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/session.js CHANGED
@@ -1,25 +1,26 @@
1
- import { access, readdir, realpath } from 'node:fs/promises';
1
+ import { access, lstat, mkdtemp, readdir, readFile, realpath, rm, stat, truncate, writeFile } from 'node:fs/promises';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { tmpdir } from 'node:os';
3
4
  import { isAbsolute, join, relative, sep } from 'node:path';
4
5
  import { createCas } from "./cas.js";
5
6
  import { createReader, DEFAULT_MAX_BYTES } from "./reader.js";
6
7
  import { createLog } from "./log.js";
7
- import { createEngine } from "./engine.js";
8
+ import { addUnknownScope, createEngine, isUnderUnknownScope } from "./engine.js";
9
+ import { detectCaptureScope } from "./capture-scope.js";
8
10
  import { createHealth } from "./health.js";
9
11
  import { acquireSessionLock } from "./lock.js";
10
12
  import { recoverSession } from "./recovery.js";
11
13
  import { StorageError, assertOwnerOnly, mkdirpDurable } from "./storage.js";
12
14
  import { snapshotsEqual } from "./snapshot.js";
13
- import { isValidSessionId } from "./store-reader.js";
15
+ import { finalRecord, isValidSessionId } from "./store-reader.js";
14
16
  import { createPlatform } from "./platform.js";
15
17
  import { createAttributionProducer, DEFAULT_ENRICHMENT_POLICY, } from "./attribution-producer.js";
16
- import { normalizeAsk, questionBody, questionResult, readQuestionContext, QuestionError, QUESTION_TTL_MS, QUESTION_LIMIT, assertAnswerText, answerResult } from "./questions.js";
17
- import { createCoverageRunner } from "./transcript/runner.js";
18
- import { nodeDiscoveryIO, nodeTranscriptFileIO } from "./transcript/fs-io.js";
19
- /** How often the transcript watcher re-runs discovery and reads new records. */
20
- export const DEFAULT_TRANSCRIPT_POLL_MS = 2000;
18
+ import { EVENT_TYPES } from "./event.js";
19
+ import { normalizeAsk, questionBody, questionResult, readQuestionContext, QuestionError, QUESTION_TTL_MS, QUESTION_LIMIT, assertAnswerText, answerResult, normalizeExchange, exchangeBody, exchangeResult, sameSourceContext, normalizeRelayUpdate, relayUpdateBody, relayUpdateResult } from "./questions.js";
21
20
  /** A `request_id` was reused with a different title — a caller mistake, not a
22
21
  * retry, so it never commits a second declaration. */
22
+ export class RootChangedError extends Error {
23
+ }
23
24
  export class InvalidTitleError extends Error {
24
25
  code = 'INVALID_TITLE';
25
26
  constructor(message) {
@@ -27,7 +28,15 @@ export class InvalidTitleError extends Error {
27
28
  this.name = 'InvalidTitleError';
28
29
  }
29
30
  }
30
- const defaultDependencies = { createLog, platform: createPlatform(), enumerate, readQuestionContext };
31
+ const CORE_EVENT_TYPES = new Set(EVENT_TYPES);
32
+ const isCoreEvent = (event) => CORE_EVENT_TYPES.has(event.type);
33
+ const defaultDependencies = {
34
+ createLog,
35
+ platform: createPlatform(),
36
+ enumerate,
37
+ readQuestionContext,
38
+ detectScope: detectCaptureScope,
39
+ };
31
40
  /** A relative path escapes its base only via a leading `..` segment (or when it
32
41
  * comes back absolute); a filename that merely starts with `..`, like
33
42
  * `..notes.ts`, stays inside. */
@@ -37,6 +46,37 @@ function escapesBase(rel) {
37
46
  function isUnder(child, parent) {
38
47
  return !escapesBase(relative(parent, child));
39
48
  }
49
+ /** A frozen copy of the root `.slipstreamignore`, handed to git as its
50
+ * lowest-precedence excludes file so capture can drop extra paths — including
51
+ * tracked ones git would merge. Copied once so a mid-capture edit does not
52
+ * change this capture's scope; a later restart re-reads it, matching how git's
53
+ * own rule changes take effect. Returns undefined when the file is absent or
54
+ * empty. A file that exists but cannot be read rejects rather than silently
55
+ * widening scope to paths the user meant to exclude. Only consulted inside a
56
+ * git work tree; a non-git root ignores the file. */
57
+ async function freezeSlipstreamIgnore(root) {
58
+ const path = join(root, '.slipstreamignore');
59
+ // Stat (which follows symlinks but never blocks) before opening: a named pipe
60
+ // or other non-regular file would otherwise hang the read and stall startup.
61
+ let info;
62
+ try {
63
+ info = await stat(path);
64
+ }
65
+ catch (err) {
66
+ if (err.code === 'ENOENT')
67
+ return undefined;
68
+ throw err;
69
+ }
70
+ if (!info.isFile())
71
+ throw new Error(`.slipstreamignore is not a regular file`);
72
+ const content = await readFile(path, 'utf8');
73
+ if (content.trim() === '')
74
+ return undefined;
75
+ const dir = await mkdtemp(join(tmpdir(), 'slip-ignore-'));
76
+ const file = join(dir, 'excludes');
77
+ await writeFile(file, content);
78
+ return { dir, file };
79
+ }
40
80
  export async function startCapture(opts, dependencies = {}) {
41
81
  const deps = { ...defaultDependencies, ...dependencies };
42
82
  if (opts.sessionId !== undefined) {
@@ -50,13 +90,45 @@ export async function startCapture(opts, dependencies = {}) {
50
90
  // The native watcher reports realpaths; resolve symlinks in the root (e.g.
51
91
  // macOS /var -> /private/var) so relative-path math against events matches.
52
92
  const root = await realpath(opts.root);
93
+ const rootStat = await stat(root, { bigint: true });
94
+ const expected = opts.rootIdentity;
95
+ const replaced = (st) => expected !== undefined
96
+ && (st?.dev.toString() !== expected.dev || st.ino.toString() !== expected.ino);
97
+ if (replaced(rootStat)) {
98
+ throw new RootChangedError(`the workspace root ${root} was replaced; a directory recreated there is captured under a new session`);
99
+ }
100
+ // Validate the store before freezing anything, so a bad store directory fails
101
+ // without leaving a frozen copy of the ignore rules behind.
53
102
  await mkdirpDurable(opts.storeDir);
54
103
  const storeDir = await realpath(opts.storeDir);
55
104
  await assertOwnerOnly(storeDir, 'dir');
56
105
  if (isUnder(root, storeDir)) {
57
106
  throw new Error('store directory must not equal or contain the watched root');
58
107
  }
108
+ // Decided before anything is written: a git root git cannot read rejects here
109
+ // rather than silently capturing what git would not merge.
110
+ const baseScope = await deps.detectScope(root);
111
+ // Only a git work tree honors .slipstreamignore; a non-git root never reads it
112
+ // (so an unreadable file there does not block capture). Frozen before
113
+ // classification so a mid-capture edit does not change this capture's scope;
114
+ // cleaned up on a failed start, in the catches below, and in stop().
115
+ let frozenSlip;
116
+ let scope = baseScope;
117
+ if (baseScope.policy === 'git') {
118
+ try {
119
+ frozenSlip = await freezeSlipstreamIgnore(root);
120
+ if (frozenSlip)
121
+ scope = await deps.detectScope(root, frozenSlip.file);
122
+ }
123
+ catch (err) {
124
+ if (frozenSlip)
125
+ await rm(frozenSlip.dir, { recursive: true, force: true }).catch(() => { });
126
+ throw err;
127
+ }
128
+ }
129
+ const slipstreamApplied = frozenSlip !== undefined;
59
130
  const resuming = opts.resumeSessionId !== undefined;
131
+ const captureModeField = opts.captureMode ? { capture_mode: opts.captureMode } : {};
60
132
  const sessionId = opts.resumeSessionId ?? opts.sessionId ?? randomUUID();
61
133
  const blobsDir = join(storeDir, 'blobs');
62
134
  const sessionDir = join(storeDir, 'sessions', sessionId);
@@ -64,7 +136,7 @@ export async function startCapture(opts, dependencies = {}) {
64
136
  await mkdirpDurable(sessionDir);
65
137
  await assertOwnerOnly(sessionDir, 'dir'); // blobsDir perms are checked by createCas
66
138
  const logPath = join(sessionDir, 'events.jsonl');
67
- const cas = await createCas(blobsDir);
139
+ const cas = opts.cas ?? await createCas(blobsDir);
68
140
  // Durable task boundaries (Stage 3). `currentTaskId` is the latest committed
69
141
  // declaration in shared append order — the grouping hint stamped onto changes.
70
142
  // The two maps are the idempotency index keyed by `request_id`: `committedTasks`
@@ -74,41 +146,96 @@ export async function startCapture(opts, dependencies = {}) {
74
146
  const committedQuestions = new Map();
75
147
  const inflightQuestions = new Map();
76
148
  const reservedQuestions = new Set();
77
- // Questions not yet attempted. Removed once dispatch is attempted (durably
78
- // consumed either way) so eligibility counting and claim lookup scan only
79
- // outstanding questions, not every question ever queued by a long-lived capture.
80
- const unattemptedQuestions = new Map();
149
+ // Exchanges are a separate namespace: they never queue, expire, count against
150
+ // QUESTION_LIMIT, or appear in the ask follow-up lookup.
151
+ const committedExchanges = new Map();
152
+ const inflightExchanges = new Map();
153
+ const exchangesById = new Map();
154
+ // relay_id -> question_id for every committed send. A relay_id is reserved while
155
+ // its append is in flight, so a concurrent reuse is refused before either lands.
156
+ const relayQuestions = new Map();
157
+ const reservedRelays = new Set();
158
+ const committedRelayUpdates = new Map();
159
+ const inflightRelayUpdates = new Map();
160
+ const sourceContext = (context) => ({
161
+ change_seq: context.change_seq,
162
+ path: context.path,
163
+ snapshot_sha256: context.snapshot_sha256,
164
+ line_start: context.line_start,
165
+ line_end: context.line_end,
166
+ });
167
+ // Questions a chat may claim. Removed once dispatch is attempted (durably
168
+ // consumed either way) or answered, so eligibility counting and claim lookup
169
+ // scan only outstanding questions, not every question ever queued by a
170
+ // long-lived capture. An unlink re-arms the unanswered ones for the next chat.
171
+ const claimableQuestions = new Map();
81
172
  // Kept for the capture's lifetime: a question stays answerable after it
82
- // leaves the unattempted queue or passes its claim TTL.
173
+ // leaves the claimable queue or passes its claim TTL.
83
174
  const questionsById = new Map();
84
- const attemptSeqs = new Map();
175
+ // Every chat a question was handed to, in attempt order.
176
+ const attemptsById = new Map();
177
+ let latestResumeSeq = 0n;
85
178
  const committedAnswers = new Map();
86
179
  const inflightAnswers = new Map();
87
180
  let currentTaskId;
88
181
  const committedTasks = new Map();
89
182
  const inflightTasks = new Map();
183
+ const pruneExpiredClaimableQuestions = () => {
184
+ const currentTime = now();
185
+ for (const [id, queued] of claimableQuestions) {
186
+ if (currentTime >= queued.data.expires_at_ms)
187
+ claimableQuestions.delete(id);
188
+ }
189
+ };
90
190
  // The attribution producer, constructed once the append path exists below. Its
91
191
  // `noteCommitted` is fed from the log's single in-order commit hook, so every
92
192
  // durably-committed change/evidence/policy reaches it exactly once, in order.
93
193
  let producer;
194
+ const noteAttempt = (event) => {
195
+ const id = event.data.question_id;
196
+ // An attempt written before attempts recorded their chat went to the queued target.
197
+ const target = event.data.target ?? questionsById.get(id).data.target;
198
+ const attempts = attemptsById.get(id) ?? [];
199
+ attempts.push({ target, seq: event.seq });
200
+ attemptsById.set(id, attempts);
201
+ };
94
202
  const seedTaskState = (rec) => {
95
203
  committedQuestions.clear();
96
- unattemptedQuestions.clear();
204
+ claimableQuestions.clear();
205
+ questionsById.clear();
206
+ attemptsById.clear();
207
+ committedAnswers.clear();
208
+ latestResumeSeq = rec.resumeSeq;
97
209
  for (const event of rec.questions) {
98
210
  committedQuestions.set(event.data.request_id, { body: questionBody(event.data), result: questionResult(event) });
99
- unattemptedQuestions.set(event.data.question_id, event);
211
+ questionsById.set(event.data.question_id, event);
212
+ // Questions queued before the latest resume stay history only (D2).
213
+ if (BigInt(event.seq) > rec.resumeSeq)
214
+ claimableQuestions.set(event.data.question_id, event);
100
215
  }
101
- for (const event of rec.questionAttempts)
102
- unattemptedQuestions.delete(event.data.question_id);
103
- questionsById.clear();
104
- attemptSeqs.clear();
105
- committedAnswers.clear();
106
- for (const event of rec.questions)
107
- questionsById.set(event.data.question_id, event.data);
108
- for (const event of rec.questionAttempts)
109
- attemptSeqs.set(event.data.question_id, event.seq);
110
- for (const event of rec.questionAnswers)
216
+ for (const event of rec.questionAttempts) {
217
+ noteAttempt(event);
218
+ if (BigInt(event.seq) > rec.unlinkSeq)
219
+ claimableQuestions.delete(event.data.question_id);
220
+ }
221
+ for (const event of rec.questionAnswers) {
111
222
  committedAnswers.set(event.data.question_id, { text: event.data.text, result: answerResult(event) });
223
+ claimableQuestions.delete(event.data.question_id);
224
+ }
225
+ pruneExpiredClaimableQuestions();
226
+ committedExchanges.clear();
227
+ exchangesById.clear();
228
+ for (const event of rec.questionExchanges) {
229
+ committedExchanges.set(event.data.request_id, { body: exchangeBody(event.data), result: exchangeResult(event) });
230
+ exchangesById.set(event.data.question_id, sourceContext(event.data.context));
231
+ }
232
+ relayQuestions.clear();
233
+ for (const [relayId, questionId] of rec.relayQuestions)
234
+ relayQuestions.set(relayId, questionId);
235
+ committedRelayUpdates.clear();
236
+ for (const event of rec.questionRelayUpdates) {
237
+ committedRelayUpdates.set(event.data.request_id, { body: relayUpdateBody(event.data), result: relayUpdateResult(event) });
238
+ }
112
239
  currentTaskId = rec.currentTaskId;
113
240
  committedTasks.clear();
114
241
  for (const [requestId, decl] of rec.taskDeclarations) {
@@ -137,22 +264,48 @@ export async function startCapture(opts, dependencies = {}) {
137
264
  };
138
265
  },
139
266
  onCommitted: (event) => {
267
+ // A resume or an unlinked chat leaves no current task (D1). A resume leaves no
268
+ // claimable questions (D2); an unlink hands every unanswered question queued
269
+ // since the latest resume to whichever chat is linked next.
270
+ if (event.type === 'slipstream.session.resumed.v1') {
271
+ currentTaskId = undefined;
272
+ latestResumeSeq = BigInt(event.seq);
273
+ claimableQuestions.clear();
274
+ }
275
+ if (event.type === 'slipstream.agent.unlinked.v1') {
276
+ currentTaskId = undefined;
277
+ // Rebuilt in queue order so claims stay oldest first.
278
+ claimableQuestions.clear();
279
+ for (const [id, queued] of questionsById) {
280
+ if (BigInt(queued.seq) > latestResumeSeq && !committedAnswers.has(id))
281
+ claimableQuestions.set(id, queued);
282
+ }
283
+ pruneExpiredClaimableQuestions();
284
+ }
140
285
  if (event.type === 'slipstream.task.started.v1')
141
286
  currentTaskId = event.data.task_id;
142
287
  // Route every durable commit to attribution in the same total order.
143
288
  if (event.type === 'slipstream.question.queued.v1') {
144
- unattemptedQuestions.set(event.data.question_id, event);
145
- questionsById.set(event.data.question_id, event.data);
289
+ claimableQuestions.set(event.data.question_id, event);
290
+ questionsById.set(event.data.question_id, event);
146
291
  }
147
292
  if (event.type === 'slipstream.question.dispatch_attempted.v1') {
148
- unattemptedQuestions.delete(event.data.question_id);
149
- attemptSeqs.set(event.data.question_id, event.seq);
293
+ claimableQuestions.delete(event.data.question_id);
294
+ noteAttempt(event);
150
295
  }
151
296
  if (event.type === 'slipstream.question.answered.v1') {
152
297
  committedAnswers.set(event.data.question_id, { text: event.data.text, result: answerResult(event) });
298
+ claimableQuestions.delete(event.data.question_id);
299
+ }
300
+ if (event.type === 'slipstream.question.exchange_recorded.v1') {
301
+ exchangesById.set(event.data.question_id, sourceContext(event.data.context));
302
+ if (event.data.relay)
303
+ relayQuestions.set(event.data.relay.relay_id, event.data.question_id);
304
+ }
305
+ if (event.type === 'slipstream.question.relay_updated.v1' && event.data.update.kind === 'sent') {
306
+ relayQuestions.set(event.data.relay_id, event.data.question_id);
153
307
  }
154
- if (event.type !== 'slipstream.question.queued.v1' && event.type !== 'slipstream.question.dispatch_attempted.v1'
155
- && event.type !== 'slipstream.question.answered.v1')
308
+ if (isCoreEvent(event))
156
309
  producer?.noteCommitted(event);
157
310
  },
158
311
  };
@@ -179,10 +332,22 @@ export async function startCapture(opts, dependencies = {}) {
179
332
  // duplicates seqs. Resuming existing history requires resumeSessionId. The
180
333
  // check runs while we hold the session lock so a concurrent caller cannot
181
334
  // create then release the same id between the check and log creation.
182
- const logExists = await access(logPath).then(() => true, () => false);
183
- if (logExists) {
335
+ // A log holding no complete record (a first start that failed before its
336
+ // `session.started` became durable) recorded nothing, so it starts afresh.
337
+ try {
338
+ const logExists = await access(logPath).then(() => true, () => false);
339
+ const unstarted = logExists && await finalRecord(logPath).then((r) => r === undefined, () => false);
340
+ if (unstarted)
341
+ await truncate(logPath, 0);
342
+ if (logExists && !unstarted) {
343
+ throw new Error(`cannot start a fresh session ${sessionId}: a log already exists at ${logPath}; use resumeSessionId to continue it`);
344
+ }
345
+ }
346
+ catch (err) {
184
347
  await lock.release();
185
- throw new Error(`cannot start a fresh session ${sessionId}: a log already exists at ${logPath}; use resumeSessionId to continue it`);
348
+ if (frozenSlip)
349
+ await rm(frozenSlip.dir, { recursive: true, force: true }).catch(() => { });
350
+ throw err;
186
351
  }
187
352
  }
188
353
  let recovered;
@@ -203,6 +368,8 @@ export async function startCapture(opts, dependencies = {}) {
203
368
  }
204
369
  catch (err) {
205
370
  await lock.release(); // a failed resume must not leave the session locked
371
+ if (frozenSlip)
372
+ await rm(frozenSlip.dir, { recursive: true, force: true }).catch(() => { });
206
373
  throw err;
207
374
  }
208
375
  }
@@ -221,6 +388,8 @@ export async function startCapture(opts, dependencies = {}) {
221
388
  }
222
389
  catch (err) {
223
390
  await lock.release();
391
+ if (frozenSlip)
392
+ await rm(frozenSlip.dir, { recursive: true, force: true }).catch(() => { });
224
393
  throw err;
225
394
  }
226
395
  let stopped = false;
@@ -239,9 +408,12 @@ export async function startCapture(opts, dependencies = {}) {
239
408
  const enterFailing = (failure) => {
240
409
  if (surrendered)
241
410
  return; // ownership lost: do not fight the new owner
411
+ const newOutage = outageEpisodeId === undefined;
242
412
  outageEpisodeId ??= randomUUID();
243
413
  const alreadySupervising = supervising;
244
414
  health.markFailing(failure);
415
+ if (newOutage)
416
+ opts.onStorage?.('failing', failure);
245
417
  // Capture the loop's promise only when starting a fresh one, so `stop()` can
246
418
  // await the recovery in progress. runSupervisor sets `supervising` in its
247
419
  // synchronous prefix, so a concurrent kick sees it and no-ops.
@@ -301,36 +473,191 @@ export async function startCapture(opts, dependencies = {}) {
301
473
  const excluded = [storeDir, join(root, '.git')];
302
474
  const isExcluded = (abs) => excluded.some((e) => isUnder(abs, e));
303
475
  let live = false;
304
- const buffer = [];
476
+ // Raw observations not yet handed to the engine. Under the git policy every
477
+ // observation waits here until git has classified it; otherwise only those
478
+ // arriving while capture is not live.
479
+ let buffer = [];
305
480
  const onObservation = (abs, observedAtMs) => {
306
481
  if (isExcluded(abs))
307
482
  return;
308
483
  const rel = relative(root, abs);
309
- if (rel === '' || escapesBase(rel))
484
+ if (rel === '') {
485
+ void checkRoot();
486
+ return;
487
+ }
488
+ if (escapesBase(rel))
310
489
  return;
311
- if (live)
490
+ if (live && scope.policy === 'filesystem') {
312
491
  engine.notify(rel, observedAtMs);
313
- else
314
- buffer.push([rel, observedAtMs]);
492
+ return;
493
+ }
494
+ buffer.push([rel, observedAtMs]);
495
+ pumpClassifier();
496
+ };
497
+ // Paths skipped because git ignores them. Each is also marked prior-unknown in
498
+ // the engine, so if one later comes into scope it is never reported as newly
499
+ // created. Only the shallowest skipped path is kept.
500
+ const ignoredPaths = new Set();
501
+ const markIgnored = (rel) => {
502
+ if (!addUnknownScope(rel, ignoredPaths))
503
+ return;
504
+ engine.markBaselineUnknown(rel);
505
+ };
506
+ // Whether the git filter is working, as last durably disclosed. Flipped only
507
+ // once the disclosing scope event is committed.
508
+ let scopeAvailable = true;
509
+ const scopeEvent = () => ({
510
+ type: 'slipstream.capture.scope.v1',
511
+ occurred_at_ms: Date.now(),
512
+ data: scope.policy === 'git'
513
+ ? (slipstreamApplied ? { policy: 'git', status: 'active', slipstream_ignore: 'active' } : { policy: 'git', status: 'active' })
514
+ : { policy: 'filesystem', status: 'active' },
515
+ });
516
+ const isRegularFile = (rel) => lstat(join(root, rel)).then((st) => st.isFile(), () => false);
517
+ // One serial loop asks git about everything buffered so far in a single call;
518
+ // observations arriving meanwhile go in the next round. It runs only while
519
+ // live: a round that finishes after capture stopped being live puts its batch
520
+ // back to be classified afresh when capture is live again.
521
+ let classifierRunning = false;
522
+ let classifierDone = Promise.resolve();
523
+ const pumpClassifier = () => {
524
+ if (scope.policy !== 'git' || classifierRunning || !live || buffer.length === 0)
525
+ return;
526
+ classifierRunning = true;
527
+ classifierDone = classifyLoop().catch((err) => {
528
+ console.error(`slipstream: git classification failed: ${err.message}`);
529
+ });
530
+ };
531
+ const classifyLoop = async () => {
532
+ if (scope.policy !== 'git')
533
+ return;
534
+ try {
535
+ while (live && buffer.length > 0) {
536
+ const batch = buffer.splice(0);
537
+ let ignored;
538
+ try {
539
+ ignored = await scope.ignored([...new Set(batch.map(([rel]) => rel))]);
540
+ }
541
+ catch (err) {
542
+ if (!live) {
543
+ buffer = batch.concat(buffer); // no spread: a batch can exceed the argument limit
544
+ return;
545
+ }
546
+ // Fail closed: never capture what might be ignored. The dropped paths'
547
+ // prior state is now unknown, and the outage is disclosed once.
548
+ console.error(`slipstream: git could not classify ${batch.length} observation(s): ${err.message}`);
549
+ for (const [rel] of batch)
550
+ engine.markBaselineUnknown(rel);
551
+ if (scopeAvailable) {
552
+ await appendEvent({
553
+ type: 'slipstream.capture.scope.v1',
554
+ occurred_at_ms: Date.now(),
555
+ data: slipstreamApplied
556
+ ? { policy: 'git', status: 'unavailable', slipstream_ignore: 'active' }
557
+ : { policy: 'git', status: 'unavailable' },
558
+ }).then(() => { scopeAvailable = false; }, () => { });
559
+ }
560
+ continue;
561
+ }
562
+ const forward = [];
563
+ for (const [rel, ts] of batch) {
564
+ if (ignored.has(rel))
565
+ markIgnored(rel);
566
+ // An ignored directory is out of scope once deleted too, though git no
567
+ // longer matches its vanished path. Directories are never captured. A
568
+ // file forwarded here, or one captured before, is in scope, so its
569
+ // deletion is captured.
570
+ else if (!ignoredPaths.has(rel))
571
+ forward.push([rel, ts]);
572
+ else if (engine.isRecorded(rel) || await isRegularFile(rel)) {
573
+ ignoredPaths.delete(rel);
574
+ forward.push([rel, ts]);
575
+ }
576
+ }
577
+ if (live && !scopeAvailable) {
578
+ await appendEvent(scopeEvent()).then(() => { scopeAvailable = true; }, () => { });
579
+ }
580
+ if (!live || !scopeAvailable) {
581
+ buffer = batch.concat(buffer); // no spread: a batch can exceed the argument limit
582
+ return;
583
+ }
584
+ for (const [rel, ts] of forward)
585
+ engine.notify(rel, ts);
586
+ }
587
+ }
588
+ finally {
589
+ classifierRunning = false;
590
+ }
591
+ };
592
+ // Walk the worktree, skipping what git ignores; skipped paths are marked
593
+ // prior-unknown. Ignored entries listed up front prune whole directories. A
594
+ // file created after that listing is caught by asking git about every file
595
+ // found before any is read.
596
+ const walk = async (handlers) => {
597
+ if (scope.policy !== 'git')
598
+ return deps.enumerate(root, root, isExcluded, handlers);
599
+ const entries = new Set(await scope.ignoredEntries());
600
+ for (const rel of entries)
601
+ markIgnored(rel);
602
+ const files = [];
603
+ await deps.enumerate(root, root, (abs) => isExcluded(abs) || entries.has(relative(root, abs)), {
604
+ onFile: async (rel) => { files.push(rel); },
605
+ onDirError: handlers.onDirError,
606
+ });
607
+ const ignored = await scope.ignored(files);
608
+ for (const rel of files) {
609
+ if (ignored.has(rel))
610
+ markIgnored(rel);
611
+ else
612
+ await handlers.onFile(rel);
613
+ }
315
614
  };
316
615
  // A native-watcher error means delivery may have lapsed; disclose an honest
317
616
  // session-wide coverage gap instead of letting the miss vanish silently.
617
+ const watcherGap = () => appendEvent({
618
+ type: 'slipstream.capture.gap.v1',
619
+ occurred_at_ms: Date.now(),
620
+ data: { scope: { kind: 'session' }, reason: 'watcher-error' },
621
+ }).catch(() => { });
318
622
  const onError = (err) => {
319
623
  console.error(`slipstream: watcher error: ${err.message}`);
320
- void appendEvent({
321
- type: 'slipstream.capture.gap.v1',
322
- occurred_at_ms: Date.now(),
323
- data: { scope: { kind: 'session' }, reason: 'watcher-error' },
324
- }).catch(() => { });
624
+ if (terminal)
625
+ return;
626
+ void watcherGap();
627
+ void checkRoot();
325
628
  };
326
- const priorFor = (path, unknownDirs) => {
327
- for (const prefix of unknownDirs) {
328
- if (prefix === '' || path === prefix || path.startsWith(prefix + sep)) {
329
- return { kind: 'unavailable', reason: 'baseline-unknown' };
330
- }
629
+ // The activation ends, once, when the root it watches is gone or the watcher
630
+ // can no longer deliver for it.
631
+ let terminal = false;
632
+ const end = (reason, message) => {
633
+ if (terminal || stopped)
634
+ return;
635
+ terminal = true;
636
+ opts.onTerminal?.(reason, message);
637
+ };
638
+ const checkRoot = async () => {
639
+ let now;
640
+ try {
641
+ now = await stat(root, { bigint: true });
331
642
  }
332
- return { kind: 'absent' };
643
+ catch (err) {
644
+ const code = err.code;
645
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
646
+ return false;
647
+ }
648
+ if (now?.isDirectory() && now.dev === rootStat.dev && now.ino === rootStat.ino)
649
+ return false;
650
+ end('root-missing', now ? `the workspace root ${root} was replaced` : `the workspace root ${root} no longer exists`);
651
+ return true;
333
652
  };
653
+ const onClosed = async (err) => {
654
+ console.error(`slipstream: watcher subscription closed: ${err.message}`);
655
+ if (terminal || stopped || await checkRoot())
656
+ return;
657
+ await watcherGap();
658
+ end('watcher-failed', `the watcher stopped delivering changes for ${root}: ${err.message}`);
659
+ };
660
+ const priorFor = (path, unknownDirs) => isUnderUnknownScope(path, unknownDirs) ? { kind: 'unavailable', reason: 'baseline-unknown' } : { kind: 'absent' };
334
661
  // Re-observe the whole worktree against the last committed snapshots and emit a
335
662
  // reconciliation change for every differing endpoint. Enumeration omission is
336
663
  // NOT deletion — a known path missing from the scan is actually re-read, so a
@@ -338,13 +665,25 @@ export async function startCapture(opts, dependencies = {}) {
338
665
  const reconcile = async (state, gapSeq, append) => {
339
666
  const current = new Map();
340
667
  const newUnknownDirs = new Set();
341
- await deps.enumerate(root, root, isExcluded, {
668
+ await walk({
342
669
  onFile: async (rel) => { current.set(rel, await reader.read(rel)); },
343
670
  onDirError: async (relDir) => { newUnknownDirs.add(relDir); },
344
671
  });
345
672
  const unknownDirs = new Set([...state.baselineUnknownDirs, ...newUnknownDirs]);
673
+ // Under the git policy a path first seen now may have existed, ignored,
674
+ // while capture was down: its prior state is unknown, never absent.
675
+ if (scope.policy === 'git')
676
+ unknownDirs.add('');
677
+ // A recorded path the walk did not find is either gone or now ignored. An
678
+ // ignored one is out of scope: keep its last recorded state, never read it.
679
+ const unscanned = [...state.committed.keys()].filter((p) => !current.has(p));
680
+ const ignoredUnscanned = scope.policy === 'git' ? await scope.ignored(unscanned) : new Set();
346
681
  const union = new Set([...state.committed.keys(), ...current.keys()]);
347
682
  for (const path of union) {
683
+ if (ignoredUnscanned.has(path)) {
684
+ engine.setBaseline(path, state.committed.get(path));
685
+ continue;
686
+ }
348
687
  const after = current.get(path) ?? (await reader.read(path));
349
688
  const before = state.committed.get(path) ?? priorFor(path, unknownDirs);
350
689
  if (!snapshotsEqual(before, after)) {
@@ -369,7 +708,9 @@ export async function startCapture(opts, dependencies = {}) {
369
708
  // Install every unknown directory — recovered as well as newly-failed — so a
370
709
  // later live observation of a never-baselined path reports baseline-unknown
371
710
  // rather than a fabricated absent prior state.
372
- for (const relDir of unknownDirs)
711
+ for (const relDir of state.baselineUnknownDirs)
712
+ engine.markBaselineUnknown(relDir);
713
+ for (const relDir of newUnknownDirs)
373
714
  engine.markBaselineUnknown(relDir);
374
715
  };
375
716
  const goLive = () => {
@@ -379,6 +720,10 @@ export async function startCapture(opts, dependencies = {}) {
379
720
  if (stopped)
380
721
  return;
381
722
  live = true;
723
+ if (scope.policy === 'git') {
724
+ pumpClassifier();
725
+ return;
726
+ }
382
727
  for (const [rel, ts] of buffer)
383
728
  engine.notify(rel, ts);
384
729
  buffer.length = 0;
@@ -468,6 +813,7 @@ export async function startCapture(opts, dependencies = {}) {
468
813
  goLive();
469
814
  health.markHealthy();
470
815
  outageEpisodeId = undefined; // outage resolved; a later fault opens a new episode
816
+ opts.onStorage?.('healthy');
471
817
  return true;
472
818
  }
473
819
  catch {
@@ -486,7 +832,14 @@ export async function startCapture(opts, dependencies = {}) {
486
832
  // scan is missed; buffered events reconcile against the baseline afterward.
487
833
  let subscription;
488
834
  try {
489
- subscription = await deps.platform.watch({ root, ignore: excluded, onObservation, onError });
835
+ subscription = await deps.platform.watch({ root, ignore: excluded, onObservation, onError,
836
+ onClosed: (err) => { void onClosed(err); } });
837
+ // The root may have been swapped between its first stat and the watcher's
838
+ // install; nothing is written for a directory this session never watched.
839
+ const watched = await stat(root, { bigint: true }).catch(() => undefined);
840
+ if (!watched || watched.dev !== rootStat.dev || watched.ino !== rootStat.ino) {
841
+ throw new RootChangedError(watched ? `the workspace root ${root} was replaced` : `the workspace root ${root} no longer exists`);
842
+ }
490
843
  if (resuming) {
491
844
  // Replay barrier: rebuild the producer's projections and reconstruct
492
845
  // outstanding work from the durable log, seeding prior results so a
@@ -499,6 +852,7 @@ export async function startCapture(opts, dependencies = {}) {
499
852
  data: {
500
853
  recovered_through_seq: recovered.recoveredThroughSeq.toString(),
501
854
  discarded_tail_bytes: recovered.discardedTailBytes,
855
+ ...captureModeField,
502
856
  },
503
857
  });
504
858
  const restartGap = await appendEvent({
@@ -506,6 +860,7 @@ export async function startCapture(opts, dependencies = {}) {
506
860
  occurred_at_ms: Date.now(),
507
861
  data: { scope: { kind: 'session' }, reason: 'restart' },
508
862
  });
863
+ await appendEvent(scopeEvent());
509
864
  await producer.ensurePolicy(enrichmentPolicy);
510
865
  await reconcile({
511
866
  committed: recovered.committed,
@@ -516,10 +871,11 @@ export async function startCapture(opts, dependencies = {}) {
516
871
  await appendEvent({
517
872
  type: 'slipstream.session.started.v1',
518
873
  occurred_at_ms: Date.now(),
519
- data: { root, max_bytes: maxBytes },
874
+ data: { root, max_bytes: maxBytes, ...captureModeField },
520
875
  });
876
+ await appendEvent(scopeEvent());
521
877
  const unknownScopes = [];
522
- await deps.enumerate(root, root, isExcluded, {
878
+ await walk({
523
879
  onFile: async (rel) => {
524
880
  const snapshot = await reader.read(rel);
525
881
  await appendEvent({
@@ -549,6 +905,8 @@ export async function startCapture(opts, dependencies = {}) {
549
905
  producer.start([]);
550
906
  await producer.ensurePolicy(enrichmentPolicy);
551
907
  }
908
+ // Every append above resolved durable, so the barrier has passed.
909
+ await appendEvent({ type: 'slipstream.capture.ready.v1', occurred_at_ms: Date.now(), data: {} });
552
910
  }
553
911
  catch (err) {
554
912
  // A failed startup may already have kicked the recovery supervisor (an append
@@ -562,35 +920,25 @@ export async function startCapture(opts, dependencies = {}) {
562
920
  await engine.drain().catch(() => { });
563
921
  await underlying.close().catch(() => { });
564
922
  await lock.release();
923
+ if (frozenSlip)
924
+ await rm(frozenSlip.dir, { recursive: true, force: true }).catch(() => { });
565
925
  throw err;
566
926
  }
567
927
  goLive();
568
928
  health.markHealthy();
569
929
  const ingestEvidence = (evidence) => producer.ingestEvidence(evidence);
570
- // The transcript coverage watcher (A2): reads configured harnesses' transcripts
571
- // for this worktree as revisable evidence. It never creates a change and its
572
- // evidence flows through the same deduped, append-only producer path, so a
573
- // re-read (including a late transcript after a restart) revises without
574
- // duplicating. Runs only when a harness is actually configured.
575
- let coverageRunner;
576
- const tc = opts.transcript;
577
- if (tc && tc.harnesses.length > 0) {
578
- coverageRunner = createCoverageRunner({
579
- harnesses: tc.harnesses,
580
- homes: tc.homes,
581
- codexScanLimit: tc.codexScanLimit,
582
- root,
583
- sink: { ingest: ingestEvidence },
584
- publish: async (data) => {
585
- await appendEvent({ type: 'slipstream.enrichment.coverage.v1', occurred_at_ms: Date.now(), data });
586
- },
587
- discoveryIO: tc.discoveryIO ?? nodeDiscoveryIO,
588
- fileIO: tc.fileIO ?? nodeTranscriptFileIO,
589
- intervalMs: tc.pollIntervalMs ?? DEFAULT_TRANSCRIPT_POLL_MS,
590
- onError: (err) => console.error(`slipstream: transcript watcher error: ${err.message}`),
591
- });
592
- coverageRunner.start();
593
- }
930
+ // Transcript reading (A2): configured harnesses' transcripts for this worktree
931
+ // are read as revisable evidence on the shared scheduler. It never creates a
932
+ // change and its evidence flows through the same deduped, append-only producer
933
+ // path, so a re-read (including a late transcript after a restart) revises
934
+ // without duplicating.
935
+ const unregisterTranscripts = opts.transcript?.register({
936
+ root,
937
+ sink: { ingest: ingestEvidence },
938
+ publish: async (data) => {
939
+ await appendEvent({ type: 'slipstream.enrichment.coverage.v1', occurred_at_ms: Date.now(), data });
940
+ },
941
+ });
594
942
  const beginTask = async ({ title, requestId }) => {
595
943
  // Readiness: only a live, healthy, still-owned session may declare a task.
596
944
  // Domain error codes (CAPTURE_NOT_READY, STORAGE_UNAVAILABLE, ...) are a later
@@ -684,10 +1032,7 @@ export async function startCapture(opts, dependencies = {}) {
684
1032
  if (pending)
685
1033
  return { ...await pending.promise, duplicate: true };
686
1034
  if (req.reply_to_question_id !== undefined) {
687
- const parent = questionsById.get(req.reply_to_question_id)?.context;
688
- const c = req.context;
689
- if (!parent || parent.change_seq !== c.change_seq || parent.path !== c.path || parent.snapshot_sha256 !== c.snapshot_sha256
690
- || parent.line_start !== c.line_start || parent.line_end !== c.line_end) {
1035
+ if (!sameSourceContext(questionsById.get(req.reply_to_question_id)?.data.context, req.context)) {
691
1036
  throw new QuestionError('INVALID_CONTEXT', 'a follow-up must reply to a question in this capture about the same source');
692
1037
  }
693
1038
  }
@@ -695,11 +1040,11 @@ export async function startCapture(opts, dependencies = {}) {
695
1040
  // An expired-but-never-attempted entry can never become eligible again, so it
696
1041
  // is dropped here rather than kept forever awaiting a claim that will not come.
697
1042
  let eligible = 0;
698
- for (const [id, q] of unattemptedQuestions) {
1043
+ for (const [id, q] of claimableQuestions) {
699
1044
  if (now() < q.data.expires_at_ms)
700
1045
  eligible += 1;
701
1046
  else
702
- unattemptedQuestions.delete(id);
1047
+ claimableQuestions.delete(id);
703
1048
  }
704
1049
  const reserved = [...inflightQuestions.keys()].filter(id => !committedQuestions.has(id)).length;
705
1050
  if (eligible + reserved >= QUESTION_LIMIT)
@@ -730,12 +1075,124 @@ export async function startCapture(opts, dependencies = {}) {
730
1075
  inflightQuestions.delete(req.request_id);
731
1076
  }
732
1077
  };
1078
+ // A committed send is registered by onCommitted (or rebuilt by recovery), so a
1079
+ // reservation can be dropped once its append settles either way.
1080
+ const isKnownRelay = (relayId) => relayQuestions.has(relayId) || reservedRelays.has(relayId);
1081
+ const recordQuestionExchange = async (input, assertOwnership = () => { }) => {
1082
+ const req = normalizeExchange(input);
1083
+ if (req.session_id !== sessionId)
1084
+ throw new QuestionError('SESSION_NOT_SELECTED', 'exchange addresses a different capture');
1085
+ assertOwnership();
1086
+ questionReady();
1087
+ const body = exchangeBody(req);
1088
+ const settled = committedExchanges.get(req.request_id);
1089
+ const pending = inflightExchanges.get(req.request_id);
1090
+ const existing = settled ?? pending;
1091
+ if (existing && existing.body !== body)
1092
+ throw new QuestionError('REQUEST_CONFLICT', 'request_id was used for a different exchange or context');
1093
+ if (settled)
1094
+ return { ...settled.result, duplicate: true };
1095
+ if (pending)
1096
+ return { ...await pending.promise, duplicate: true };
1097
+ if (req.reply_to_question_id !== undefined
1098
+ && !sameSourceContext(exchangesById.get(req.reply_to_question_id), req.context)) {
1099
+ throw new QuestionError('INVALID_CONTEXT', 'a follow-up must reply to an exchange in this capture about the same source');
1100
+ }
1101
+ const relayId = req.relay?.relay_id;
1102
+ if (relayId !== undefined && isKnownRelay(relayId))
1103
+ throw new QuestionError('INVALID_RELAY_UPDATE', 'relay_id was already sent in this capture');
1104
+ // Defer starting I/O until the in-flight reservation is installed synchronously.
1105
+ const promise = Promise.resolve().then(async () => {
1106
+ const selected_text = await deps.readQuestionContext({ storeDir, logPath, sessionId,
1107
+ boundary: BigInt(health.snapshot().durable_seq), context: req.context });
1108
+ assertOwnership();
1109
+ questionReady();
1110
+ const recorded_at_ms = now();
1111
+ const event = await appendEvent({ type: 'slipstream.question.exchange_recorded.v1', occurred_at_ms: recorded_at_ms,
1112
+ data: { question_id: randomUUID(), request_id: req.request_id, text: req.text,
1113
+ context: { ...req.context, selected_text }, answer_text: req.answer_text, recorded_at_ms,
1114
+ conversation: { ...req.conversation, reported_by: 'client' },
1115
+ ...(req.reply_to_question_id !== undefined ? { reply_to_question_id: req.reply_to_question_id } : {}),
1116
+ ...(req.relay !== undefined ? { relay: req.relay } : {}) } });
1117
+ if (surrendered)
1118
+ throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
1119
+ assertOwnership();
1120
+ const result = exchangeResult(event);
1121
+ committedExchanges.set(req.request_id, { body, result });
1122
+ return result;
1123
+ });
1124
+ inflightExchanges.set(req.request_id, { body, promise });
1125
+ if (relayId !== undefined)
1126
+ reservedRelays.add(relayId);
1127
+ try {
1128
+ return await promise;
1129
+ }
1130
+ finally {
1131
+ inflightExchanges.delete(req.request_id);
1132
+ if (relayId !== undefined)
1133
+ reservedRelays.delete(relayId);
1134
+ }
1135
+ };
1136
+ const recordQuestionRelayUpdate = async (input, assertOwnership = () => { }) => {
1137
+ const req = normalizeRelayUpdate(input);
1138
+ if (req.session_id !== sessionId)
1139
+ throw new QuestionError('SESSION_NOT_SELECTED', 'relay update addresses a different capture');
1140
+ assertOwnership();
1141
+ questionReady();
1142
+ const body = relayUpdateBody(req);
1143
+ const settled = committedRelayUpdates.get(req.request_id);
1144
+ const pending = inflightRelayUpdates.get(req.request_id);
1145
+ const existing = settled ?? pending;
1146
+ if (existing && existing.body !== body)
1147
+ throw new QuestionError('REQUEST_CONFLICT', 'request_id was used for a different relay update');
1148
+ if (settled)
1149
+ return { ...settled.result, duplicate: true };
1150
+ if (pending)
1151
+ return { ...await pending.promise, duplicate: true };
1152
+ if (!exchangesById.has(req.question_id))
1153
+ throw new QuestionError('INVALID_RELAY_UPDATE', 'question_id names no recorded exchange in this capture');
1154
+ const isSend = req.update.kind === 'sent';
1155
+ if (isSend && isKnownRelay(req.relay_id))
1156
+ throw new QuestionError('INVALID_RELAY_UPDATE', 'relay_id was already sent in this capture');
1157
+ if (!isSend) {
1158
+ const owner = relayQuestions.get(req.relay_id);
1159
+ if (owner === undefined)
1160
+ throw new QuestionError('INVALID_RELAY_UPDATE', 'relay_id was never sent in this capture');
1161
+ if (owner !== req.question_id)
1162
+ throw new QuestionError('INVALID_RELAY_UPDATE', 'relay_id belongs to another question');
1163
+ }
1164
+ const promise = Promise.resolve().then(async () => {
1165
+ assertOwnership();
1166
+ questionReady();
1167
+ const recorded_at_ms = now();
1168
+ const event = await appendEvent({ type: 'slipstream.question.relay_updated.v1', occurred_at_ms: recorded_at_ms,
1169
+ data: { question_id: req.question_id, relay_id: req.relay_id, request_id: req.request_id, update: req.update, recorded_at_ms } });
1170
+ if (surrendered)
1171
+ throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
1172
+ assertOwnership();
1173
+ const result = relayUpdateResult(event);
1174
+ committedRelayUpdates.set(req.request_id, { body, result });
1175
+ return result;
1176
+ });
1177
+ inflightRelayUpdates.set(req.request_id, { body, promise });
1178
+ if (isSend)
1179
+ reservedRelays.add(req.relay_id);
1180
+ try {
1181
+ return await promise;
1182
+ }
1183
+ finally {
1184
+ inflightRelayUpdates.delete(req.request_id);
1185
+ if (isSend)
1186
+ reservedRelays.delete(req.relay_id);
1187
+ }
1188
+ };
733
1189
  const claimQuestion = async (target, assertOwnership = () => { }) => {
734
1190
  assertOwnership();
735
1191
  questionReady();
736
- const queued = [...unattemptedQuestions.values()].find(q => q.data.target.harness === target.harness
737
- && q.data.target.harness_session_id === target.harness_session_id
738
- && q.data.target.worktree === target.worktree
1192
+ pruneExpiredClaimableQuestions();
1193
+ // The control handler admits only the linked chat, so a question queued for an
1194
+ // earlier chat of this worktree is the current chat's to claim.
1195
+ const queued = [...claimableQuestions.values()].find(q => q.data.target.worktree === target.worktree
739
1196
  && now() < q.data.expires_at_ms
740
1197
  && !reservedQuestions.has(q.data.question_id));
741
1198
  if (!queued)
@@ -745,7 +1202,7 @@ export async function startCapture(opts, dependencies = {}) {
745
1202
  try {
746
1203
  const attempted_at_ms = now();
747
1204
  await appendEvent({ type: 'slipstream.question.dispatch_attempted.v1', occurred_at_ms: attempted_at_ms,
748
- data: { question_id: id, queued_seq: queued.seq, attempted_at_ms } });
1205
+ data: { question_id: id, queued_seq: queued.seq, attempted_at_ms, target } });
749
1206
  if (surrendered)
750
1207
  throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
751
1208
  assertOwnership();
@@ -756,18 +1213,18 @@ export async function startCapture(opts, dependencies = {}) {
756
1213
  }
757
1214
  };
758
1215
  // Order: INVALID_ANSWER, then QUESTION_NOT_FOUND (which also hides questions
759
- // dispatched to another harness session), then duplicate or ANSWER_CONFLICT.
1216
+ // never dispatched to this harness session), then duplicate or ANSWER_CONFLICT.
760
1217
  const answerQuestion = async (input, target, assertOwnership = () => { }) => {
761
1218
  const { question_id: id, text } = input;
762
1219
  assertAnswerText(text);
763
1220
  assertOwnership();
764
1221
  questionReady();
765
- const owner = typeof id === 'string' ? questionsById.get(id)?.target : undefined;
766
- const attemptSeq = typeof id === 'string' ? attemptSeqs.get(id) : undefined;
767
- if (typeof id !== 'string' || !owner || attemptSeq === undefined || owner.harness !== target.harness
768
- || owner.harness_session_id !== target.harness_session_id || owner.worktree !== target.worktree) {
1222
+ const attempt = typeof id === 'string' ? attemptsById.get(id)?.findLast(a => a.target.harness === target.harness
1223
+ && a.target.harness_session_id === target.harness_session_id && a.target.worktree === target.worktree) : undefined;
1224
+ if (typeof id !== 'string' || attempt === undefined) {
769
1225
  throw new QuestionError('QUESTION_NOT_FOUND', 'no question with that id was dispatched to this harness session');
770
1226
  }
1227
+ const attemptSeq = attempt.seq;
771
1228
  const settled = committedAnswers.get(id);
772
1229
  const pending = inflightAnswers.get(id);
773
1230
  const existing = settled ?? pending;
@@ -794,22 +1251,40 @@ export async function startCapture(opts, dependencies = {}) {
794
1251
  inflightAnswers.delete(id);
795
1252
  }
796
1253
  };
797
- const doStop = async () => {
1254
+ const recordAgent = async (input, assertOwnership = () => { }) => {
1255
+ assertOwnership();
1256
+ questionReady();
1257
+ const event = await appendEvent(input);
1258
+ if (input.type === 'slipstream.agent.binding.v1')
1259
+ recordedBindings.add(input.data.binding_generation);
1260
+ if (surrendered)
1261
+ throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
1262
+ assertOwnership();
1263
+ return event;
1264
+ };
1265
+ const recordedBindings = new Set(recovered?.bindingGenerations);
1266
+ const doStop = async (opts) => {
798
1267
  stopped = true;
799
1268
  // Stop feeding the engine immediately: if the watcher unsubscribe below
800
1269
  // rejects, late observations buffer instead of appending after the lock is
801
1270
  // released. Every step is then best-effort so a single failure never skips
802
- // closing the log or releasing the lock; the first error is rethrown.
803
- live = false;
1271
+ // closing the log or releasing the lock; the first error is rethrown. Under
1272
+ // the git policy capture stays live until git has classified what the
1273
+ // watcher already delivered, so stopping never drops an observed edit.
1274
+ if (scope.policy === 'filesystem')
1275
+ live = false;
804
1276
  let firstError;
805
1277
  const record = (err) => {
806
1278
  if (firstError === undefined)
807
1279
  firstError = err;
808
1280
  };
809
- // Stop the coverage watcher before the producer and log close: it appends
1281
+ // Stop transcript reading before the producer and log close: it appends
810
1282
  // evidence and coverage through both, and an append after close would throw.
811
- await coverageRunner?.stop().catch(record);
1283
+ await unregisterTranscripts?.().catch(record);
812
1284
  await subscription?.close().catch(record);
1285
+ while (classifierRunning)
1286
+ await classifierDone;
1287
+ live = false;
813
1288
  // Await any in-flight recovery: it may be mid-reopen, and appending after
814
1289
  // we release the lock would let a second owner's writes interleave. The
815
1290
  // loop stops starting new attempts once `stopped` is set.
@@ -818,9 +1293,30 @@ export async function startCapture(opts, dependencies = {}) {
818
1293
  // Fence the attribution generation and flush its in-flight appends before the
819
1294
  // log closes: a stale evaluation must never append after the log is gone.
820
1295
  await producer?.stop().catch(record);
821
- await Promise.allSettled([...inflightQuestions.values(), ...inflightAnswers.values()].map(q => q.promise));
1296
+ await Promise.allSettled([...inflightQuestions.values(), ...inflightExchanges.values(), ...inflightRelayUpdates.values(), ...inflightAnswers.values()].map(q => q.promise));
1297
+ // Never manufactured: after a lost lock or during a storage outage the stop
1298
+ // stays unrecorded and stop() rejects, so no caller can report it stopped.
1299
+ if (opts && firstError === undefined && (surrendered || health.snapshot().state !== 'healthy')) {
1300
+ record(new Error(`the stop could not be recorded: capture storage is ${surrendered ? 'owned by another process' : health.snapshot().state}`));
1301
+ }
1302
+ else if (opts && firstError === undefined) {
1303
+ try {
1304
+ if (opts.unlinkGeneration !== undefined) {
1305
+ opts.assertOwnership?.();
1306
+ await appendEvent({ type: 'slipstream.agent.unlinked.v1', occurred_at_ms: now(),
1307
+ data: { binding_generation: opts.unlinkGeneration, reason: opts.reason } });
1308
+ }
1309
+ opts.assertOwnership?.();
1310
+ await appendEvent({ type: 'slipstream.capture.stopped.v1', occurred_at_ms: now(), data: { reason: opts.reason } });
1311
+ }
1312
+ catch (err) {
1313
+ record(err);
1314
+ }
1315
+ }
822
1316
  await underlying.close().catch(record);
823
1317
  await lock.release().catch(record);
1318
+ if (frozenSlip)
1319
+ await rm(frozenSlip.dir, { recursive: true, force: true }).catch(record);
824
1320
  if (firstError !== undefined)
825
1321
  throw firstError;
826
1322
  };
@@ -835,10 +1331,14 @@ export async function startCapture(opts, dependencies = {}) {
835
1331
  health,
836
1332
  beginTask,
837
1333
  askQuestion,
1334
+ recordQuestionExchange,
1335
+ recordQuestionRelayUpdate,
838
1336
  claimQuestion,
839
1337
  answerQuestion,
1338
+ recordAgent,
1339
+ bindingRecorded: (generation) => recordedBindings.has(generation),
840
1340
  ingestEvidence,
841
- stop: () => (stopPromise ??= doStop()),
1341
+ stop: (opts) => (stopPromise ??= doStop(opts)),
842
1342
  };
843
1343
  }
844
1344
  async function enumerate(root, dir, isExcluded, handlers) {