@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.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +132 -0
  3. package/SKILL.md +63 -0
  4. package/THIRD-PARTY-NOTICES.md +36 -0
  5. package/contracts/interface/v2/schema.json +480 -0
  6. package/dist/answer-forwarder.js +58 -0
  7. package/dist/attribution-engine.js +164 -0
  8. package/dist/attribution-producer.js +153 -0
  9. package/dist/attribution-scoring.js +86 -0
  10. package/dist/attribution.js +120 -0
  11. package/dist/blob-source.js +100 -0
  12. package/dist/boundary-registry.js +47 -0
  13. package/dist/cas.js +99 -0
  14. package/dist/change-view.js +201 -0
  15. package/dist/claude-root-transcript.js +110 -0
  16. package/dist/cli.js +629 -0
  17. package/dist/clip-blob-reader.js +104 -0
  18. package/dist/clip-function-parser.js +83 -0
  19. package/dist/clip-language.js +15 -0
  20. package/dist/clip-projection-service.js +240 -0
  21. package/dist/clip-projection-worker.js +18 -0
  22. package/dist/clip-projection.js +585 -0
  23. package/dist/clip-worker-pool.js +118 -0
  24. package/dist/config.js +154 -0
  25. package/dist/control-client.js +98 -0
  26. package/dist/control-protocol.js +72 -0
  27. package/dist/daemon-location.js +33 -0
  28. package/dist/daemon.js +951 -0
  29. package/dist/display-fold.js +332 -0
  30. package/dist/engine.js +112 -0
  31. package/dist/entrypoint.js +17 -0
  32. package/dist/event.js +79 -0
  33. package/dist/evidence-ingest.js +60 -0
  34. package/dist/harness-context/claude.js +42 -0
  35. package/dist/harness-context/codex.js +49 -0
  36. package/dist/harness-context.js +49 -0
  37. package/dist/health.js +65 -0
  38. package/dist/http-reader.js +655 -0
  39. package/dist/http-security.js +50 -0
  40. package/dist/interface-projection.js +243 -0
  41. package/dist/interface-range-resolver.js +230 -0
  42. package/dist/interface-service.js +509 -0
  43. package/dist/interface-ts-pool.js +111 -0
  44. package/dist/interface-ts-worker.js +35 -0
  45. package/dist/interface-v2-comparison.js +135 -0
  46. package/dist/interface-v2-core.js +123 -0
  47. package/dist/interface-v2-typescript.js +448 -0
  48. package/dist/lock.js +197 -0
  49. package/dist/log-reader.js +148 -0
  50. package/dist/log.js +59 -0
  51. package/dist/maintenance.js +255 -0
  52. package/dist/mcp-forwarder.js +161 -0
  53. package/dist/mcp-protocol.js +92 -0
  54. package/dist/platform.js +21 -0
  55. package/dist/projection-admission.js +296 -0
  56. package/dist/projection-trace.js +17 -0
  57. package/dist/public-events.js +27 -0
  58. package/dist/question-hook.js +110 -0
  59. package/dist/questions.js +167 -0
  60. package/dist/reader-runtime.js +38 -0
  61. package/dist/reader.js +93 -0
  62. package/dist/recovery.js +303 -0
  63. package/dist/schema.js +108 -0
  64. package/dist/session.js +866 -0
  65. package/dist/sibling-module.js +10 -0
  66. package/dist/snapshot.js +15 -0
  67. package/dist/storage.js +111 -0
  68. package/dist/store-reader.js +185 -0
  69. package/dist/swift-grammar.js +268 -0
  70. package/dist/swift-interface-extract.js +344 -0
  71. package/dist/swift-interface.js +62 -0
  72. package/dist/swift-parse-host.js +174 -0
  73. package/dist/swift-parse-worker.js +34 -0
  74. package/dist/swift-parse.js +134 -0
  75. package/dist/swift-spans.js +62 -0
  76. package/dist/task-forwarder.js +152 -0
  77. package/dist/transcript/claude.js +199 -0
  78. package/dist/transcript/codex.js +132 -0
  79. package/dist/transcript/discovery.js +365 -0
  80. package/dist/transcript/file-reader.js +128 -0
  81. package/dist/transcript/fs-io.js +287 -0
  82. package/dist/transcript/runner.js +62 -0
  83. package/dist/transcript/steppers.js +30 -0
  84. package/dist/transcript/types.js +30 -0
  85. package/dist/transcript/watcher.js +133 -0
  86. package/dist/tui.js +168 -0
  87. package/package.json +60 -0
  88. package/schemas/projections/clip.v1.json +195 -0
  89. package/schemas/projections/clip.v2.json +212 -0
  90. package/schemas/projections/clip.v3.json +212 -0
  91. package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
  92. package/schemas/slipstream.capture.gap.v1.json +112 -0
  93. package/schemas/slipstream.change.attribution.v1.json +115 -0
  94. package/schemas/slipstream.enrichment.configured.v1.json +77 -0
  95. package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
  96. package/schemas/slipstream.file.baselined.v1.json +122 -0
  97. package/schemas/slipstream.file.changed.v1.json +179 -0
  98. package/schemas/slipstream.harness.evidence.v1.json +145 -0
  99. package/schemas/slipstream.question.answered.v1.json +29 -0
  100. package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
  101. package/schemas/slipstream.question.queued.v1.json +55 -0
  102. package/schemas/slipstream.session.resumed.v1.json +70 -0
  103. package/schemas/slipstream.session.started.v1.json +69 -0
  104. 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
+ }