@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.
- package/README.md +427 -101
- package/SKILL.md +4 -1
- package/contracts/interface/v2/schema.json +3 -3
- package/dist/attach-workflow.js +557 -0
- package/dist/attribution-engine.js +2 -3
- package/dist/attribution-scoring.js +70 -13
- package/dist/boundary-registry.js +25 -0
- package/dist/capture-intent.js +70 -0
- package/dist/capture-scope.js +135 -0
- package/dist/cas.js +61 -14
- package/dist/claude-root-transcript.js +18 -11
- package/dist/cli.js +426 -26
- package/dist/clip-function-parser.js +9 -2
- package/dist/clip-projection-service.js +28 -0
- package/dist/clip-projection-worker.js +4 -0
- package/dist/clip-worker-pool.js +13 -1
- package/dist/codex-root-transcript.js +46 -0
- package/dist/conductor-settings.js +178 -0
- package/dist/conductor.js +313 -0
- package/dist/control-protocol.js +2 -1
- package/dist/daemon.js +1718 -470
- package/dist/engine.js +26 -10
- package/dist/ensure-daemon.js +30 -0
- package/dist/http-reader.js +20 -8
- package/dist/intent-store.js +196 -0
- package/dist/interface-range-resolver.js +28 -1
- package/dist/launch-agent.js +179 -0
- package/dist/lock.js +1 -1
- package/dist/public-events.js +26 -2
- package/dist/question-hook.js +11 -1
- package/dist/questions.js +102 -3
- package/dist/recovery.js +62 -6
- package/dist/session.js +604 -104
- package/dist/store-reader.js +112 -7
- package/dist/task-forwarder.js +8 -8
- package/dist/transcript/discovery.js +242 -111
- package/dist/transcript/fs-io.js +10 -1
- package/dist/transcript/scheduler.js +114 -0
- package/dist/transcript/watcher.js +5 -3
- package/dist/withdraw-workflow.js +106 -0
- package/dist/workspace.js +133 -0
- package/package.json +3 -2
- package/schemas/slipstream.agent.binding.v1.json +90 -0
- package/schemas/slipstream.agent.connection.v1.json +34 -0
- package/schemas/slipstream.agent.unlinked.v1.json +68 -0
- package/schemas/slipstream.capture.gap.v1.json +1 -0
- package/schemas/slipstream.capture.ready.v1.json +55 -0
- package/schemas/slipstream.capture.scope.v1.json +85 -0
- package/schemas/slipstream.capture.stopped.v1.json +65 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +11 -1
- package/schemas/slipstream.question.exchange_recorded.v1.json +65 -0
- package/schemas/slipstream.question.relay_updated.v1.json +64 -0
- package/schemas/slipstream.session.resumed.v1.json +4 -1
- package/schemas/slipstream.session.started.v1.json +4 -1
- package/dist/transcript/runner.js +0 -62
package/dist/store-reader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
75
|
+
let parsed;
|
|
74
76
|
try {
|
|
75
|
-
|
|
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
|
|
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;
|
package/dist/task-forwarder.js
CHANGED
|
@@ -15,16 +15,16 @@
|
|
|
15
15
|
* {@link shouldAutoResend}); a generic ambiguous request is never blindly
|
|
16
16
|
* retried.
|
|
17
17
|
*
|
|
18
|
-
* KNOWN LIMIT
|
|
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,
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
46
|
-
* an in-root candidate we failed to read rather than a genuine
|
|
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
|
|
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
|
|
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
|
|
113
|
+
return { kind: 'undecidable' }; // uninspectable (e.g. permissions): cannot rule out membership
|
|
113
114
|
}
|
|
114
|
-
return
|
|
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(
|
|
124
|
-
const canonicalCwd = await
|
|
146
|
+
async function classifyCwd(resolver, root, cwd) {
|
|
147
|
+
const canonicalCwd = await resolver.realpath(cwd);
|
|
125
148
|
if (canonicalCwd === undefined) {
|
|
126
|
-
|
|
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
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
+
discloseTo(results, all, {
|
|
312
424
|
kind: 'inaccessible',
|
|
313
425
|
detail: `some codex session subdirectories under ${dir} were unreadable`,
|
|
314
426
|
});
|
|
315
427
|
}
|
|
316
|
-
|
|
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
|
|
434
|
+
const head = await readRemembered(io, memory?.codex, path, () => readCodexHeader(io, path));
|
|
322
435
|
if (!head.ok) {
|
|
323
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
468
|
+
return results;
|
|
360
469
|
}
|
|
361
|
-
|
|
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
|
-
?
|
|
364
|
-
:
|
|
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
|
}
|
package/dist/transcript/fs-io.js
CHANGED
|
@@ -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);
|