@playola-radio/slipstream 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +132 -0
- package/SKILL.md +63 -0
- package/THIRD-PARTY-NOTICES.md +36 -0
- package/contracts/interface/v2/schema.json +480 -0
- package/dist/answer-forwarder.js +58 -0
- package/dist/attribution-engine.js +164 -0
- package/dist/attribution-producer.js +153 -0
- package/dist/attribution-scoring.js +86 -0
- package/dist/attribution.js +120 -0
- package/dist/blob-source.js +100 -0
- package/dist/boundary-registry.js +47 -0
- package/dist/cas.js +99 -0
- package/dist/change-view.js +201 -0
- package/dist/claude-root-transcript.js +110 -0
- package/dist/cli.js +629 -0
- package/dist/clip-blob-reader.js +104 -0
- package/dist/clip-function-parser.js +83 -0
- package/dist/clip-language.js +15 -0
- package/dist/clip-projection-service.js +240 -0
- package/dist/clip-projection-worker.js +18 -0
- package/dist/clip-projection.js +585 -0
- package/dist/clip-worker-pool.js +118 -0
- package/dist/config.js +154 -0
- package/dist/control-client.js +98 -0
- package/dist/control-protocol.js +72 -0
- package/dist/daemon-location.js +33 -0
- package/dist/daemon.js +951 -0
- package/dist/display-fold.js +332 -0
- package/dist/engine.js +112 -0
- package/dist/entrypoint.js +17 -0
- package/dist/event.js +79 -0
- package/dist/evidence-ingest.js +60 -0
- package/dist/harness-context/claude.js +42 -0
- package/dist/harness-context/codex.js +49 -0
- package/dist/harness-context.js +49 -0
- package/dist/health.js +65 -0
- package/dist/http-reader.js +655 -0
- package/dist/http-security.js +50 -0
- package/dist/interface-projection.js +243 -0
- package/dist/interface-range-resolver.js +230 -0
- package/dist/interface-service.js +509 -0
- package/dist/interface-ts-pool.js +111 -0
- package/dist/interface-ts-worker.js +35 -0
- package/dist/interface-v2-comparison.js +135 -0
- package/dist/interface-v2-core.js +123 -0
- package/dist/interface-v2-typescript.js +448 -0
- package/dist/lock.js +197 -0
- package/dist/log-reader.js +148 -0
- package/dist/log.js +59 -0
- package/dist/maintenance.js +255 -0
- package/dist/mcp-forwarder.js +161 -0
- package/dist/mcp-protocol.js +92 -0
- package/dist/platform.js +21 -0
- package/dist/projection-admission.js +296 -0
- package/dist/projection-trace.js +17 -0
- package/dist/public-events.js +27 -0
- package/dist/question-hook.js +110 -0
- package/dist/questions.js +167 -0
- package/dist/reader-runtime.js +38 -0
- package/dist/reader.js +93 -0
- package/dist/recovery.js +303 -0
- package/dist/schema.js +108 -0
- package/dist/session.js +866 -0
- package/dist/sibling-module.js +10 -0
- package/dist/snapshot.js +15 -0
- package/dist/storage.js +111 -0
- package/dist/store-reader.js +185 -0
- package/dist/swift-grammar.js +268 -0
- package/dist/swift-interface-extract.js +344 -0
- package/dist/swift-interface.js +62 -0
- package/dist/swift-parse-host.js +174 -0
- package/dist/swift-parse-worker.js +34 -0
- package/dist/swift-parse.js +134 -0
- package/dist/swift-spans.js +62 -0
- package/dist/task-forwarder.js +152 -0
- package/dist/transcript/claude.js +199 -0
- package/dist/transcript/codex.js +132 -0
- package/dist/transcript/discovery.js +365 -0
- package/dist/transcript/file-reader.js +128 -0
- package/dist/transcript/fs-io.js +287 -0
- package/dist/transcript/runner.js +62 -0
- package/dist/transcript/steppers.js +30 -0
- package/dist/transcript/types.js +30 -0
- package/dist/transcript/watcher.js +133 -0
- package/dist/tui.js +168 -0
- package/package.json +60 -0
- package/schemas/projections/clip.v1.json +195 -0
- package/schemas/projections/clip.v2.json +212 -0
- package/schemas/projections/clip.v3.json +212 -0
- package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
- package/schemas/slipstream.capture.gap.v1.json +112 -0
- package/schemas/slipstream.change.attribution.v1.json +115 -0
- package/schemas/slipstream.enrichment.configured.v1.json +77 -0
- package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
- package/schemas/slipstream.file.baselined.v1.json +122 -0
- package/schemas/slipstream.file.changed.v1.json +179 -0
- package/schemas/slipstream.harness.evidence.v1.json +145 -0
- package/schemas/slipstream.question.answered.v1.json +29 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
- package/schemas/slipstream.question.queued.v1.json +55 -0
- package/schemas/slipstream.session.resumed.v1.json +70 -0
- package/schemas/slipstream.session.started.v1.json +69 -0
- package/schemas/slipstream.task.started.v1.json +72 -0
package/dist/cas.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { access, open, mkdir, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { DIR_MODE, FILE_MODE, StorageError, assertOwnerOnly, fsyncDir, mkdirpDurable, writeAll, } from "./storage.js";
|
|
5
|
+
export async function createCas(rootDir) {
|
|
6
|
+
const base = join(rootDir, 'sha256');
|
|
7
|
+
await mkdirpDurable(base);
|
|
8
|
+
await assertOwnerOnly(rootDir, 'dir');
|
|
9
|
+
await assertOwnerOnly(base, 'dir');
|
|
10
|
+
/** sha256 -> durably published (temp+fsync+rename+dir-fsync completed this process). */
|
|
11
|
+
const durable = new Set();
|
|
12
|
+
/** shard dirs whose creation has been fsync'd into `base`. */
|
|
13
|
+
const durableShards = new Set();
|
|
14
|
+
/** In-flight publishes, keyed by sha, so concurrent same-hash puts share one write. */
|
|
15
|
+
const inFlight = new Map();
|
|
16
|
+
const pathFor = (sha256) => join(base, sha256.slice(0, 2), sha256);
|
|
17
|
+
const has = async (sha256) => {
|
|
18
|
+
try {
|
|
19
|
+
await access(pathFor(sha256));
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const publish = async (sha256, bytes) => {
|
|
27
|
+
const dest = pathFor(sha256);
|
|
28
|
+
const shard = dirname(dest);
|
|
29
|
+
if (!durableShards.has(shard)) {
|
|
30
|
+
try {
|
|
31
|
+
await mkdir(shard, { recursive: true, mode: DIR_MODE });
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
throw new StorageError('mkdir-shard', err);
|
|
35
|
+
}
|
|
36
|
+
await fsyncDir(base); // persist the new shard directory entry
|
|
37
|
+
durableShards.add(shard);
|
|
38
|
+
}
|
|
39
|
+
const tmp = `${dest}.${randomUUID()}.tmp`;
|
|
40
|
+
let handle;
|
|
41
|
+
try {
|
|
42
|
+
try {
|
|
43
|
+
handle = await open(tmp, 'wx', FILE_MODE);
|
|
44
|
+
await writeAll(handle, bytes);
|
|
45
|
+
await handle.sync();
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new StorageError('write-blob', err);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await handle?.close();
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await rename(tmp, dest);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
throw new StorageError('rename-blob', err);
|
|
58
|
+
}
|
|
59
|
+
await fsyncDir(shard); // persist the rename into the shard directory
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await unlink(tmp).catch(() => { });
|
|
63
|
+
}
|
|
64
|
+
durable.add(sha256);
|
|
65
|
+
return { sha256, size: bytes.length };
|
|
66
|
+
};
|
|
67
|
+
const put = async (bytes) => {
|
|
68
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
69
|
+
if (durable.has(sha256))
|
|
70
|
+
return { sha256, size: bytes.length };
|
|
71
|
+
const existing = inFlight.get(sha256);
|
|
72
|
+
if (existing)
|
|
73
|
+
return existing;
|
|
74
|
+
const pending = publish(sha256, bytes).finally(() => inFlight.delete(sha256));
|
|
75
|
+
inFlight.set(sha256, pending);
|
|
76
|
+
return pending;
|
|
77
|
+
};
|
|
78
|
+
const read = (sha256) => readFile(pathFor(sha256));
|
|
79
|
+
const ensureDurable = async (sha256) => {
|
|
80
|
+
if (durable.has(sha256))
|
|
81
|
+
return;
|
|
82
|
+
const dest = pathFor(sha256);
|
|
83
|
+
let handle;
|
|
84
|
+
try {
|
|
85
|
+
handle = await open(dest, 'r');
|
|
86
|
+
await handle.sync();
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
throw new StorageError('fsync-blob', err);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await handle?.close();
|
|
93
|
+
}
|
|
94
|
+
await fsyncDir(dirname(dest)); // persist the blob's shard entry
|
|
95
|
+
durable.add(sha256);
|
|
96
|
+
};
|
|
97
|
+
return { put, read, has, ensureDurable, pathFor };
|
|
98
|
+
}
|
|
99
|
+
export { StorageError } from "./storage.js";
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Renders a `file.changed` event as the file's *current* content, line-numbered,
|
|
2
|
+
// with an `x` beside each changed line and a blank beside unchanged ones — the
|
|
3
|
+
// after-state, not a diff. Unchanged lines far from any change are elided, with
|
|
4
|
+
// a `⋯` marking each gap.
|
|
5
|
+
import { isValidHex } from "./store-reader.js";
|
|
6
|
+
// Control chars must never reach the terminal verbatim: C0 (0x00–0x1f), DEL, and
|
|
7
|
+
// C1 (0x80–0x9f, e.g. U+009B = CSI) are all replaced.
|
|
8
|
+
const sanitize = (s) => s.replace(/[\x00-\x1f\x7f-\x9f]/g, '�');
|
|
9
|
+
// Above this size a content blob is summarized rather than fetched and printed,
|
|
10
|
+
// unless the caller passes `full`.
|
|
11
|
+
const MAX_RENDER_BYTES = 128 * 1024;
|
|
12
|
+
// Above this before×after line-cell product the O(n·m) alignment is skipped and
|
|
13
|
+
// content is shown unmarked, bounding time and memory even under `full`.
|
|
14
|
+
const MAX_ALIGN_CELLS = 8_000_000;
|
|
15
|
+
/** Split file text into display lines. A trailing newline is a line terminator,
|
|
16
|
+
* not an extra blank line; empty text is zero lines. */
|
|
17
|
+
export function splitLines(text) {
|
|
18
|
+
if (text === '')
|
|
19
|
+
return [];
|
|
20
|
+
const parts = text.split(/\r?\n/);
|
|
21
|
+
if (parts[parts.length - 1] === '')
|
|
22
|
+
parts.pop();
|
|
23
|
+
return parts;
|
|
24
|
+
}
|
|
25
|
+
/** For each after-line, true if it is NOT part of the longest common subsequence
|
|
26
|
+
* with the before-lines — i.e. it is new or replaced rather than carried over. */
|
|
27
|
+
export function markChanges(before, after) {
|
|
28
|
+
const n = before.length;
|
|
29
|
+
const m = after.length;
|
|
30
|
+
const w = m + 1;
|
|
31
|
+
const dp = new Int32Array((n + 1) * w);
|
|
32
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
33
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
34
|
+
dp[i * w + j] = before[i] === after[j]
|
|
35
|
+
? dp[(i + 1) * w + (j + 1)] + 1
|
|
36
|
+
: Math.max(dp[(i + 1) * w + j], dp[i * w + (j + 1)]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const changed = new Array(m).fill(true);
|
|
40
|
+
let i = 0;
|
|
41
|
+
let j = 0;
|
|
42
|
+
while (i < n && j < m) {
|
|
43
|
+
if (before[i] === after[j]) {
|
|
44
|
+
changed[j] = false;
|
|
45
|
+
i++;
|
|
46
|
+
j++;
|
|
47
|
+
}
|
|
48
|
+
else if (dp[(i + 1) * w + j] >= dp[i * w + (j + 1)])
|
|
49
|
+
i++;
|
|
50
|
+
else
|
|
51
|
+
j++;
|
|
52
|
+
}
|
|
53
|
+
return changed;
|
|
54
|
+
}
|
|
55
|
+
/** Render after-content as numbered, marked display lines with `context` lines of
|
|
56
|
+
* unchanged context around each changed run and `⋯` between elided gaps. Returns
|
|
57
|
+
* [] when nothing changed. */
|
|
58
|
+
export function renderMarkedLines(before, after, context) {
|
|
59
|
+
const changed = markChanges(before, after);
|
|
60
|
+
const m = after.length;
|
|
61
|
+
const visible = new Array(m).fill(false);
|
|
62
|
+
for (let k = 0; k < m; k++) {
|
|
63
|
+
if (!changed[k])
|
|
64
|
+
continue;
|
|
65
|
+
const lo = Math.max(0, k - context);
|
|
66
|
+
const hi = Math.min(m - 1, k + context);
|
|
67
|
+
for (let v = lo; v <= hi; v++)
|
|
68
|
+
visible[v] = true;
|
|
69
|
+
}
|
|
70
|
+
const width = String(m).length;
|
|
71
|
+
const out = [];
|
|
72
|
+
let prev = -1;
|
|
73
|
+
for (let k = 0; k < m; k++) {
|
|
74
|
+
if (!visible[k])
|
|
75
|
+
continue;
|
|
76
|
+
if (prev !== -1 && k > prev + 1)
|
|
77
|
+
out.push('⋯');
|
|
78
|
+
out.push(numberedLine(changed[k] ? 'x' : ' ', k + 1, width, after[k]));
|
|
79
|
+
prev = k;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function numberedLine(mark, n, width, text) {
|
|
84
|
+
return `${mark} ${String(n).padStart(width)} ${sanitize(text)}`;
|
|
85
|
+
}
|
|
86
|
+
/** Render every after-line unmarked (used when the before-side can't be compared). */
|
|
87
|
+
function renderUnmarked(after) {
|
|
88
|
+
const width = String(after.length).length;
|
|
89
|
+
return after.map((line, i) => numberedLine(' ', i + 1, width, line));
|
|
90
|
+
}
|
|
91
|
+
function human(bytes) {
|
|
92
|
+
if (bytes < 1024)
|
|
93
|
+
return `${bytes} B`;
|
|
94
|
+
if (bytes < 1024 * 1024)
|
|
95
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
96
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
97
|
+
}
|
|
98
|
+
function parseSnapshot(v) {
|
|
99
|
+
if (typeof v !== 'object' || v === null)
|
|
100
|
+
return { kind: 'invalid' };
|
|
101
|
+
const s = v;
|
|
102
|
+
if (s.kind === 'content') {
|
|
103
|
+
if (typeof s.sha256 === 'string' && isValidHex(s.sha256)
|
|
104
|
+
&& typeof s.size === 'number' && Number.isInteger(s.size) && s.size >= 0) {
|
|
105
|
+
return { kind: 'content', sha256: s.sha256, size: s.size };
|
|
106
|
+
}
|
|
107
|
+
return { kind: 'invalid' };
|
|
108
|
+
}
|
|
109
|
+
if (s.kind === 'absent')
|
|
110
|
+
return { kind: 'absent' };
|
|
111
|
+
if (s.kind === 'unavailable' && typeof s.reason === 'string') {
|
|
112
|
+
return { kind: 'unavailable', reason: s.reason };
|
|
113
|
+
}
|
|
114
|
+
return { kind: 'invalid' };
|
|
115
|
+
}
|
|
116
|
+
function sizeSummary(before, afterSize) {
|
|
117
|
+
const a = human(afterSize);
|
|
118
|
+
if (before.kind === 'content')
|
|
119
|
+
return `${human(before.size)} → ${a}`;
|
|
120
|
+
if (before.kind === 'absent')
|
|
121
|
+
return `new, ${a}`;
|
|
122
|
+
return a;
|
|
123
|
+
}
|
|
124
|
+
/** Render one `file.changed` event as a header line plus either the marked
|
|
125
|
+
* after-content or an honest one-line note about why it isn't shown. */
|
|
126
|
+
export async function renderChange(ev, getText, opts) {
|
|
127
|
+
const path = typeof ev.data.path === 'string' ? sanitize(ev.data.path) : '(path unavailable)';
|
|
128
|
+
// Surface the grouping hint on the header so the changes view has the same task
|
|
129
|
+
// membership the disk/HTTP readers carry in `data` (parity, not a new view).
|
|
130
|
+
const hint = typeof ev.data.task_hint_id === 'string' ? ` · task/${sanitize(ev.data.task_hint_id)}` : '';
|
|
131
|
+
const header = `#${ev.seq.toString()} ${path}${hint}`;
|
|
132
|
+
const note = (msg) => [header, ` ${msg}`];
|
|
133
|
+
const cap = opts.full ? Number.POSITIVE_INFINITY : MAX_RENDER_BYTES;
|
|
134
|
+
const after = parseSnapshot(ev.data.after);
|
|
135
|
+
if (after.kind === 'invalid')
|
|
136
|
+
return note('(snapshot missing or malformed)');
|
|
137
|
+
if (after.kind === 'absent')
|
|
138
|
+
return note('(deleted)');
|
|
139
|
+
if (after.kind === 'unavailable')
|
|
140
|
+
return note(`(content unavailable: ${sanitize(after.reason)})`);
|
|
141
|
+
if (!opts.full && after.size > MAX_RENDER_BYTES) {
|
|
142
|
+
return note(`(large file, ${human(after.size)} — content hidden; rerun with --full)`);
|
|
143
|
+
}
|
|
144
|
+
const afterBlob = await getText(after.sha256, cap);
|
|
145
|
+
if (afterBlob.kind === 'missing')
|
|
146
|
+
return note(`(content unavailable: ${sanitize(afterBlob.reason)})`);
|
|
147
|
+
if (afterBlob.kind === 'oversize') {
|
|
148
|
+
return note(`(large file, ${human(afterBlob.size)} — content hidden; rerun with --full)`);
|
|
149
|
+
}
|
|
150
|
+
if (afterBlob.kind === 'binary')
|
|
151
|
+
return note(`(binary, ${sizeSummary(before(ev), after.size)})`);
|
|
152
|
+
const afterLines = splitLines(afterBlob.text);
|
|
153
|
+
if (afterLines.length === 0)
|
|
154
|
+
return note('(empty file)');
|
|
155
|
+
const beforeSnap = before(ev);
|
|
156
|
+
const beforeRes = await resolveBefore(beforeSnap, getText, opts, cap);
|
|
157
|
+
if ('reason' in beforeRes) {
|
|
158
|
+
if (opts.full)
|
|
159
|
+
return [header, ` (${beforeRes.reason})`, ...renderUnmarked(afterLines)];
|
|
160
|
+
return note(`(${beforeRes.reason}; rerun with --full)`);
|
|
161
|
+
}
|
|
162
|
+
const beforeLines = beforeRes.lines;
|
|
163
|
+
if (beforeLines.length * afterLines.length > MAX_ALIGN_CELLS) {
|
|
164
|
+
if (opts.full)
|
|
165
|
+
return [header, ' (too large to align; showing content unmarked)', ...renderUnmarked(afterLines)];
|
|
166
|
+
return note('(too large to compare line-by-line; rerun with --full)');
|
|
167
|
+
}
|
|
168
|
+
const body = renderMarkedLines(beforeLines, afterLines, opts.context);
|
|
169
|
+
if (body.length === 0) {
|
|
170
|
+
const removed = beforeLines.length - afterLines.length;
|
|
171
|
+
const msg = removed > 0
|
|
172
|
+
? `(${removed} line${removed === 1 ? '' : 's'} removed)`
|
|
173
|
+
: '(no line-level change; whitespace or newline only)';
|
|
174
|
+
if (opts.full)
|
|
175
|
+
return [header, ` ${msg}`, ...renderUnmarked(afterLines)];
|
|
176
|
+
return note(msg);
|
|
177
|
+
}
|
|
178
|
+
// A new file has no baseline, so every line is marked; the note explains why.
|
|
179
|
+
if (beforeSnap.kind === 'absent')
|
|
180
|
+
return [header, ' (new file)', ...body];
|
|
181
|
+
return [header, ...body];
|
|
182
|
+
}
|
|
183
|
+
function before(ev) { return parseSnapshot(ev.data.before); }
|
|
184
|
+
async function resolveBefore(before, getText, opts, cap) {
|
|
185
|
+
if (before.kind === 'absent')
|
|
186
|
+
return { lines: [] };
|
|
187
|
+
if (before.kind === 'unavailable')
|
|
188
|
+
return { reason: `before unavailable: ${sanitize(before.reason)}` };
|
|
189
|
+
if (before.kind !== 'content')
|
|
190
|
+
return { reason: 'before snapshot malformed' };
|
|
191
|
+
if (!opts.full && before.size > MAX_RENDER_BYTES)
|
|
192
|
+
return { reason: 'before too large to compare' };
|
|
193
|
+
const blob = await getText(before.sha256, cap);
|
|
194
|
+
if (blob.kind === 'missing')
|
|
195
|
+
return { reason: `before unavailable: ${sanitize(blob.reason)}` };
|
|
196
|
+
if (blob.kind === 'oversize')
|
|
197
|
+
return { reason: 'before too large to compare' };
|
|
198
|
+
if (blob.kind === 'binary')
|
|
199
|
+
return { reason: 'before was binary' };
|
|
200
|
+
return { lines: splitLines(blob.text) };
|
|
201
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, realpath } from 'node:fs/promises';
|
|
3
|
+
import { HEAD_LINE_CAP, HEAD_SCAN_BYTES, HEAD_SCAN_LINES } from "./transcript/fs-io.js";
|
|
4
|
+
const RUNTIMES = new Map([
|
|
5
|
+
['2.1.280', 'sdk-ts'], // Conductor's observed Claude Code launch
|
|
6
|
+
['2.1.283', 'sdk-cli'], // Terminal's observed Claude Code launch
|
|
7
|
+
]);
|
|
8
|
+
const FATAL_UTF8 = new TextDecoder('utf8', { fatal: true });
|
|
9
|
+
function record(value) {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
11
|
+
? value : null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Verify the explicit root transcript from complete, bounded startup records.
|
|
15
|
+
* Claude can write cwd-less preamble lines before the first user/attachment
|
|
16
|
+
* record, and may still be writing the last line while attach reads. Every
|
|
17
|
+
* complete bounded line is parsed; the first identity record fixes the root
|
|
18
|
+
* cwd and identity, while later records must not conflict with the binding.
|
|
19
|
+
*/
|
|
20
|
+
export async function verifyClaudeRootTranscript(path, sessionId, worktree) {
|
|
21
|
+
let handle;
|
|
22
|
+
try {
|
|
23
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
24
|
+
if (!(await handle.stat()).isFile())
|
|
25
|
+
return { ok: false, reason: 'unavailable' };
|
|
26
|
+
// The extra byte distinguishes an exact bound from a head with more records.
|
|
27
|
+
const bytes = Buffer.alloc(HEAD_SCAN_BYTES + 1);
|
|
28
|
+
let total = 0;
|
|
29
|
+
while (total < bytes.length) {
|
|
30
|
+
const { bytesRead } = await handle.read(bytes, total, bytes.length - total, total);
|
|
31
|
+
if (bytesRead === 0)
|
|
32
|
+
break;
|
|
33
|
+
total += bytesRead;
|
|
34
|
+
}
|
|
35
|
+
let start = 0;
|
|
36
|
+
let lines = 0;
|
|
37
|
+
let version;
|
|
38
|
+
let entrypoint;
|
|
39
|
+
let rootCwd;
|
|
40
|
+
for (let i = 0; i < total && lines < HEAD_SCAN_LINES; i += 1) {
|
|
41
|
+
if (bytes[i] !== 0x0a)
|
|
42
|
+
continue;
|
|
43
|
+
if (i - start > HEAD_LINE_CAP || i >= HEAD_SCAN_BYTES)
|
|
44
|
+
return { ok: false, reason: 'gap' };
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = record(JSON.parse(FATAL_UTF8.decode(bytes.subarray(start, i))));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { ok: false, reason: 'gap' };
|
|
51
|
+
}
|
|
52
|
+
if (!parsed)
|
|
53
|
+
return { ok: false, reason: 'gap' };
|
|
54
|
+
lines += 1;
|
|
55
|
+
start = i + 1;
|
|
56
|
+
if (Object.hasOwn(parsed, 'sessionId') && parsed.sessionId !== sessionId)
|
|
57
|
+
return { ok: false, reason: 'mismatch' };
|
|
58
|
+
const hasIdentityField = Object.hasOwn(parsed, 'cwd') || Object.hasOwn(parsed, 'version')
|
|
59
|
+
|| Object.hasOwn(parsed, 'entrypoint') || Object.hasOwn(parsed, 'isSidechain');
|
|
60
|
+
if (!hasIdentityField)
|
|
61
|
+
continue;
|
|
62
|
+
if (version === undefined) {
|
|
63
|
+
if (parsed.sessionId !== sessionId || typeof parsed.cwd !== 'string' || parsed.cwd.length === 0
|
|
64
|
+
|| typeof parsed.version !== 'string' || parsed.version.length === 0
|
|
65
|
+
|| typeof parsed.entrypoint !== 'string' || parsed.userType !== 'external'
|
|
66
|
+
|| parsed.isSidechain !== false || (parsed.type !== 'user' && parsed.type !== 'attachment')) {
|
|
67
|
+
return { ok: false, reason: 'mismatch' };
|
|
68
|
+
}
|
|
69
|
+
const allowedEntrypoint = RUNTIMES.get(parsed.version);
|
|
70
|
+
if (allowedEntrypoint === undefined)
|
|
71
|
+
return { ok: false, reason: 'unsupported-version' };
|
|
72
|
+
if (parsed.entrypoint !== allowedEntrypoint)
|
|
73
|
+
return { ok: false, reason: 'mismatch' };
|
|
74
|
+
try {
|
|
75
|
+
if (await realpath(parsed.cwd) !== worktree)
|
|
76
|
+
return { ok: false, reason: 'mismatch' };
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { ok: false, reason: 'mismatch' };
|
|
80
|
+
}
|
|
81
|
+
version = parsed.version;
|
|
82
|
+
entrypoint = parsed.entrypoint;
|
|
83
|
+
rootCwd = parsed.cwd;
|
|
84
|
+
}
|
|
85
|
+
else if ((Object.hasOwn(parsed, 'version') && parsed.version !== version)
|
|
86
|
+
|| (Object.hasOwn(parsed, 'entrypoint') && parsed.entrypoint !== entrypoint)
|
|
87
|
+
|| (Object.hasOwn(parsed, 'cwd') && parsed.cwd !== rootCwd)
|
|
88
|
+
|| (Object.hasOwn(parsed, 'userType') && parsed.userType !== 'external')
|
|
89
|
+
|| (Object.hasOwn(parsed, 'isSidechain') && parsed.isSidechain !== false)) {
|
|
90
|
+
return { ok: false, reason: 'mismatch' };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (lines < HEAD_SCAN_LINES && total > HEAD_SCAN_BYTES)
|
|
94
|
+
return { ok: false, reason: 'gap' };
|
|
95
|
+
if (lines < HEAD_SCAN_LINES && total - start > HEAD_LINE_CAP)
|
|
96
|
+
return { ok: false, reason: 'gap' };
|
|
97
|
+
// A torn record inside the bounded head may still be written; attach is retryable.
|
|
98
|
+
if (lines < HEAD_SCAN_LINES && start < total)
|
|
99
|
+
return { ok: false, reason: 'not-yet' };
|
|
100
|
+
if (version === undefined)
|
|
101
|
+
return { ok: false, reason: lines === HEAD_SCAN_LINES ? 'gap' : 'not-yet' };
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return { ok: false, reason: 'unavailable' };
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await handle?.close();
|
|
109
|
+
}
|
|
110
|
+
}
|