@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { scopeFromPaths } from "./types.js";
|
|
2
|
+
export function initialCodexState() {
|
|
3
|
+
return { pending: new Map() };
|
|
4
|
+
}
|
|
5
|
+
function isObject(v) {
|
|
6
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
7
|
+
}
|
|
8
|
+
function parseMs(record) {
|
|
9
|
+
const ts = record.timestamp;
|
|
10
|
+
if (typeof ts !== 'string')
|
|
11
|
+
return undefined;
|
|
12
|
+
const ms = Date.parse(ts);
|
|
13
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
14
|
+
}
|
|
15
|
+
const PATCH_HEADER = /^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/;
|
|
16
|
+
/** Extract every file path an apply_patch envelope names, in order. */
|
|
17
|
+
function applyPatchPaths(patch) {
|
|
18
|
+
const paths = [];
|
|
19
|
+
for (const line of patch.split('\n')) {
|
|
20
|
+
const m = PATCH_HEADER.exec(line.trimEnd());
|
|
21
|
+
if (m)
|
|
22
|
+
paths.push(m[1].trim());
|
|
23
|
+
}
|
|
24
|
+
return paths;
|
|
25
|
+
}
|
|
26
|
+
function makeEvidence(ctx, recordId, toolName, scope, atMs, basis) {
|
|
27
|
+
return {
|
|
28
|
+
evidence_key: {
|
|
29
|
+
harness: ctx.harness,
|
|
30
|
+
harness_session_id: ctx.harnessSessionId,
|
|
31
|
+
record_id: recordId,
|
|
32
|
+
},
|
|
33
|
+
adapter_version: ctx.adapterVersion,
|
|
34
|
+
tool_name: toolName,
|
|
35
|
+
timestamp: { at_ms: atMs, basis },
|
|
36
|
+
file_scope: scope,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The scope a Codex call declares, or `skip` when it is entirely out of root. */
|
|
40
|
+
function scopeForCall(payloadType, name, payload, ctx) {
|
|
41
|
+
if (payloadType === 'custom_tool_call' && name === 'apply_patch') {
|
|
42
|
+
const input = payload.input;
|
|
43
|
+
if (typeof input !== 'string') {
|
|
44
|
+
return {
|
|
45
|
+
scope: { kind: 'unknown', reason: 'apply_patch input was not text' },
|
|
46
|
+
diagnostics: [{ kind: 'malformed', detail: 'apply_patch without a text patch' }],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const paths = applyPatchPaths(input);
|
|
50
|
+
if (paths.length === 0) {
|
|
51
|
+
return {
|
|
52
|
+
scope: { kind: 'unknown', reason: 'apply_patch named no files' },
|
|
53
|
+
diagnostics: [{ kind: 'malformed', detail: 'apply_patch envelope named no files' }],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const scope = scopeFromPaths(paths, ctx);
|
|
57
|
+
if (!scope)
|
|
58
|
+
return 'skip';
|
|
59
|
+
return { scope, diagnostics: [] };
|
|
60
|
+
}
|
|
61
|
+
// A shell command or any unrecognized custom tool: a possible writer we cannot
|
|
62
|
+
// scope from its structured fields.
|
|
63
|
+
return {
|
|
64
|
+
scope: { kind: 'unknown', reason: `unmapped ${payloadType} ${name}` },
|
|
65
|
+
diagnostics: [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function stepCall(state, record, payload, ctx) {
|
|
69
|
+
const callId = payload.call_id;
|
|
70
|
+
const name = payload.name;
|
|
71
|
+
const payloadType = payload.type;
|
|
72
|
+
// A missing/empty call_id is not a stable identity we can key: unsupported, never
|
|
73
|
+
// a fabricated or empty key that would collide across unrelated calls.
|
|
74
|
+
if (typeof callId !== 'string' || callId.length === 0) {
|
|
75
|
+
return { state, evidence: [], diagnostics: [{ kind: 'unsupported', detail: 'tool call without a stable call_id' }] };
|
|
76
|
+
}
|
|
77
|
+
if (typeof name !== 'string' || name.length === 0 || typeof payloadType !== 'string') {
|
|
78
|
+
return { state, evidence: [], diagnostics: [{ kind: 'malformed', detail: `call ${callId} without a name/type` }] };
|
|
79
|
+
}
|
|
80
|
+
const resolved = scopeForCall(payloadType, name, payload, ctx);
|
|
81
|
+
if (resolved === 'skip')
|
|
82
|
+
return { state, evidence: [], diagnostics: [] };
|
|
83
|
+
const atMs = parseMs(record);
|
|
84
|
+
if (atMs === undefined) {
|
|
85
|
+
return { state, evidence: [], diagnostics: [{ kind: 'malformed', detail: `call ${callId} has no usable timestamp` }] };
|
|
86
|
+
}
|
|
87
|
+
state.pending.set(callId, { toolName: name, scope: resolved.scope });
|
|
88
|
+
return {
|
|
89
|
+
state,
|
|
90
|
+
evidence: [makeEvidence(ctx, callId, name, resolved.scope, atMs, 'tool-start')],
|
|
91
|
+
diagnostics: resolved.diagnostics,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function stepOutput(state, record, payload, ctx) {
|
|
95
|
+
const callId = payload.call_id;
|
|
96
|
+
if (typeof callId !== 'string')
|
|
97
|
+
return { state, evidence: [], diagnostics: [] };
|
|
98
|
+
const memo = state.pending.get(callId);
|
|
99
|
+
if (!memo)
|
|
100
|
+
return { state, evidence: [], diagnostics: [] };
|
|
101
|
+
const atMs = parseMs(record);
|
|
102
|
+
// A matched output with an unusable timestamp is malformed, not a clean read:
|
|
103
|
+
// disclose it (as the call side does) rather than silently drop the endpoint.
|
|
104
|
+
if (atMs === undefined) {
|
|
105
|
+
return { state, evidence: [], diagnostics: [{ kind: 'malformed', detail: `call ${callId} output has no usable timestamp` }] };
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
state,
|
|
109
|
+
evidence: [makeEvidence(ctx, callId, memo.toolName, memo.scope, atMs, 'tool-end')],
|
|
110
|
+
diagnostics: [],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Fold one parsed Codex rollout record into the running adapter state. */
|
|
114
|
+
export function codexStep(state, record, ctx) {
|
|
115
|
+
if (!isObject(record)) {
|
|
116
|
+
return { state, evidence: [], diagnostics: [{ kind: 'malformed', detail: 'record is not an object' }] };
|
|
117
|
+
}
|
|
118
|
+
if (record.type !== 'response_item')
|
|
119
|
+
return { state, evidence: [], diagnostics: [] };
|
|
120
|
+
const payload = record.payload;
|
|
121
|
+
if (!isObject(payload) || typeof payload.type !== 'string') {
|
|
122
|
+
// A response_item always carries a typed payload; a null/absent/untyped one is
|
|
123
|
+
// a malformed envelope, disclosed so it stays distinct from a clean read.
|
|
124
|
+
return { state, evidence: [], diagnostics: [{ kind: 'malformed', detail: 'response_item without a typed payload' }] };
|
|
125
|
+
}
|
|
126
|
+
const t = payload.type;
|
|
127
|
+
if (t === 'function_call' || t === 'custom_tool_call')
|
|
128
|
+
return stepCall(state, record, payload, ctx);
|
|
129
|
+
if (t === 'function_call_output' || t === 'custom_tool_call_output')
|
|
130
|
+
return stepOutput(state, record, payload, ctx);
|
|
131
|
+
return { state, evidence: [], diagnostics: [] };
|
|
132
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript discovery: find every transcript that may hold evidence for one
|
|
3
|
+
* captured worktree, honestly.
|
|
4
|
+
*
|
|
5
|
+
* Two harness layouts:
|
|
6
|
+
* - Claude Code stores `<home>/projects/<slug>/<sessionId>.jsonl`, where the slug
|
|
7
|
+
* is the worktree path with '/'→'-'. The slug directory IS the worktree binding,
|
|
8
|
+
* so membership is the cwd filter; each file name is the harness session id.
|
|
9
|
+
* - Codex stores date-partitioned rollouts NOT organized by worktree, so we scan
|
|
10
|
+
* and filter by the canonicalized `session_meta.cwd` being inside the root. mtime
|
|
11
|
+
* is never an eligibility boundary (an old session can resume). A bounded scan
|
|
12
|
+
* discloses `discovery-limited` rather than pretend the search was exhaustive.
|
|
13
|
+
*
|
|
14
|
+
* "Different harness sessions in the same worktree" are all legitimate candidates
|
|
15
|
+
* (Stage 3: two agents in one worktree are both captured); each keeps its own
|
|
16
|
+
* native session id, so their invocation identities never collide.
|
|
17
|
+
*/
|
|
18
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
19
|
+
export const CLAUDE_ADAPTER_VERSION = 'claude-code/1';
|
|
20
|
+
export const CODEX_ADAPTER_VERSION = 'codex/1';
|
|
21
|
+
/** Claude's on-disk slug for a worktree: the absolute path with '/'→'-'. */
|
|
22
|
+
export function claudeSlug(root) {
|
|
23
|
+
return root.replace(/\//g, '-');
|
|
24
|
+
}
|
|
25
|
+
function baseName(path) {
|
|
26
|
+
const i = path.lastIndexOf('/');
|
|
27
|
+
return i >= 0 ? path.slice(i + 1) : path;
|
|
28
|
+
}
|
|
29
|
+
/** True when `child` is `root` itself or nested inside it (same canonical space). */
|
|
30
|
+
export function isWithinRoot(root, child) {
|
|
31
|
+
if (child === root)
|
|
32
|
+
return true;
|
|
33
|
+
const rel = relative(root, child);
|
|
34
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && rel[0] !== '/';
|
|
35
|
+
}
|
|
36
|
+
/** A generous bound on symlink hops while degrading a broken cwd, defeating a
|
|
37
|
+
* symlink cycle. Beyond it, a cwd is too pathological to place cheaply, so we
|
|
38
|
+
* disclose it rather than silently drop what might be an in-root candidate. */
|
|
39
|
+
const MAX_UNRESOLVED_HOPS = 64;
|
|
40
|
+
/** Path segments, dropping empty and `.` parts. */
|
|
41
|
+
function segmentsOf(path) {
|
|
42
|
+
return path.split('/').filter((s) => s !== '' && s !== '.');
|
|
43
|
+
}
|
|
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.
|
|
47
|
+
*
|
|
48
|
+
* A textual check is unreliable both ways: the raw string may be in the root's
|
|
49
|
+
* alias namespace (cwd `/tmp/proj/gone` for canonical root `/private/tmp/proj`)
|
|
50
|
+
* yet be in-root, or textually in-root yet escape through an internal symlink
|
|
51
|
+
* (`/work/proj/link/gone` where `link` -> `/other/project`). So we resolve the
|
|
52
|
+
* cwd the way `realpath` would — but `realpath` on the whole string fails because
|
|
53
|
+
* of the deleted tail. Instead we walk it component by component and let the
|
|
54
|
+
* KERNEL canonicalize every component that still exists: `realpath` on a living
|
|
55
|
+
* component resolves symlinks, case-insensitive names, and unicode normalization
|
|
56
|
+
* exactly, rather than reimplementing those rules. We keep a canonical prefix
|
|
57
|
+
* `base`: for each component, `realpath(base/seg)` supplies its true form; only
|
|
58
|
+
* when that component does NOT resolve do we fall back to `probe`. A truly
|
|
59
|
+
* nonexistent component contains no symlinks below it, so a `..` after it is
|
|
60
|
+
* resolved lexically (exact); a `..` can also pop back above a gap into living
|
|
61
|
+
* space, which the continued walk re-canonicalizes. A component that exists only
|
|
62
|
+
* as a broken symlink is followed by splicing its literal target, so any `..`
|
|
63
|
+
* after it is evaluated against the target, not the link's own parent.
|
|
64
|
+
*
|
|
65
|
+
* A cwd that places outside the root is a different worktree's session, skipped
|
|
66
|
+
* silently so unrelated dead sessions never degrade this root's coverage.
|
|
67
|
+
* Undecidable cases (an uninspectable component, a symlink cycle past the hop
|
|
68
|
+
* cap) fail toward disclosure: never claim non-membership we did not establish.
|
|
69
|
+
*/
|
|
70
|
+
async function unresolvedCwdMayBeInRoot(io, root, cwd) {
|
|
71
|
+
const queue = segmentsOf(cwd);
|
|
72
|
+
let cursor = 0;
|
|
73
|
+
let base = '/';
|
|
74
|
+
let symlinkHops = 0;
|
|
75
|
+
while (cursor < queue.length) {
|
|
76
|
+
const seg = queue[cursor];
|
|
77
|
+
cursor += 1;
|
|
78
|
+
if (seg === '..') {
|
|
79
|
+
base = dirname(base); // popping a canonical prefix is exact
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const candidate = base === '/' ? `/${seg}` : `${base}/${seg}`;
|
|
83
|
+
// Let the kernel canonicalize any component that still resolves (dir, file, or
|
|
84
|
+
// a symlink whose target resolves): this handles symlinks, case-insensitive
|
|
85
|
+
// names, and unicode normalization exactly instead of reimplementing them.
|
|
86
|
+
const canonical = await io.realpath(candidate);
|
|
87
|
+
if (canonical !== undefined) {
|
|
88
|
+
base = canonical;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// The component does not fully resolve; distinguish the reasons.
|
|
92
|
+
const probe = await io.probe(candidate);
|
|
93
|
+
if (probe.kind === 'absent') {
|
|
94
|
+
// Confirmed nonexistent (ENOENT/ENOTDIR): no symlinks live below it, so
|
|
95
|
+
// advancing the prefix lexically is exact. A later `..` may still pop back
|
|
96
|
+
// above this gap into living space, which the continued walk re-resolves.
|
|
97
|
+
base = candidate;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (probe.kind === 'symlink') {
|
|
101
|
+
// A symlink whose target does not resolve (dangling, or reaching through
|
|
102
|
+
// deleted space). Follow its literal target so a `..` after it is evaluated
|
|
103
|
+
// against the target and the target's living parts get canonicalized above.
|
|
104
|
+
symlinkHops += 1;
|
|
105
|
+
if (symlinkHops > MAX_UNRESOLVED_HOPS)
|
|
106
|
+
return true; // cycle: disclose
|
|
107
|
+
if (isAbsolute(probe.target))
|
|
108
|
+
base = '/';
|
|
109
|
+
queue.splice(cursor, 0, ...segmentsOf(probe.target));
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
return true; // uninspectable (e.g. permissions): cannot rule out membership
|
|
113
|
+
}
|
|
114
|
+
return isWithinRoot(root, base);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Classify a recorded cwd against the capture root, the single membership rule
|
|
118
|
+
* shared by both harnesses. The recorded cwd — never the on-disk location — is
|
|
119
|
+
* what establishes which worktree a transcript belongs to, so a slug collision
|
|
120
|
+
* (Claude) or a shared global sessions dir (Codex) cannot bind another worktree's
|
|
121
|
+
* session to this root.
|
|
122
|
+
*/
|
|
123
|
+
async function classifyCwd(io, root, cwd) {
|
|
124
|
+
const canonicalCwd = await io.realpath(cwd);
|
|
125
|
+
if (canonicalCwd === undefined) {
|
|
126
|
+
return (await unresolvedCwdMayBeInRoot(io, root, cwd))
|
|
127
|
+
? { kind: 'unresolved-in-root' }
|
|
128
|
+
: { kind: 'out-of-root' };
|
|
129
|
+
}
|
|
130
|
+
if (!isWithinRoot(root, canonicalCwd))
|
|
131
|
+
return { kind: 'out-of-root' };
|
|
132
|
+
// If the recorded cwd is a non-canonical alias reached through a symlinked
|
|
133
|
+
// ANCESTOR, its root form relativizes a record's absolute paths the same way
|
|
134
|
+
// capture's canonical root does. Only trust an alias root that canonicalizes
|
|
135
|
+
// back to the capture root: a leaf symlink (e.g. /links/alias -> root/pkg)
|
|
136
|
+
// yields a bogus ancestor (/links) that would mis-scope or invent evidence.
|
|
137
|
+
let rootAliases;
|
|
138
|
+
if (cwd !== canonicalCwd) {
|
|
139
|
+
const aliasRoot = resolve(cwd, relative(canonicalCwd, root));
|
|
140
|
+
if ((await io.realpath(aliasRoot)) === root)
|
|
141
|
+
rootAliases = [aliasRoot];
|
|
142
|
+
}
|
|
143
|
+
return { kind: 'in-root', cwd: canonicalCwd, ...(rootAliases !== undefined ? { rootAliases } : {}) };
|
|
144
|
+
}
|
|
145
|
+
function readLineCwd(line) {
|
|
146
|
+
let obj;
|
|
147
|
+
try {
|
|
148
|
+
obj = JSON.parse(line);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return { kind: 'malformed' };
|
|
152
|
+
}
|
|
153
|
+
// A valid JSON scalar or `null` parses cleanly but is not a cwd-bearing record;
|
|
154
|
+
// it carries no cwd, it is not malformed. Guard before property access so a bare
|
|
155
|
+
// `null` line cannot throw past the catch and abort the whole discovery tick.
|
|
156
|
+
if (typeof obj !== 'object' || obj === null)
|
|
157
|
+
return { kind: 'no-cwd' };
|
|
158
|
+
const cwd = obj.cwd;
|
|
159
|
+
return typeof cwd === 'string' && cwd.length > 0 ? { kind: 'cwd', cwd } : { kind: 'no-cwd' };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Search a Claude transcript for the working directory it declares. A session's
|
|
163
|
+
* first records are a cwd-less preamble (ai-title, queue-operation, attachments),
|
|
164
|
+
* so the first line rarely carries the cwd; scan a bounded head for the first
|
|
165
|
+
* record that does.
|
|
166
|
+
*
|
|
167
|
+
* `none` means the searched head was read cleanly and genuinely holds no cwd yet —
|
|
168
|
+
* an empty or still-preamble transcript — which the caller withholds (pending) and
|
|
169
|
+
* re-checks once more is written. `unconfirmed` means a cwd could exist but the
|
|
170
|
+
* head could NOT be searched through to rule it out — the scan hit its budget
|
|
171
|
+
* (truncated), a line was skipped (oversized/undecodable) or malformed, or the head
|
|
172
|
+
* was unreadable — so membership cannot be trusted; the caller discloses it rather
|
|
173
|
+
* than treat it as a clean preamble or slug-trust a candidate that could belong to
|
|
174
|
+
* a slug-colliding sibling worktree.
|
|
175
|
+
*/
|
|
176
|
+
async function resolveClaudeCwd(io, path, firstLine, firstId) {
|
|
177
|
+
const first = readLineCwd(firstLine);
|
|
178
|
+
// The cwd came off the first line: pin to that read's generation (`firstId`).
|
|
179
|
+
if (first.kind === 'cwd')
|
|
180
|
+
return { kind: 'found', cwd: first.cwd, id: firstId };
|
|
181
|
+
let sawMalformed = first.kind === 'malformed';
|
|
182
|
+
const head = await io.readHeadLines(path);
|
|
183
|
+
// The first line already read, so the file is non-empty; an unreadable or
|
|
184
|
+
// raced-to-empty head here leaves membership unconfirmable.
|
|
185
|
+
if (!head.ok)
|
|
186
|
+
return { kind: 'unconfirmed', reason: 'inaccessible', detail: 'the head could not be read' };
|
|
187
|
+
for (const line of head.lines) {
|
|
188
|
+
const parsed = readLineCwd(line);
|
|
189
|
+
// The cwd came off the head scan: pin to the head read's generation, the exact
|
|
190
|
+
// bytes this cwd was decoded from (not the earlier first-line read, which may
|
|
191
|
+
// predate a replacement the head then read through).
|
|
192
|
+
if (parsed.kind === 'cwd')
|
|
193
|
+
return { kind: 'found', cwd: parsed.cwd, id: head.id };
|
|
194
|
+
if (parsed.kind === 'malformed')
|
|
195
|
+
sawMalformed = true;
|
|
196
|
+
}
|
|
197
|
+
// No cwd in the searched head. Only a head that was read through cleanly (every
|
|
198
|
+
// line examined, none skipped, none malformed, nothing beyond the window) is a
|
|
199
|
+
// genuine still-preamble transcript; anything else is a gap we must disclose so a
|
|
200
|
+
// cwd we could not read is never mistaken for a cwd that is not there yet.
|
|
201
|
+
if (head.truncated) {
|
|
202
|
+
return { kind: 'unconfirmed', reason: 'inaccessible', detail: 'a cwd may lie beyond the truncated head' };
|
|
203
|
+
}
|
|
204
|
+
if (head.skipped) {
|
|
205
|
+
return { kind: 'unconfirmed', reason: 'inaccessible', detail: 'an oversized or undecodable head line may carry the cwd' };
|
|
206
|
+
}
|
|
207
|
+
if (sawMalformed) {
|
|
208
|
+
return { kind: 'unconfirmed', reason: 'malformed', detail: 'a malformed head line may carry the cwd' };
|
|
209
|
+
}
|
|
210
|
+
return { kind: 'none' };
|
|
211
|
+
}
|
|
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}` }] };
|
|
217
|
+
}
|
|
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
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
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;
|
|
260
|
+
}
|
|
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
|
+
}
|
|
274
|
+
return { bindings, issues };
|
|
275
|
+
}
|
|
276
|
+
function parseSessionMeta(line) {
|
|
277
|
+
try {
|
|
278
|
+
const obj = JSON.parse(line);
|
|
279
|
+
if (obj.type !== 'session_meta')
|
|
280
|
+
return undefined;
|
|
281
|
+
const payload = obj.payload;
|
|
282
|
+
if (typeof payload !== 'object' || payload === null)
|
|
283
|
+
return undefined;
|
|
284
|
+
const p = payload;
|
|
285
|
+
// An empty id or cwd is not a usable session identity/scope: keying evidence
|
|
286
|
+
// under "" would collapse unrelated sessions, so treat it as malformed (undefined).
|
|
287
|
+
if (typeof p.id !== 'string' || p.id.length === 0 || typeof p.cwd !== 'string' || p.cwd.length === 0) {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
return { id: p.id, cwd: p.cwd };
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
export async function discoverCodex(io, home, root, limit) {
|
|
297
|
+
const dir = `${home}/sessions`;
|
|
298
|
+
const listed = await io.listTreeJsonl(dir, limit);
|
|
299
|
+
if ('ok' in listed && listed.ok === false) {
|
|
300
|
+
return { bindings: [], issues: [{ kind: listed.reason, detail: `codex sessions dir ${dir}` }] };
|
|
301
|
+
}
|
|
302
|
+
const { paths, truncated, incomplete } = listed;
|
|
303
|
+
const issues = [];
|
|
304
|
+
if (truncated) {
|
|
305
|
+
issues.push({
|
|
306
|
+
kind: 'discovery-limited',
|
|
307
|
+
detail: `codex session scan hit the ${limit}-file cap; some transcripts may be unread`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (incomplete) {
|
|
311
|
+
issues.push({
|
|
312
|
+
kind: 'inaccessible',
|
|
313
|
+
detail: `some codex session subdirectories under ${dir} were unreadable`,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
const bindings = [];
|
|
317
|
+
for (const path of paths) {
|
|
318
|
+
// A file we cannot read or parse is a candidate we cannot rule out, so it is
|
|
319
|
+
// disclosed as an issue — never silently skipped (which would let a readable
|
|
320
|
+
// sibling conceal it and report coverage as clean).
|
|
321
|
+
const head = await io.readFirstLine(path);
|
|
322
|
+
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` });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
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;
|
|
343
|
+
}
|
|
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
|
+
}
|
|
359
|
+
return { bindings, issues };
|
|
360
|
+
}
|
|
361
|
+
export async function discover(harness, io, home, root, codexScanLimit) {
|
|
362
|
+
return harness === 'claude-code'
|
|
363
|
+
? discoverClaude(io, home, root)
|
|
364
|
+
: discoverCodex(io, home, root, codexScanLimit);
|
|
365
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const READ_CHUNK_BYTES = 1 << 20; // 1 MiB
|
|
2
|
+
// A fatal decoder so an invalid-UTF-8 line is rejected rather than silently decoded
|
|
3
|
+
// to replacement characters (which could fabricate a path). Reused across polls;
|
|
4
|
+
// safe because each call is a one-shot decode (no streaming state).
|
|
5
|
+
const LINE_DECODER = new TextDecoder('utf8', { fatal: true });
|
|
6
|
+
export function createTranscriptFileReader(opts) {
|
|
7
|
+
const { path, io, sink, stepper, generation } = opts;
|
|
8
|
+
const readChunkBytes = opts.readChunkBytes ?? READ_CHUNK_BYTES;
|
|
9
|
+
let offset = 0;
|
|
10
|
+
let lastSize = 0;
|
|
11
|
+
let issues = [];
|
|
12
|
+
const rereadFromZero = () => {
|
|
13
|
+
offset = 0;
|
|
14
|
+
issues = [];
|
|
15
|
+
stepper.reset();
|
|
16
|
+
};
|
|
17
|
+
// Ingest every record for one line; false means a retryable stop (advance held).
|
|
18
|
+
const ingestLine = async (records) => {
|
|
19
|
+
for (const record of records) {
|
|
20
|
+
let outcome;
|
|
21
|
+
try {
|
|
22
|
+
outcome = await sink.ingest(record);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false; // append failure — retry the whole line next poll
|
|
26
|
+
}
|
|
27
|
+
if (outcome.status === 'rejected')
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
// The (dev, ino) of the bytes `rd` supplied is not the pinned generation: the file
|
|
33
|
+
// at this path was replaced (an atomic rename-into-place) with content discovery has
|
|
34
|
+
// NOT re-confirmed. Because the id and the bytes came from the SAME handle, this
|
|
35
|
+
// catches a replace at any instant up to the read — there is no stat-then-open window
|
|
36
|
+
// through which a replacement's bytes could be read while a prior generation's id was
|
|
37
|
+
// validated. Ingesting them would credit the new file's records to the prior
|
|
38
|
+
// generation's session and scope, so refuse: report `unconfirmed`, offset untouched.
|
|
39
|
+
// The next discovery tick re-derives the binding for the new generation and, if it
|
|
40
|
+
// too is a confirmed in-root member, recreates this reader against it.
|
|
41
|
+
const isPinned = (rd) => rd.id.dev === generation.dev && rd.id.ino === generation.ino;
|
|
42
|
+
// Returns true if backpressure held the offset at a line (retry next poll).
|
|
43
|
+
const ingestBuffer = async (buf) => {
|
|
44
|
+
// Split on newline BYTES, not decoded characters: invalid UTF-8 decodes to
|
|
45
|
+
// a 3-byte replacement char, so measuring a decoded line's length would
|
|
46
|
+
// advance the cursor past bytes that were never there and skip live records.
|
|
47
|
+
// Bytes after the last newline are an incomplete line, left for a later poll.
|
|
48
|
+
let lineStart = 0;
|
|
49
|
+
for (let i = 0; i < buf.length; i++) {
|
|
50
|
+
if (buf[i] !== 0x0a)
|
|
51
|
+
continue;
|
|
52
|
+
const lineBytes = i - lineStart + 1; // through the newline
|
|
53
|
+
const slice = buf.subarray(lineStart, i);
|
|
54
|
+
lineStart = i + 1;
|
|
55
|
+
let decoded;
|
|
56
|
+
try {
|
|
57
|
+
decoded = LINE_DECODER.decode(slice);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Invalid UTF-8: decoding it lossily would coin replacement characters and
|
|
61
|
+
// could fabricate a path, so disclose it malformed and advance past it.
|
|
62
|
+
issues.push({ kind: 'malformed', detail: 'transcript line was not valid UTF-8' });
|
|
63
|
+
offset += lineBytes;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const trimmed = decoded.trim();
|
|
67
|
+
if (trimmed.length > 0) {
|
|
68
|
+
let record;
|
|
69
|
+
try {
|
|
70
|
+
record = JSON.parse(trimmed);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
issues.push({ kind: 'malformed', detail: 'transcript line was not valid JSON' });
|
|
74
|
+
offset += lineBytes;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const out = stepper.step(record);
|
|
78
|
+
if (out.diagnostics.length > 0)
|
|
79
|
+
issues.push(...out.diagnostics);
|
|
80
|
+
if (out.evidence.length > 0) {
|
|
81
|
+
const ok = await ingestLine(out.evidence);
|
|
82
|
+
if (!ok)
|
|
83
|
+
return true; // backpressure: hold offset at this line
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
offset += lineBytes;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
};
|
|
90
|
+
const poll = async () => {
|
|
91
|
+
let backpressured = false;
|
|
92
|
+
let readSize = readChunkBytes;
|
|
93
|
+
for (;;) {
|
|
94
|
+
const rd = await io.readFrom(path, offset, readSize);
|
|
95
|
+
if (!rd.ok) {
|
|
96
|
+
return { state: rd.reason === 'missing' ? 'missing' : 'inaccessible', issues, backpressured };
|
|
97
|
+
}
|
|
98
|
+
if (!isPinned(rd)) {
|
|
99
|
+
return { state: 'unconfirmed', issues, backpressured: false };
|
|
100
|
+
}
|
|
101
|
+
if (rd.size < lastSize) {
|
|
102
|
+
rereadFromZero();
|
|
103
|
+
lastSize = 0;
|
|
104
|
+
readSize = readChunkBytes;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
lastSize = rd.size;
|
|
108
|
+
const chunkStart = offset;
|
|
109
|
+
if (await ingestBuffer(rd.bytes)) {
|
|
110
|
+
backpressured = true;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
if (offset >= rd.size)
|
|
114
|
+
break; // drained to EOF
|
|
115
|
+
if (offset > chunkStart) {
|
|
116
|
+
readSize = readChunkBytes;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// No complete line consumed this read (offset === chunkStart).
|
|
120
|
+
if (chunkStart + rd.bytes.length >= rd.size)
|
|
121
|
+
break; // unterminated tail: hold
|
|
122
|
+
readSize = rd.size - offset; // one line exceeds the window: read it whole next
|
|
123
|
+
}
|
|
124
|
+
const state = issues.length > 0 ? 'degraded' : 'readable';
|
|
125
|
+
return { state, issues, backpressured };
|
|
126
|
+
};
|
|
127
|
+
return { poll };
|
|
128
|
+
}
|