@diffin/cli 0.0.0-stage → 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.
@@ -0,0 +1,178 @@
1
+ // Antigravity CLI adapter. Same contract as the others — detect / enumerate / tsOf / currentSession.
2
+ //
3
+ // A conversation is kept twice under ~/.gemini/antigravity-cli: as protobuf rows in conversations/<id>.db, and as a
4
+ // plain log, brain/<id>/.system_generated/logs/transcript_full.jsonl, one JSON row per step (read 2026-10-04, CLI
5
+ // 1.2.16). The plain log is what is read: it holds the same steps with their times, and reading the protobuf would mean
6
+ // carrying Antigravity's schema. `transcript.jsonl` beside it is the same log with long fields CUT (`truncated_fields`),
7
+ // so it is only the fallback.
8
+ //
9
+ // Which conversations exist, where each was made and whose child it is come from conversation_summaries.db — the
10
+ // index Antigravity itself lists conversations from. A sub-agent is a conversation of its own with a
11
+ // `parent_conversation_id`; it rides under its parent. Without that index a sub-agent's first step — its parent's
12
+ // instruction to it, stored as a user step — would be recorded as something the person said, so when the index cannot
13
+ // be read nothing is offered at all.
14
+ //
15
+ // What is taken from a row: the person's step with Antigravity's wrapper removed (the text sits in <USER_REQUEST>,
16
+ // beside an <ADDITIONAL_METADATA> block the person did not type), the agent's reply, and of a tool call its name and
17
+ // the one argument SCAN reads (a command, a file path, a query). Tool output (`GENERIC` rows) and `thinking` are
18
+ // never put into a row.
19
+ import { createReadStream, existsSync, statSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { createInterface } from 'node:readline';
24
+ import { projectDirOf } from './project-dir.mjs';
25
+
26
+ const ROOT = () => join(process.env.DIFFIN_HOME ?? homedir(), '.gemini', 'antigravity-cli');
27
+ const INDEX = () => join(ROOT(), 'conversation_summaries.db');
28
+ const logOf = (id) => {
29
+ const dir = join(ROOT(), 'brain', id, '.system_generated', 'logs');
30
+ const full = join(dir, 'transcript_full.jsonl');
31
+ return existsSync(full) ? full : join(dir, 'transcript.jsonl');
32
+ };
33
+
34
+ let _sqlite = null;
35
+ function sqlite() {
36
+ if (_sqlite) return _sqlite;
37
+ // node:sqlite prints an ExperimentalWarning nobody can act on (see hermes.mjs): drop that one warning only
38
+ const orig = process.emitWarning.bind(process);
39
+ process.emitWarning = (warning, ...rest) => {
40
+ const type = rest[0]?.type ?? rest[0];
41
+ const msg = typeof warning === 'string' ? warning : warning?.message ?? '';
42
+ if ((type === 'ExperimentalWarning' || warning?.name === 'ExperimentalWarning') && /SQLite/i.test(msg)) return;
43
+ return orig(warning, ...rest);
44
+ };
45
+ _sqlite = process.getBuiltinModule ? process.getBuiltinModule('node:sqlite') : null;
46
+ if (!_sqlite) throw new Error('node:sqlite unavailable (node < 22.5)');
47
+ return _sqlite;
48
+ }
49
+
50
+ const folderOf = (uris) => {
51
+ try { const u = JSON.parse(String(uris ?? '[]'))[0]; return u ? (String(u).startsWith('file:') ? fileURLToPath(u) : String(u)) : ''; } catch { return ''; }
52
+ };
53
+
54
+ /** every conversation the index knows: { id, cwd, parent, last } — or null when the index cannot be read */
55
+ function conversations() {
56
+ if (!existsSync(INDEX())) return null;
57
+ let d;
58
+ try { d = new (sqlite().DatabaseSync)(INDEX(), { readOnly: true }); } catch { return null; }
59
+ try {
60
+ return d.prepare('SELECT conversation_id, workspace_uris, parent_conversation_id, last_modified_time FROM conversation_summaries').all().map((r) => {
61
+ const id = String(r.conversation_id);
62
+ // the index is written when a turn ends; the log is written as it goes — whichever moved last
63
+ let last = Date.parse(String(r.last_modified_time ?? '').replace(' ', 'T')) || 0;
64
+ try { last = Math.max(last, statSync(logOf(id)).mtimeMs); } catch { /* no log yet */ }
65
+ return { id, cwd: folderOf(r.workspace_uris), parent: r.parent_conversation_id ? String(r.parent_conversation_id) : null, last };
66
+ }).filter((c) => /^[0-9a-f-]{8,64}$/i.test(c.id));
67
+ } catch { return null; }
68
+ finally { try { d.close(); } catch { /* closing */ } }
69
+ }
70
+
71
+ /** what the person typed, out of the wrapper Antigravity stores it in */
72
+ export function typedText(content) {
73
+ const s = String(content ?? '');
74
+ const a = s.indexOf('<USER_REQUEST>'), b = s.lastIndexOf('</USER_REQUEST>');
75
+ if (a >= 0 && b > a) return s.slice(a + 14, b).trim();
76
+ // no wrapper: the text as it is, without a metadata block if one stands after it
77
+ const m = s.indexOf('<ADDITIONAL_METADATA>');
78
+ return (m >= 0 ? s.slice(0, m) : s).trim();
79
+ }
80
+
81
+ // an argument is sometimes stored as a JSON string of the string
82
+ const arg = (v) => { const s = String(v ?? ''); if (s.length > 1 && s.startsWith('"') && s.endsWith('"')) { try { return String(JSON.parse(s)); } catch { /* as it is */ } } return s; };
83
+
84
+ /** Antigravity's tool call → the marker name SCAN reads, with the one argument it reads; a tool with none is dropped */
85
+ function mapTool(t) {
86
+ const a = t?.args && typeof t.args === 'object' ? t.args : {};
87
+ switch (t?.name) {
88
+ case 'run_command': return { name: 'Bash', input: { command: arg(a.CommandLine) } };
89
+ case 'write_to_file': return { name: 'Write', input: { file_path: arg(a.TargetFile) } };
90
+ case 'replace_file_content': case 'multi_replace_file_content': return { name: 'Edit', input: { file_path: arg(a.TargetFile) } };
91
+ case 'search_web': return { name: 'WebSearch', input: { query: arg(a.query) } };
92
+ case 'read_url_content': return { name: 'WebFetch', input: { url: arg(a.Url ?? a.url) } };
93
+ default: return null;
94
+ }
95
+ }
96
+
97
+ /** one conversation's log → Claude Code-shaped rows */
98
+ async function* conversationRows(id, cwd, { sidechain = false } = {}) {
99
+ const path = logOf(id);
100
+ if (!existsSync(path)) return;
101
+ const rl = createInterface({ input: createReadStream(path, 'utf8'), crlfDelay: Infinity });
102
+ for await (const line of rl) {
103
+ if (!line.trim()) continue;
104
+ let r;
105
+ try { r = JSON.parse(line); } catch { continue; } // the last line of a live log may be torn
106
+ const base = { uuid: `ag:${id}:${r.step_index}`, timestamp: r.created_at, cwd, ...(sidechain ? { isSidechain: true } : {}) };
107
+ if (r.type === 'USER_INPUT') {
108
+ // only what the person sent: a step Antigravity put there on its own carries another source
109
+ if (r.source !== 'USER_EXPLICIT') continue;
110
+ const text = typedText(r.content);
111
+ if (text) yield { type: 'user', ...base, message: { role: 'user', content: text } };
112
+ } else if (r.type === 'PLANNER_RESPONSE') {
113
+ const blocks = [];
114
+ if (typeof r.content === 'string' && r.content.trim()) blocks.push({ type: 'text', text: r.content });
115
+ (Array.isArray(r.tool_calls) ? r.tool_calls : []).forEach((t, k) => {
116
+ const m = mapTool(t);
117
+ if (m && Object.values(m.input)[0]) blocks.push({ type: 'tool_use', id: `${r.step_index}:${k}`, name: m.name, input: m.input });
118
+ });
119
+ if (!blocks.length) continue;
120
+ const num = (v) => (typeof v === 'number' ? v : undefined);
121
+ const usage = { input_tokens: num(r.input_tokens), output_tokens: num(r.output_tokens), cache_read_input_tokens: num(r.cache_read_tokens) };
122
+ yield { type: 'assistant', ...base, message: { role: 'assistant', content: blocks, ...(Object.values(usage).some((v) => v !== undefined) ? { usage } : {}) } };
123
+ }
124
+ // GENERIC rows are tool output; `thinking` is on the row and never copied
125
+ }
126
+ }
127
+
128
+ const rootOf = (c, byId) => { let cur = c; for (let i = 0; i < 8 && cur?.parent; i++) cur = byId.get(cur.parent); return cur && !cur.parent ? cur : null; };
129
+
130
+ export const antigravity = {
131
+ name: 'antigravity',
132
+
133
+ detect() {
134
+ const all = conversations();
135
+ if (!all) return null;
136
+ const n = all.filter((c) => !c.parent && existsSync(logOf(c.id))).length;
137
+ return { present: n > 0, sessions: n };
138
+ },
139
+
140
+ enumerate({ from }) {
141
+ const all = conversations();
142
+ if (!all) return [];
143
+ const fromMs = new Date(from).getTime();
144
+ const byId = new Map(all.map((c) => [c.id, c]));
145
+ const kids = new Map();
146
+ for (const c of all) { if (!c.parent) continue; const root = rootOf(c, byId); if (root) kids.set(root.id, [...(kids.get(root.id) ?? []), c]); }
147
+ return all.filter((c) => !c.parent && existsSync(logOf(c.id))).map((c) => {
148
+ const subs = (kids.get(c.id) ?? []).filter((s) => existsSync(logOf(s.id)));
149
+ return {
150
+ sid: 'ag-' + c.id,
151
+ projectDir: projectDirOf(c.cwd),
152
+ files: [
153
+ { path: 'main', rows: () => conversationRows(c.id, c.cwd) },
154
+ ...subs.map((s) => ({ path: `subagents/${s.id}.jsonl`, rows: () => conversationRows(s.id, s.cwd || c.cwd, { sidechain: true }) })),
155
+ ],
156
+ last: Math.max(c.last, ...subs.map((s) => s.last)),
157
+ cwd: c.cwd,
158
+ };
159
+ }).filter((e) => e.last >= fromMs);
160
+ },
161
+
162
+ tsOf(r) { return r?.timestamp ? new Date(r.timestamp).getTime() : 0; },
163
+
164
+ /**
165
+ * The conversation a capture is for: the one Antigravity names in ANTIGRAVITY_CONVERSATION_ID (set for every
166
+ * command it runs) — or its parent, when that is a sub-agent — else the conversation of this folder that moved last.
167
+ */
168
+ currentSession(cwd = process.cwd(), env = process.env) {
169
+ const all = conversations();
170
+ if (!all) return null;
171
+ const byId = new Map(all.map((c) => [c.id, c]));
172
+ const named = env.ANTIGRAVITY_CONVERSATION_ID && byId.get(String(env.ANTIGRAVITY_CONVERSATION_ID));
173
+ const root = named ? rootOf(named, byId) : null;
174
+ if (root) return { sid: 'ag-' + root.id, id: root.id, cwd: root.cwd, how: 'env' };
175
+ const here = all.filter((c) => !c.parent && c.cwd === cwd).sort((a, b) => b.last - a.last)[0];
176
+ return here ? { sid: 'ag-' + here.id, id: here.id, cwd: here.cwd, how: 'newest' } : null;
177
+ },
178
+ };
@@ -0,0 +1,107 @@
1
+ // Claude Code adapter. Sessions are <root>/<encoded-project-dir>/<sid>.jsonl, with
2
+ // subagent transcripts under <sid>/subagents/**.jsonl. Every row carries an ISO
3
+ // `timestamp`, which is the time key the range cut uses — so a long-running session
4
+ // contributes only its turns inside the window, not all-or-nothing on its start time.
5
+ import { closeSync, createReadStream, existsSync, fstatSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { createInterface } from 'node:readline';
9
+
10
+ const ROOT = join(process.env.DIFFIN_HOME ?? homedir(), '.claude', 'projects');
11
+
12
+ function projectDirs() {
13
+ try {
14
+ return readdirSync(ROOT, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
15
+ } catch { return []; }
16
+ }
17
+
18
+ /**
19
+ * The folder a session was last in, read from the END of its transcript: a session
20
+ * keeps writing to the project dir it began in after the person cd's elsewhere, so the
21
+ * first row's folder is where it started, not where it is. The last 256KB are enough —
22
+ * every row carries its cwd — and a torn first line of the chunk simply fails to parse.
23
+ */
24
+ function tailCwd(path) {
25
+ let fd;
26
+ try {
27
+ fd = openSync(path, 'r');
28
+ const size = fstatSync(fd).size;
29
+ const len = Math.min(size, 256 * 1024);
30
+ const buf = Buffer.alloc(len);
31
+ readSync(fd, buf, 0, len, size - len);
32
+ const lines = buf.toString('utf8').split('\n');
33
+ for (let i = lines.length - 1; i >= 0; i--) {
34
+ if (!lines[i].includes('"cwd"')) continue;
35
+ try { const r = JSON.parse(lines[i]); if (r?.cwd) return String(r.cwd); } catch { /* torn */ }
36
+ }
37
+ return null;
38
+ } catch { return null; }
39
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* closing */ } }
40
+ }
41
+
42
+ // rows of one jsonl file, one at a time; a torn line is skipped (the pipeline's scan
43
+ // skips it too, so shipping it would only spend bytes)
44
+ async function* fileRows(path) {
45
+ const rl = createInterface({ input: createReadStream(path, 'utf8'), crlfDelay: Infinity });
46
+ for await (const line of rl) {
47
+ if (!line.trim()) continue;
48
+ try { yield JSON.parse(line); } catch { /* torn */ }
49
+ }
50
+ }
51
+
52
+ export const claudeCode = {
53
+ name: 'cc',
54
+
55
+ detect() {
56
+ if (!existsSync(ROOT)) return null;
57
+ let sessions = 0;
58
+ for (const d of projectDirs()) {
59
+ try { sessions += readdirSync(join(ROOT, d)).filter((f) => f.endsWith('.jsonl') && !f.startsWith('._')).length; }
60
+ catch { /* skip */ }
61
+ }
62
+ return { present: sessions > 0, sessions };
63
+ },
64
+
65
+ // A session is a candidate if its file was modified within the window — a cheap
66
+ // mtime prefilter. The per-row cut then keeps only in-window rows, so a session that
67
+ // started long ago but was touched inside the window still counts.
68
+ // Each entry lists its files, each with a rows() thunk — nothing is read here.
69
+ enumerate({ from }) {
70
+ const fromMs = new Date(from).getTime();
71
+ const out = [];
72
+ for (const projectDir of projectDirs()) {
73
+ const abs = join(ROOT, projectDir);
74
+ let names;
75
+ try { names = readdirSync(abs); } catch { continue; }
76
+ for (const f of names) {
77
+ if (!f.endsWith('.jsonl') || f.startsWith('._')) continue;
78
+ const path = join(abs, f);
79
+ let mtime = 0;
80
+ try { mtime = statSync(path).mtimeMs; } catch { continue; }
81
+ if (mtime < fromMs) continue;
82
+ const sid = f.replace(/\.jsonl$/, '');
83
+ const files = [{ path: 'main', rows: () => fileRows(path) }];
84
+ const subDir = join(abs, sid, 'subagents');
85
+ if (existsSync(subDir)) {
86
+ for (const p of walkJsonl(subDir)) {
87
+ files.push({ path: `subagents/${p.slice(subDir.length + 1)}`, rows: () => fileRows(p) });
88
+ }
89
+ }
90
+ // `last`: when the session last moved — the file's mtime is its last row. A
91
+ // capture of every session sends it so the server can skip what has not moved.
92
+ out.push({ sid, projectDir, files, last: mtime, lastCwd: () => tailCwd(path) });
93
+ }
94
+ }
95
+ return out;
96
+ },
97
+
98
+ tsOf(r) { return r?.timestamp ? new Date(r.timestamp).getTime() : 0; },
99
+ };
100
+
101
+ function* walkJsonl(dir) {
102
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
103
+ const p = join(dir, e.name);
104
+ if (e.isDirectory()) yield* walkJsonl(p);
105
+ else if (e.name.endsWith('.jsonl')) yield p;
106
+ }
107
+ }
@@ -0,0 +1,230 @@
1
+ // Codex CLI adapter. Same contract as the others — detect / enumerate / tsOf / currentSession.
2
+ //
3
+ // A Codex thread is one file: ~/.codex/sessions/YYYY/MM/DD/rollout-<local time>-<thread id>.jsonl, appended to for as
4
+ // long as the thread lives (a resume writes on into the same file — checked 2026-10-04). A sub-agent is a thread of
5
+ // its own, in its own file, whose first row names its parent (`parent_thread_id`); it rides with its parent the way a
6
+ // Claude Code sub-agent's transcript does.
7
+ //
8
+ // What is read, and why not the rest. Every message is in the file TWICE — as the model's wire item (`response_item`)
9
+ // and as a completed item of the turn (`event_msg` / `item_completed`). The wire side also carries what Codex itself
10
+ // put in front of the model under the role `user` (an <environment_context> block, per turn) and under `developer`
11
+ // (its instructions); the completed-item side holds exactly what the person typed (`UserMessage`), what the agent
12
+ // said (`AgentMessage`), each command it ran (`CommandExecution`, with the command as argv) and each file it changed
13
+ // (`FileChange`, keyed by path). So the completed items are the transcript, and nothing else is read: measured on the
14
+ // first session of this machine, 4 wire rows of role `user` against 3 things the person typed.
15
+ //
16
+ // Rows come out in Claude Code's shape, so the upload diet (scrub.mjs) and SCAN treat them as they treat any other.
17
+ // Command output, file contents and reasoning are never put into a row at all.
18
+ import { closeSync, createReadStream, existsSync, fstatSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { createInterface } from 'node:readline';
22
+ import { projectDirOf } from './project-dir.mjs';
23
+
24
+ const ROOT = () => join(process.env.DIFFIN_HOME ?? homedir(), '.codex', 'sessions');
25
+ const FILE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
26
+
27
+ /** every rollout file under the sessions tree: { path, id, mtime } */
28
+ function rollouts() {
29
+ const out = [];
30
+ const walk = (dir, depth) => {
31
+ let names;
32
+ try { names = readdirSync(dir, { withFileTypes: true }); } catch { return; }
33
+ for (const e of names) {
34
+ const p = join(dir, e.name);
35
+ if (e.isDirectory()) { if (depth < 3) walk(p, depth + 1); continue; }
36
+ const m = FILE.exec(e.name);
37
+ if (!m) continue;
38
+ try { out.push({ path: p, id: m[1], mtime: statSync(p).mtimeMs }); } catch { /* gone */ }
39
+ }
40
+ };
41
+ walk(ROOT(), 0);
42
+ return out;
43
+ }
44
+
45
+ /** a thread's first row — who it is, where it began, whose child it is. The row carries Codex's base instructions,
46
+ * so it can be long; read until its newline, bounded. */
47
+ function meta(path) {
48
+ let fd;
49
+ try {
50
+ fd = openSync(path, 'r');
51
+ const chunks = []; let at = 0;
52
+ for (let i = 0; i < 64; i++) {
53
+ const buf = Buffer.alloc(64 * 1024);
54
+ const n = readSync(fd, buf, 0, buf.length, at);
55
+ if (!n) break;
56
+ const nl = buf.subarray(0, n).indexOf(10);
57
+ chunks.push(buf.subarray(0, nl >= 0 ? nl : n));
58
+ if (nl >= 0) break;
59
+ at += n;
60
+ }
61
+ const row = JSON.parse(Buffer.concat(chunks).toString('utf8'));
62
+ const p = row?.type === 'session_meta' ? row.payload : null;
63
+ if (!p?.id) return null;
64
+ return {
65
+ id: String(p.id),
66
+ cwd: typeof p.cwd === 'string' ? p.cwd : '',
67
+ parent: typeof p.parent_thread_id === 'string' ? p.parent_thread_id : null,
68
+ // a sub-agent's file opens with a copy of its parent's rows; its own begin at this ordinal
69
+ startOrdinal: Number.isFinite(p.subagent_history_start_ordinal) ? p.subagent_history_start_ordinal : 0,
70
+ };
71
+ } catch { return null; }
72
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* closing */ } }
73
+ }
74
+
75
+ /** the folder the thread was last in: the last turn's `cwd`, read from the end of the file */
76
+ function tailCwd(path) {
77
+ let fd;
78
+ try {
79
+ fd = openSync(path, 'r');
80
+ const size = fstatSync(fd).size;
81
+ const len = Math.min(size, 512 * 1024);
82
+ const buf = Buffer.alloc(len);
83
+ readSync(fd, buf, 0, len, size - len);
84
+ const lines = buf.toString('utf8').split('\n');
85
+ for (let i = lines.length - 1; i >= 0; i--) {
86
+ if (!lines[i].includes('"turn_context"')) continue;
87
+ try { const r = JSON.parse(lines[i]); if (r?.type === 'turn_context' && r.payload?.cwd) return String(r.payload.cwd); } catch { /* torn */ }
88
+ }
89
+ return null;
90
+ } catch { return null; }
91
+ finally { if (fd !== undefined) try { closeSync(fd); } catch { /* closing */ } }
92
+ }
93
+
94
+ const textOf = (content) => (Array.isArray(content) ? content : []).map((c) => (typeof c?.text === 'string' ? c.text : '')).filter(Boolean).join('\n');
95
+
96
+ /**
97
+ * argv → the shell tool it was and the command a person would read. Codex runs `<shell> -lc <command>` (seen:
98
+ * /bin/zsh -lc …); on Windows the shell is PowerShell or cmd, whose flag is `-Command` or `/c` and may stand after
99
+ * others (`-NoProfile`). The tool's name matters downstream: a PowerShell command's written files are read by
100
+ * PowerShell's grammar (scrub.mjs). The Windows shapes are written from how those shells are invoked, not from a
101
+ * session recorded on Windows — none exists yet; anything unrecognised is the argv joined, read as a Bash line.
102
+ */
103
+ export function shellCall(argv) {
104
+ if (!Array.isArray(argv)) return { name: 'Bash', command: typeof argv === 'string' ? argv : '' };
105
+ const a = argv.map(String);
106
+ const exe = (a[0] ?? '').split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
107
+ const name = exe === 'powershell' || exe === 'pwsh' ? 'PowerShell' : 'Bash';
108
+ const flag = a.findIndex((x, i) => i > 0 && (/^-[a-z]*c$/.test(x) || /^-(c|command)$/i.test(x) || /^\/c$/i.test(x)));
109
+ return { name, command: flag > 0 && flag < a.length - 1 ? a.slice(flag + 1).join(' ') : a.join(' ') };
110
+ }
111
+
112
+ /**
113
+ * One rollout file → Claude Code-shaped rows. An assistant row is held back one step so the usage record that
114
+ * follows the model's response can be put on it (SCAN charges tokens to the turn from the assistant rows).
115
+ */
116
+ async function* threadRows(path, { sidechain = false, startOrdinal = 0 } = {}) {
117
+ const rl = createInterface({ input: createReadStream(path, 'utf8'), crlfDelay: Infinity });
118
+ let cwd = '', model = '', held = null;
119
+ const flush = function* () { if (held) { const h = held; held = null; yield h; } };
120
+ const assistant = (id, ts, content) => ({
121
+ type: 'assistant', uuid: `cx:${id}`, timestamp: ts, cwd, ...(sidechain ? { isSidechain: true } : {}),
122
+ message: { role: 'assistant', ...(model ? { model } : {}), content },
123
+ });
124
+ for await (const line of rl) {
125
+ if (!line.trim()) continue;
126
+ let r;
127
+ try { r = JSON.parse(line); } catch { continue; } // the last line of a live file may be torn
128
+ const p = r?.payload;
129
+ if (!p || typeof p !== 'object') continue;
130
+ if (r.type === 'session_meta') { if (!cwd && typeof p.cwd === 'string') cwd = p.cwd; continue; }
131
+ if (r.type === 'turn_context') { if (typeof p.cwd === 'string') cwd = p.cwd; if (typeof p.model === 'string') model = p.model; continue; }
132
+ if (typeof r.ordinal === 'number' && r.ordinal < startOrdinal) continue; // the parent's rows, copied into a sub-agent's file
133
+ if (r.type === 'token_usage_record') {
134
+ const u = p.usage;
135
+ if (held && u && typeof u === 'object') {
136
+ const add = (k, v) => { if (typeof v === 'number') held.message.usage = { ...(held.message.usage ?? {}), [k]: (held.message.usage?.[k] ?? 0) + v }; };
137
+ // Codex counts cached input INSIDE input_tokens; the ledger's four counters keep them apart, as Claude Code's do
138
+ const cached = typeof u.cached_input_tokens === 'number' ? u.cached_input_tokens : 0;
139
+ add('input_tokens', typeof u.input_tokens === 'number' ? Math.max(0, u.input_tokens - cached) : undefined);
140
+ add('output_tokens', u.output_tokens);
141
+ add('cache_read_input_tokens', cached);
142
+ add('cache_creation_input_tokens', u.cache_write_input_tokens);
143
+ }
144
+ continue;
145
+ }
146
+ if (r.type !== 'event_msg' || p.type !== 'item_completed' || !p.item || typeof p.item !== 'object') continue;
147
+ const it = p.item, ts = r.timestamp, id = String(it.id ?? `${p.turn_id ?? ''}:${r.ordinal ?? ''}`);
148
+ if (it.type === 'UserMessage') {
149
+ yield* flush();
150
+ const text = textOf(it.content);
151
+ if (text) yield { type: 'user', uuid: `cx:${id}`, timestamp: ts, cwd, ...(sidechain ? { isSidechain: true } : {}), message: { role: 'user', content: text } };
152
+ } else if (it.type === 'AgentMessage') {
153
+ yield* flush();
154
+ const text = textOf(it.content);
155
+ if (text) held = assistant(id, ts, [{ type: 'text', text }]);
156
+ } else if (it.type === 'CommandExecution') {
157
+ yield* flush();
158
+ const { name, command } = shellCall(it.command);
159
+ if (command) held = assistant(id, ts, [{ type: 'tool_use', id, name, input: { command } }]);
160
+ } else if (it.type === 'FileChange') {
161
+ yield* flush();
162
+ const blocks = Object.entries(it.changes && typeof it.changes === 'object' ? it.changes : {}).slice(0, 50)
163
+ .map(([file, ch], k) => ({ type: 'tool_use', id: `${id}:${k}`, name: ch?.type === 'add' ? 'Write' : 'Edit', input: { file_path: file } }));
164
+ if (blocks.length) held = assistant(id, ts, blocks);
165
+ }
166
+ // everything else — reasoning, searches, sub-agent bookkeeping, output — stays on the machine
167
+ }
168
+ yield* flush();
169
+ }
170
+
171
+ /** the threads of the window, sub-agents folded under the thread that spawned them */
172
+ function threads(fromMs) {
173
+ const files = rollouts().filter((f) => f.mtime >= fromMs).map((f) => ({ ...f, meta: meta(f.path) })).filter((f) => f.meta);
174
+ const byId = new Map(files.map((f) => [f.meta.id, f]));
175
+ const rootOf = (f) => { let cur = f; for (let i = 0; i < 8 && cur?.meta.parent; i++) cur = byId.get(cur.meta.parent); return cur && !cur.meta.parent ? cur : null; };
176
+ const mains = files.filter((f) => !f.meta.parent);
177
+ const kids = new Map();
178
+ for (const f of files) {
179
+ if (!f.meta.parent) continue;
180
+ const root = rootOf(f); // a sub-agent whose parent is not in the window has nothing of the window to add
181
+ if (root) kids.set(root.meta.id, [...(kids.get(root.meta.id) ?? []), f]);
182
+ }
183
+ return mains.map((f) => ({ main: f, subs: kids.get(f.meta.id) ?? [] }));
184
+ }
185
+
186
+ export const codex = {
187
+ name: 'codex',
188
+
189
+ detect() {
190
+ if (!existsSync(ROOT())) return null;
191
+ const n = rollouts().length;
192
+ return { present: n > 0, sessions: n };
193
+ },
194
+
195
+ enumerate({ from }) {
196
+ const fromMs = new Date(from).getTime();
197
+ return threads(fromMs).map(({ main, subs }) => ({
198
+ sid: 'cx-' + main.meta.id,
199
+ projectDir: projectDirOf(main.meta.cwd),
200
+ files: [
201
+ { path: 'main', rows: () => threadRows(main.path) },
202
+ ...subs.map((s) => ({ path: `subagents/${s.meta.id}.jsonl`, rows: () => threadRows(s.path, { sidechain: true, startOrdinal: s.meta.startOrdinal }) })),
203
+ ],
204
+ last: Math.max(main.mtime, ...subs.map((s) => s.mtime)),
205
+ cwd: main.meta.cwd,
206
+ lastCwd: () => tailCwd(main.path),
207
+ }));
208
+ },
209
+
210
+ tsOf(r) { return r?.timestamp ? new Date(r.timestamp).getTime() : 0; },
211
+
212
+ /**
213
+ * The thread a capture is for: the one Codex names in CODEX_THREAD_ID (it sets that for every command it runs) —
214
+ * or, when that names a sub-agent, the thread that spawned it — else the thread of this folder that moved last.
215
+ */
216
+ currentSession(cwd = process.cwd(), env = process.env) {
217
+ const all = rollouts();
218
+ const named = env.CODEX_THREAD_ID;
219
+ if (named) {
220
+ let f = all.find((x) => x.id === named), m = f && meta(f.path);
221
+ for (let i = 0; i < 8 && m?.parent; i++) { f = all.find((x) => x.id === m.parent); m = f && meta(f.path); }
222
+ if (m && !m.parent) return { sid: 'cx-' + m.id, id: m.id, cwd: m.cwd, how: 'env' };
223
+ }
224
+ for (const f of all.sort((a, b) => b.mtime - a.mtime)) {
225
+ const m = meta(f.path);
226
+ if (m && !m.parent && m.cwd === cwd) return { sid: 'cx-' + m.id, id: m.id, cwd: m.cwd, how: 'newest' };
227
+ }
228
+ return null;
229
+ },
230
+ };