@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.
- package/LICENSE +22 -0
- package/README.md +82 -2
- package/bin/diffin.mjs +69 -0
- package/lib/agents.mjs +56 -0
- package/lib/backfill.mjs +316 -0
- package/lib/capture.mjs +443 -0
- package/lib/command.mjs +44 -0
- package/lib/creds.mjs +35 -0
- package/lib/folders.mjs +76 -0
- package/lib/ignore.mjs +208 -0
- package/lib/init.mjs +114 -0
- package/lib/journey.mjs +269 -0
- package/lib/login.mjs +25 -0
- package/lib/machine.mjs +45 -0
- package/lib/net.mjs +87 -0
- package/lib/onboard.mjs +74 -0
- package/lib/scrub.mjs +513 -0
- package/lib/settings.mjs +46 -0
- package/lib/sources/antigravity.mjs +178 -0
- package/lib/sources/claude-code.mjs +107 -0
- package/lib/sources/codex.mjs +230 -0
- package/lib/sources/hermes.mjs +170 -0
- package/lib/sources/index.mjs +29 -0
- package/lib/sources/project-dir.mjs +13 -0
- package/lib/state.mjs +113 -0
- package/lib/status.mjs +71 -0
- package/lib/stream.mjs +67 -0
- package/lib/ui.mjs +220 -0
- package/lib/watch.mjs +164 -0
- package/package.json +21 -4
- package/templates/skills/capture/SKILL.md +107 -0
|
@@ -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
|
+
};
|