@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
@@ -25,14 +25,16 @@ export function tombstonePath(storeDir, id) {
25
25
  export function blobPath(storeDir, hex) {
26
26
  return join(storeDir, 'blobs', 'sha256', hex.slice(0, 2), hex);
27
27
  }
28
- export async function onDiskHighWater(logPath) {
28
+ /** The last complete record's seq, time, type and data; undefined for a missing log or
29
+ * one with no complete line. Torn trailing bytes are ignored. */
30
+ export async function finalRecord(logPath) {
29
31
  let handle;
30
32
  try {
31
33
  handle = await open(logPath, 'r');
32
34
  }
33
35
  catch (err) {
34
36
  if (err.code === 'ENOENT')
35
- return 0n;
37
+ return undefined;
36
38
  throw err;
37
39
  }
38
40
  let lastLine;
@@ -62,7 +64,7 @@ export async function onDiskHighWater(logPath) {
62
64
  break;
63
65
  }
64
66
  if (!foundEnd)
65
- return 0n;
67
+ return undefined;
66
68
  lastLine = Buffer.concat(pieces.reverse()).toString('utf8');
67
69
  }
68
70
  finally {
@@ -70,17 +72,23 @@ export async function onDiskHighWater(logPath) {
70
72
  }
71
73
  if (lastLine === '')
72
74
  throw new LogCorruptError('high-water: blank final record');
73
- let seq;
75
+ let parsed;
74
76
  try {
75
- seq = JSON.parse(lastLine).seq;
77
+ parsed = JSON.parse(lastLine);
76
78
  }
77
79
  catch {
78
80
  throw new LogCorruptError('high-water: invalid JSON in final record');
79
81
  }
80
- if (typeof seq !== 'string' || !SEQ_RE.test(seq)) {
82
+ if (typeof parsed !== 'object' || parsed === null) {
83
+ throw new LogCorruptError('high-water: final record is not an object');
84
+ }
85
+ if (typeof parsed.seq !== 'string' || !SEQ_RE.test(parsed.seq)) {
81
86
  throw new LogCorruptError('high-water: final record has no valid decimal-string seq');
82
87
  }
83
- return BigInt(seq);
88
+ return { seq: BigInt(parsed.seq), time: parsed.time, type: parsed.type, data: parsed.data };
89
+ }
90
+ export async function onDiskHighWater(logPath) {
91
+ return (await finalRecord(logPath))?.seq ?? 0n;
84
92
  }
85
93
  export async function readTombstone(storeDir, id) {
86
94
  try {
@@ -125,6 +133,103 @@ export async function listSessions(storeDir) {
125
133
  out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
126
134
  return out;
127
135
  }
136
+ /** A session recorded for this workspace whose history cannot be read. Attach
137
+ * must fail closed on it rather than start a second stream for the workspace. */
138
+ export class WorkspaceLookupError extends Error {
139
+ sessionId;
140
+ /** Whether the log itself is damaged (or gone), as opposed to a transient read
141
+ * failure such as EMFILE; only corruption justifies advising deletion. */
142
+ corrupt;
143
+ constructor(sessionId, cause) {
144
+ super(`session ${sessionId} records this workspace but its log cannot be read: ${cause.message}`, { cause });
145
+ this.name = 'WorkspaceLookupError';
146
+ this.sessionId = sessionId;
147
+ this.corrupt = cause instanceof LogCorruptError || cause.code === 'ENOENT';
148
+ }
149
+ }
150
+ const FIRST_RECORD_LIMIT = 64 * 1024;
151
+ /** The `root` of a log's first record, which must be `session.started`. */
152
+ async function startedRoot(logPath) {
153
+ // Lookup opens every session's log on every attach; one FIFO must not wedge it.
154
+ const handle = await open(logPath, constants.O_RDONLY | constants.O_NONBLOCK);
155
+ let line;
156
+ try {
157
+ if (!(await handle.stat()).isFile())
158
+ throw new LogCorruptError('log is not a regular file');
159
+ const buffer = Buffer.allocUnsafe(FIRST_RECORD_LIMIT);
160
+ const { bytesRead } = await handle.read(buffer, 0, FIRST_RECORD_LIMIT, 0);
161
+ const end = buffer.subarray(0, bytesRead).indexOf(0x0a);
162
+ if (end < 0)
163
+ throw new LogCorruptError('first record is torn or larger than 64 KiB');
164
+ line = buffer.subarray(0, end).toString('utf8');
165
+ }
166
+ finally {
167
+ await handle.close();
168
+ }
169
+ let parsed;
170
+ try {
171
+ parsed = JSON.parse(line);
172
+ }
173
+ catch {
174
+ throw new LogCorruptError('first record is not valid JSON');
175
+ }
176
+ if (typeof parsed !== 'object' || parsed === null
177
+ || parsed.type !== 'slipstream.session.started.v1' || typeof parsed.data?.root !== 'string') {
178
+ throw new LogCorruptError('first record is not a session.started record');
179
+ }
180
+ return parsed.data.root;
181
+ }
182
+ /** The non-removed session recorded for `canonicalRoot`, derived from the logs
183
+ * alone. Duplicates resolve to the newest last record, then the smallest id. A
184
+ * session whose first record cannot be read is skipped and reported. */
185
+ export async function findWorkspaceSession(storeDir, canonicalRoot) {
186
+ let entries;
187
+ try {
188
+ entries = await readdir(sessionsDir(storeDir));
189
+ }
190
+ catch (err) {
191
+ if (err.code === 'ENOENT')
192
+ return { match: undefined, skipped: [] };
193
+ throw err;
194
+ }
195
+ const skipped = [];
196
+ let best;
197
+ for (const id of entries.filter(isValidSessionId).sort()) {
198
+ if ((await readTombstone(storeDir, id)) !== null)
199
+ continue;
200
+ const logPath = sessionLogPath(storeDir, id);
201
+ let root;
202
+ try {
203
+ root = await startedRoot(logPath);
204
+ }
205
+ catch (err) {
206
+ const unreadable = err instanceof LogCorruptError || err.code === 'ENOENT';
207
+ if (!unreadable)
208
+ throw err;
209
+ skipped.push({ id, reason: err.message });
210
+ continue;
211
+ }
212
+ if (root !== canonicalRoot)
213
+ continue;
214
+ let last;
215
+ let ms;
216
+ try {
217
+ const final = await finalRecord(logPath);
218
+ if (!final)
219
+ throw new LogCorruptError('log has no complete record');
220
+ last = final;
221
+ ms = typeof last.time === 'string' ? Date.parse(last.time) : Number.NaN;
222
+ if (Number.isNaN(ms))
223
+ throw new LogCorruptError('final record has no valid time');
224
+ }
225
+ catch (err) {
226
+ throw new WorkspaceLookupError(id, err);
227
+ }
228
+ if (!best || ms > best.ms)
229
+ best = { id, durableSeq: last.seq, ms };
230
+ }
231
+ return { match: best && { id: best.id, durableSeq: best.durableSeq }, skipped };
232
+ }
128
233
  export async function schemaBytes(type) {
129
234
  if (!EVENT_TYPES.includes(type))
130
235
  return null;
@@ -15,16 +15,16 @@
15
15
  * {@link shouldAutoResend}); a generic ambiguous request is never blindly
16
16
  * retried.
17
17
  *
18
- * KNOWN LIMIT (at-least-once): the forwarder omits session_id (it never
18
+ * KNOWN LIMIT: the forwarder omits session_id (it never
19
19
  * attaches, so it cannot know the capture UUID), so idempotency is scoped to
20
20
  * the selected capture session. If the SAME identity triple is detached and
21
- * reattached between the first send and the resend, the resend lands in a NEW
22
- * capture session whose `committedTasks` does not yet hold this request_id,
23
- * and the task is recorded a second time (in the new session). The success we
24
- * then report is truthful — a task WAS declared — but the intent is recorded
25
- * twice across the two sessions. This is the accepted cost of the locked
26
- * at-least-once auto-resend; closing it needs store-level request_id dedup
27
- * (P3 idempotency scope), which is a design decision, not a forwarder fix.
21
+ * reattached between the first send and the resend, re-attaching resumes the
22
+ * SAME session and rebuilds its request_id index from the log, so the resend
23
+ * replays the first send's result instead of recording the task twice. But
24
+ * a resume deliberately clears the current task (spec D1), and a replay does
25
+ * not reactivate it: the success we report is truthful (the task WAS
26
+ * declared), yet later changes stay ungrouped until the agent declares a
27
+ * task again under a new request_id.
28
28
  *
29
29
  * 2. When the resend cannot definitively resolve the first send, we must not
30
30
  * imply "nothing committed." A structurally-rejected resend (INVALID_TITLE /
@@ -42,8 +42,9 @@ function segmentsOf(path) {
42
42
  return path.split('/').filter((s) => s !== '' && s !== '.');
43
43
  }
44
44
  /**
45
- * True when an UNRESOLVABLE cwd could still sit inside the capture root, so it is
46
- * an in-root candidate we failed to read rather than a genuine non-candidate.
45
+ * Place an UNRESOLVABLE cwd so each capture root can tell whether it could still
46
+ * sit inside it: an in-root candidate we failed to read rather than a genuine
47
+ * non-candidate.
47
48
  *
48
49
  * A textual check is unreliable both ways: the raw string may be in the root's
49
50
  * alias namespace (cwd `/tmp/proj/gone` for canonical root `/private/tmp/proj`)
@@ -67,7 +68,7 @@ function segmentsOf(path) {
67
68
  * Undecidable cases (an uninspectable component, a symlink cycle past the hop
68
69
  * cap) fail toward disclosure: never claim non-membership we did not establish.
69
70
  */
70
- async function unresolvedCwdMayBeInRoot(io, root, cwd) {
71
+ async function placeUnresolvedCwd(io, cwd) {
71
72
  const queue = segmentsOf(cwd);
72
73
  let cursor = 0;
73
74
  let base = '/';
@@ -103,15 +104,37 @@ async function unresolvedCwdMayBeInRoot(io, root, cwd) {
103
104
  // against the target and the target's living parts get canonicalized above.
104
105
  symlinkHops += 1;
105
106
  if (symlinkHops > MAX_UNRESOLVED_HOPS)
106
- return true; // cycle: disclose
107
+ return { kind: 'undecidable' }; // cycle: disclose
107
108
  if (isAbsolute(probe.target))
108
109
  base = '/';
109
110
  queue.splice(cursor, 0, ...segmentsOf(probe.target));
110
111
  continue;
111
112
  }
112
- return true; // uninspectable (e.g. permissions): cannot rule out membership
113
+ return { kind: 'undecidable' }; // uninspectable (e.g. permissions): cannot rule out membership
113
114
  }
114
- return isWithinRoot(root, base);
115
+ return { kind: 'placed', path: base };
116
+ }
117
+ function createCwdResolver(io) {
118
+ const real = new Map();
119
+ const placed = new Map();
120
+ const memoIO = {
121
+ probe: (path) => io.probe(path),
122
+ realpath: (path) => {
123
+ let p = real.get(path);
124
+ if (!p)
125
+ real.set(path, (p = io.realpath(path)));
126
+ return p;
127
+ },
128
+ };
129
+ return {
130
+ realpath: memoIO.realpath,
131
+ placeUnresolved: (cwd) => {
132
+ let p = placed.get(cwd);
133
+ if (!p)
134
+ placed.set(cwd, (p = placeUnresolvedCwd(memoIO, cwd)));
135
+ return p;
136
+ },
137
+ };
115
138
  }
116
139
  /**
117
140
  * Classify a recorded cwd against the capture root, the single membership rule
@@ -120,10 +143,11 @@ async function unresolvedCwdMayBeInRoot(io, root, cwd) {
120
143
  * (Claude) or a shared global sessions dir (Codex) cannot bind another worktree's
121
144
  * session to this root.
122
145
  */
123
- async function classifyCwd(io, root, cwd) {
124
- const canonicalCwd = await io.realpath(cwd);
146
+ async function classifyCwd(resolver, root, cwd) {
147
+ const canonicalCwd = await resolver.realpath(cwd);
125
148
  if (canonicalCwd === undefined) {
126
- return (await unresolvedCwdMayBeInRoot(io, root, cwd))
149
+ const placed = await resolver.placeUnresolved(cwd);
150
+ return placed.kind === 'undecidable' || isWithinRoot(root, placed.path)
127
151
  ? { kind: 'unresolved-in-root' }
128
152
  : { kind: 'out-of-root' };
129
153
  }
@@ -137,7 +161,7 @@ async function classifyCwd(io, root, cwd) {
137
161
  let rootAliases;
138
162
  if (cwd !== canonicalCwd) {
139
163
  const aliasRoot = resolve(cwd, relative(canonicalCwd, root));
140
- if ((await io.realpath(aliasRoot)) === root)
164
+ if ((await resolver.realpath(aliasRoot)) === root)
141
165
  rootAliases = [aliasRoot];
142
166
  }
143
167
  return { kind: 'in-root', cwd: canonicalCwd, ...(rootAliases !== undefined ? { rootAliases } : {}) };
@@ -209,69 +233,147 @@ async function resolveClaudeCwd(io, path, firstLine, firstId) {
209
233
  }
210
234
  return { kind: 'none' };
211
235
  }
212
- export async function discoverClaude(io, home, root) {
213
- const dir = `${home}/projects/${claudeSlug(root)}`;
214
- const listed = await io.listDir(dir);
215
- if (!listed.ok) {
216
- return { bindings: [], issues: [{ kind: listed.reason, detail: `claude project dir ${dir}` }] };
236
+ export function createDiscoveryMemory() {
237
+ return { codex: new Map(), claude: new Map() };
238
+ }
239
+ function sameFile(a, b) {
240
+ return a.dev === b.dev && a.ino === b.ino;
241
+ }
242
+ /**
243
+ * `read`'s result for `path`, or the remembered header if the file is unchanged.
244
+ * A permission change keeps the header: the bytes it came from are unchanged,
245
+ * and a bound transcript that turns unreadable is still disclosed by its reader.
246
+ */
247
+ async function readRemembered(io, memory, path, read) {
248
+ if (memory === undefined)
249
+ return read();
250
+ // Stat BEFORE reading, so the remembered size is never larger than the
251
+ // generation that was actually read.
252
+ const now = await io.stat(path);
253
+ const known = memory.get(path);
254
+ if (now !== undefined && known !== undefined && sameFile(now.id, known.id) && now.size >= known.size) {
255
+ known.size = now.size;
256
+ return { ok: true, header: known.header, id: known.id };
217
257
  }
218
- const bindings = [];
219
- const issues = [];
220
- for (const path of listed.paths) {
221
- const sessionId = baseName(path).slice(0, -'.jsonl'.length);
222
- // The slug directory alone cannot establish membership: two distinct roots can
223
- // collide onto one slug (e.g. /work/a-b and /work/a/b). So a transcript is bound
224
- // ONLY once its recorded cwd has been read and confirmed in-root — it is never
225
- // slug-trusted. Until confirmation:
226
- // - an empty or still-preamble transcript is withheld with no issue (pending
227
- // coverage); it is re-checked next tick once more is written, and no record
228
- // is ingested under an unconfirmed binding (so a colliding sibling's records
229
- // landing before confirmation can never be credited to this root);
230
- // - a transcript whose membership cannot be confirmed (unreadable/oversized or
231
- // truncated head, or an in-root cwd that will not resolve) is disclosed.
232
- const head = await io.readFirstLine(path);
233
- if (!head.ok) {
234
- if (head.reason === 'empty')
235
- continue; // not yet written: withhold, stays pending
236
- // A first line that exists but could not be read (malformed, e.g. invalid
237
- // UTF-8 or an unbounded line; or inaccessible): disclose the gap.
238
- issues.push({ kind: head.reason, detail: `claude transcript ${path}` });
239
- continue;
240
- }
241
- const resolution = await resolveClaudeCwd(io, path, head.line, head.id);
242
- if (resolution.kind === 'none')
243
- continue; // a cwd-less preamble so far: withhold, pending
244
- if (resolution.kind === 'unconfirmed') {
245
- issues.push({
246
- kind: resolution.reason,
247
- detail: `claude transcript ${path} membership unconfirmed: ${resolution.detail}`,
248
- });
258
+ const result = await read();
259
+ if (result.ok && now !== undefined && sameFile(now.id, result.id)) {
260
+ memory.set(path, { id: result.id, size: now.size, header: result.header });
261
+ }
262
+ else {
263
+ memory.delete(path);
264
+ }
265
+ return result;
266
+ }
267
+ function forgetUnlisted(memory, listed) {
268
+ if (memory === undefined)
269
+ return;
270
+ for (const path of memory.keys())
271
+ if (!listed.has(path))
272
+ memory.delete(path);
273
+ }
274
+ /** One result per distinct root, each with its own arrays. */
275
+ function emptyResults(roots) {
276
+ return new Map([...new Set(roots)].map((root) => [root, { bindings: [], issues: [] }]));
277
+ }
278
+ /** Disclose one issue to every root it concerns, each getting its own copy. */
279
+ function discloseTo(results, roots, issue) {
280
+ for (const root of roots)
281
+ results.get(root).issues.push({ ...issue });
282
+ }
283
+ /**
284
+ * Claude discovery for several capture roots in one pass. Each Claude project
285
+ * directory is listed once and each transcript head read once, however many roots
286
+ * slug onto it; the recorded cwd then decides, per root, whether it is a member.
287
+ */
288
+ export async function discoverClaudeRoots(io, home, roots, memory) {
289
+ const results = emptyResults(roots);
290
+ const resolver = createCwdResolver(io);
291
+ const listedPaths = new Set();
292
+ const byDir = new Map();
293
+ for (const root of results.keys()) {
294
+ const dir = `${home}/projects/${claudeSlug(root)}`;
295
+ byDir.set(dir, [...(byDir.get(dir) ?? []), root]);
296
+ }
297
+ for (const [dir, group] of byDir) {
298
+ const listed = await io.listDir(dir);
299
+ if (!listed.ok) {
300
+ discloseTo(results, group, { kind: listed.reason, detail: `claude project dir ${dir}` });
249
301
  continue;
250
302
  }
251
- const cls = await classifyCwd(io, root, resolution.cwd);
252
- if (cls.kind === 'out-of-root')
253
- continue; // a slug-colliding other worktree
254
- if (cls.kind === 'unresolved-in-root') {
255
- issues.push({
256
- kind: 'inaccessible',
257
- detail: `claude transcript ${path} cwd ${resolution.cwd} could not be resolved`,
258
- });
259
- continue;
303
+ for (const path of listed.paths) {
304
+ listedPaths.add(path);
305
+ const sessionId = baseName(path).slice(0, -'.jsonl'.length);
306
+ // The slug directory alone cannot establish membership: two distinct roots can
307
+ // collide onto one slug (e.g. /work/a-b and /work/a/b). So a transcript is bound
308
+ // ONLY once its recorded cwd has been read and confirmed in-root — it is never
309
+ // slug-trusted. Until confirmation:
310
+ // - an empty or still-preamble transcript is withheld with no issue (pending
311
+ // coverage); it is re-checked next tick once more is written, and no record
312
+ // is ingested under an unconfirmed binding (so a colliding sibling's records
313
+ // landing before confirmation can never be credited to this root);
314
+ // - a transcript whose membership cannot be confirmed (unreadable/oversized or
315
+ // truncated head, or an in-root cwd that will not resolve) is disclosed.
316
+ const resolution = await readRemembered(io, memory?.claude, path, () => readClaudeHeader(io, path));
317
+ if (!resolution.ok) {
318
+ if (resolution.issue !== undefined)
319
+ discloseTo(results, group, resolution.issue);
320
+ continue;
321
+ }
322
+ const cwd = resolution.header;
323
+ for (const root of group) {
324
+ const result = results.get(root);
325
+ const cls = await classifyCwd(resolver, root, cwd);
326
+ if (cls.kind === 'out-of-root')
327
+ continue; // a slug-colliding other worktree
328
+ if (cls.kind === 'unresolved-in-root') {
329
+ result.issues.push({
330
+ kind: 'inaccessible',
331
+ detail: `claude transcript ${path} cwd ${cwd} could not be resolved`,
332
+ });
333
+ continue;
334
+ }
335
+ result.bindings.push({
336
+ path,
337
+ ctx: {
338
+ harness: 'claude-code',
339
+ harnessSessionId: sessionId,
340
+ root,
341
+ cwd: cls.cwd,
342
+ adapterVersion: CLAUDE_ADAPTER_VERSION,
343
+ ...(cls.rootAliases !== undefined ? { rootAliases: cls.rootAliases } : {}),
344
+ },
345
+ generation: resolution.id,
346
+ });
347
+ }
260
348
  }
261
- bindings.push({
262
- path,
263
- ctx: {
264
- harness: 'claude-code',
265
- harnessSessionId: sessionId,
266
- root,
267
- cwd: cls.cwd,
268
- adapterVersion: CLAUDE_ADAPTER_VERSION,
269
- ...(cls.rootAliases !== undefined ? { rootAliases: cls.rootAliases } : {}),
270
- },
271
- generation: resolution.id,
272
- });
273
349
  }
274
- return { bindings, issues };
350
+ forgetUnlisted(memory?.claude, listedPaths);
351
+ return results;
352
+ }
353
+ /** A Claude transcript's recorded cwd, or the issue to disclose (none: withhold it
354
+ * as pending, to be re-checked next cycle). */
355
+ async function readClaudeHeader(io, path) {
356
+ const head = await io.readFirstLine(path);
357
+ if (!head.ok) {
358
+ if (head.reason === 'empty')
359
+ return { ok: false }; // not yet written: withhold, stays pending
360
+ // A first line that exists but could not be read (malformed, e.g. invalid
361
+ // UTF-8 or an unbounded line; or inaccessible): disclose the gap.
362
+ return { ok: false, issue: { kind: head.reason, detail: `claude transcript ${path}` } };
363
+ }
364
+ const resolution = await resolveClaudeCwd(io, path, head.line, head.id);
365
+ if (resolution.kind === 'none')
366
+ return { ok: false }; // a cwd-less preamble so far: withhold, pending
367
+ if (resolution.kind === 'unconfirmed') {
368
+ return {
369
+ ok: false,
370
+ issue: { kind: resolution.reason, detail: `claude transcript ${path} membership unconfirmed: ${resolution.detail}` },
371
+ };
372
+ }
373
+ return { ok: true, header: resolution.cwd, id: resolution.id };
374
+ }
375
+ export async function discoverClaude(io, home, root) {
376
+ return (await discoverClaudeRoots(io, home, [root])).get(root);
275
377
  }
276
378
  function parseSessionMeta(line) {
277
379
  try {
@@ -293,73 +395,102 @@ function parseSessionMeta(line) {
293
395
  return undefined;
294
396
  }
295
397
  }
296
- export async function discoverCodex(io, home, root, limit) {
398
+ /**
399
+ * Codex discovery for several capture roots in one pass. The global sessions tree
400
+ * is enumerated once and each rollout's `session_meta` read once; its cwd then
401
+ * decides, per root, whether it is a member. A scan-wide gap (the file cap, an
402
+ * unreadable subdirectory, a rollout we cannot read) could hide any root's
403
+ * session, so it is disclosed to every root, exactly as a lone scan would.
404
+ */
405
+ export async function discoverCodexRoots(io, home, roots, limit, memory) {
406
+ const results = emptyResults(roots);
407
+ const all = [...results.keys()];
297
408
  const dir = `${home}/sessions`;
298
409
  const listed = await io.listTreeJsonl(dir, limit);
299
410
  if ('ok' in listed && listed.ok === false) {
300
- return { bindings: [], issues: [{ kind: listed.reason, detail: `codex sessions dir ${dir}` }] };
411
+ memory?.codex.clear();
412
+ discloseTo(results, all, { kind: listed.reason, detail: `codex sessions dir ${dir}` });
413
+ return results;
301
414
  }
302
415
  const { paths, truncated, incomplete } = listed;
303
- const issues = [];
304
416
  if (truncated) {
305
- issues.push({
417
+ discloseTo(results, all, {
306
418
  kind: 'discovery-limited',
307
419
  detail: `codex session scan hit the ${limit}-file cap; some transcripts may be unread`,
308
420
  });
309
421
  }
310
422
  if (incomplete) {
311
- issues.push({
423
+ discloseTo(results, all, {
312
424
  kind: 'inaccessible',
313
425
  detail: `some codex session subdirectories under ${dir} were unreadable`,
314
426
  });
315
427
  }
316
- const bindings = [];
428
+ forgetUnlisted(memory?.codex, new Set(paths));
429
+ const resolver = createCwdResolver(io);
317
430
  for (const path of paths) {
318
431
  // A file we cannot read or parse is a candidate we cannot rule out, so it is
319
432
  // disclosed as an issue — never silently skipped (which would let a readable
320
433
  // sibling conceal it and report coverage as clean).
321
- const head = await io.readFirstLine(path);
434
+ const head = await readRemembered(io, memory?.codex, path, () => readCodexHeader(io, path));
322
435
  if (!head.ok) {
323
- issues.push({
324
- kind: head.reason === 'inaccessible' ? 'inaccessible' : 'malformed',
325
- detail: `codex rollout ${path}`,
326
- });
327
- continue;
328
- }
329
- const meta = parseSessionMeta(head.line);
330
- if (!meta) {
331
- issues.push({ kind: 'malformed', detail: `codex rollout ${path} has no session_meta` });
436
+ discloseTo(results, all, head.issue);
332
437
  continue;
333
438
  }
334
- const cls = await classifyCwd(io, root, meta.cwd);
335
- if (cls.kind === 'out-of-root')
336
- continue;
337
- if (cls.kind === 'unresolved-in-root') {
338
- // An in-root candidate we failed to read: disclose the gap rather than let a
339
- // readable sibling report clean coverage. A cwd whose nearest living ancestor
340
- // is outside the root is a different worktree's session, skipped silently.
341
- issues.push({ kind: 'inaccessible', detail: `codex rollout ${path} cwd ${meta.cwd} could not be resolved` });
342
- continue;
439
+ const meta = head.header;
440
+ for (const root of all) {
441
+ const result = results.get(root);
442
+ const cls = await classifyCwd(resolver, root, meta.cwd);
443
+ if (cls.kind === 'out-of-root')
444
+ continue;
445
+ if (cls.kind === 'unresolved-in-root') {
446
+ // An in-root candidate we failed to read: disclose the gap rather than let a
447
+ // readable sibling report clean coverage. A cwd whose nearest living ancestor
448
+ // is outside the root is a different worktree's session, skipped silently.
449
+ result.issues.push({ kind: 'inaccessible', detail: `codex rollout ${path} cwd ${meta.cwd} could not be resolved` });
450
+ continue;
451
+ }
452
+ result.bindings.push({
453
+ path,
454
+ ctx: {
455
+ harness: 'codex',
456
+ harnessSessionId: meta.id,
457
+ root,
458
+ cwd: cls.cwd,
459
+ adapterVersion: CODEX_ADAPTER_VERSION,
460
+ ...(cls.rootAliases !== undefined ? { rootAliases: cls.rootAliases } : {}),
461
+ },
462
+ // The Codex ctx derives entirely from line 1 (session_meta), so the generation
463
+ // is the first-line read that produced it.
464
+ generation: head.id,
465
+ });
343
466
  }
344
- bindings.push({
345
- path,
346
- ctx: {
347
- harness: 'codex',
348
- harnessSessionId: meta.id,
349
- root,
350
- cwd: cls.cwd,
351
- adapterVersion: CODEX_ADAPTER_VERSION,
352
- ...(cls.rootAliases !== undefined ? { rootAliases: cls.rootAliases } : {}),
353
- },
354
- // The Codex ctx derives entirely from line 1 (session_meta), so the generation
355
- // is the first-line read that produced it.
356
- generation: head.id,
357
- });
358
467
  }
359
- return { bindings, issues };
468
+ return results;
360
469
  }
361
- export async function discover(harness, io, home, root, codexScanLimit) {
470
+ /** A Codex rollout's `session_meta`, or the issue to disclose. */
471
+ async function readCodexHeader(io, path) {
472
+ const head = await io.readFirstLine(path);
473
+ if (!head.ok) {
474
+ return {
475
+ ok: false,
476
+ issue: { kind: head.reason === 'inaccessible' ? 'inaccessible' : 'malformed', detail: `codex rollout ${path}` },
477
+ };
478
+ }
479
+ const meta = parseSessionMeta(head.line);
480
+ if (!meta)
481
+ return { ok: false, issue: { kind: 'malformed', detail: `codex rollout ${path} has no session_meta` } };
482
+ return { ok: true, header: meta, id: head.id };
483
+ }
484
+ export async function discoverCodex(io, home, root, limit) {
485
+ return (await discoverCodexRoots(io, home, [root], limit)).get(root);
486
+ }
487
+ /** One harness's discovery for every given capture root, sharing enumeration and
488
+ * header reads across them, and with `memory`, across cycles. */
489
+ export async function discoverRoots(harness, io, home, roots, codexScanLimit, memory) {
362
490
  return harness === 'claude-code'
363
- ? discoverClaude(io, home, root)
364
- : discoverCodex(io, home, root, codexScanLimit);
491
+ ? discoverClaudeRoots(io, home, roots, memory)
492
+ : discoverCodexRoots(io, home, roots, codexScanLimit, memory);
493
+ }
494
+ export async function discover(harness, io, home, root, codexScanLimit) {
495
+ return (await discoverRoots(harness, io, home, [root], codexScanLimit)).get(root);
365
496
  }
@@ -1,4 +1,4 @@
1
- import { lstat as fsLstat, open, readdir, readlink as fsReadlink, realpath as fsRealpath, } from 'node:fs/promises';
1
+ import { lstat as fsLstat, open, readdir, readlink as fsReadlink, realpath as fsRealpath, stat as fsStat, } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  /** The real filesystem IO for transcript files. Missing → `missing`; any other
4
4
  * open/read error → `inaccessible` (disclosed, never a silent empty read). The
@@ -253,6 +253,15 @@ export const nodeDiscoveryIO = {
253
253
  await handle.close();
254
254
  }
255
255
  },
256
+ async stat(path) {
257
+ try {
258
+ const s = await fsStat(path, { bigint: true });
259
+ return { id: { dev: s.dev, ino: s.ino }, size: Number(s.size) };
260
+ }
261
+ catch {
262
+ return undefined;
263
+ }
264
+ },
256
265
  async realpath(path) {
257
266
  try {
258
267
  return await fsRealpath(path);