throughline 0.3.24 → 0.4.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/.claude/commands/tl.md +6 -21
- package/.codex-sidecar.yml +62 -0
- package/CHANGELOG.md +632 -0
- package/README.ja.md +71 -46
- package/README.md +420 -76
- package/bin/throughline.mjs +169 -7
- package/codex/skills/throughline/SKILL.md +157 -0
- package/codex/skills/throughline/agents/openai.yaml +7 -0
- package/docs/INHERITANCE_ON_CLEAR_ONLY.md +159 -0
- package/docs/L1_L2_L3_REDESIGN.md +415 -0
- package/docs/PUBLIC_RELEASE_PLAN.md +185 -0
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +286 -0
- package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
- package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
- package/docs/archive/CONCEPT.md +476 -0
- package/docs/archive/EXPERIMENT.md +371 -0
- package/docs/archive/README.md +22 -0
- package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
- package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
- package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
- package/docs/throughline-handoff-context.example.json +57 -0
- package/docs/throughline-rollback-context-trim-insight.md +455 -0
- package/package.json +6 -2
- package/src/baton.mjs +17 -45
- package/src/baton.test.mjs +4 -41
- package/src/cli/codex-capture.mjs +95 -0
- package/src/cli/codex-handoff-model-smoke.mjs +292 -0
- package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
- package/src/cli/codex-handoff-smoke.mjs +163 -0
- package/src/cli/codex-handoff-smoke.test.mjs +149 -0
- package/src/cli/codex-handoff-start.mjs +291 -0
- package/src/cli/codex-handoff-start.test.mjs +194 -0
- package/src/cli/codex-hook.mjs +276 -0
- package/src/cli/codex-hook.test.mjs +293 -0
- package/src/cli/codex-host-primitive-audit.mjs +110 -0
- package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
- package/src/cli/codex-restore-smoke.mjs +357 -0
- package/src/cli/codex-restore-source-audit.mjs +304 -0
- package/src/cli/codex-resume.mjs +138 -0
- package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
- package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
- package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
- package/src/cli/codex-sidecar-dry-run.mjs +85 -0
- package/src/cli/codex-summarize.mjs +224 -0
- package/src/cli/codex-threads.mjs +89 -0
- package/src/cli/codex-visibility-smoke.mjs +196 -0
- package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
- package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
- package/src/cli/doctor.mjs +503 -1
- package/src/cli/doctor.test.mjs +542 -3
- package/src/cli/handoff-preview.mjs +78 -0
- package/src/cli/help.test.mjs +64 -0
- package/src/cli/install.mjs +226 -3
- package/src/cli/install.test.mjs +205 -4
- package/src/cli/trim.mjs +564 -0
- package/src/codex-app-server.mjs +1816 -0
- package/src/codex-app-server.test.mjs +512 -0
- package/src/codex-auto-refresh.mjs +194 -0
- package/src/codex-auto-refresh.test.mjs +182 -0
- package/src/codex-capture.mjs +235 -0
- package/src/codex-capture.test.mjs +393 -0
- package/src/codex-handoff-model-smoke.mjs +114 -0
- package/src/codex-handoff-model-smoke.test.mjs +89 -0
- package/src/codex-handoff-smoke.mjs +124 -0
- package/src/codex-handoff-smoke.test.mjs +103 -0
- package/src/codex-handoff.mjs +331 -0
- package/src/codex-handoff.test.mjs +220 -0
- package/src/codex-host-primitive-audit.mjs +374 -0
- package/src/codex-host-primitive-audit.test.mjs +208 -0
- package/src/codex-restore-smoke.test.mjs +639 -0
- package/src/codex-restore-source-audit.mjs +1348 -0
- package/src/codex-restore-source-audit.test.mjs +623 -0
- package/src/codex-resume.test.mjs +242 -0
- package/src/codex-rollout-memory.mjs +711 -0
- package/src/codex-rollout-memory.test.mjs +610 -0
- package/src/codex-sidecar-cli.test.mjs +75 -0
- package/src/codex-sidecar.mjs +246 -0
- package/src/codex-sidecar.test.mjs +172 -0
- package/src/codex-summarize.test.mjs +143 -0
- package/src/codex-thread-identity.mjs +23 -0
- package/src/codex-thread-index.mjs +173 -0
- package/src/codex-thread-index.test.mjs +164 -0
- package/src/codex-usage.mjs +110 -0
- package/src/codex-usage.test.mjs +140 -0
- package/src/codex-visibility-smoke.test.mjs +222 -0
- package/src/codex-vscode-restore-smoke.mjs +206 -0
- package/src/codex-vscode-restore-smoke.test.mjs +325 -0
- package/src/codex-vscode-rollback-smoke.mjs +90 -0
- package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
- package/src/db-schema.test.mjs +96 -0
- package/src/db.mjs +14 -1
- package/src/haiku-summarizer.mjs +267 -26
- package/src/haiku-summarizer.test.mjs +282 -0
- package/src/handoff-preview.test.mjs +108 -0
- package/src/handoff-record.mjs +294 -0
- package/src/handoff-record.test.mjs +226 -0
- package/src/hook-entrypoints.test.mjs +286 -0
- package/src/package-files.test.mjs +19 -0
- package/src/prompt-submit.mjs +9 -6
- package/src/resume-context.mjs +58 -171
- package/src/resume-context.test.mjs +177 -0
- package/src/session-start.mjs +85 -26
- package/src/state-file.mjs +50 -6
- package/src/state-file.test.mjs +50 -0
- package/src/token-monitor.mjs +14 -10
- package/src/token-monitor.test.mjs +27 -0
- package/src/trim-cli.test.mjs +1584 -0
- package/src/trim-model.mjs +584 -0
- package/src/trim-model.test.mjs +568 -0
- package/src/turn-processor.mjs +17 -10
- package/src/vscode-task.mjs +33 -10
- package/src/vscode-task.test.mjs +19 -9
- package/src/cli/save-inflight.mjs +0 -81
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const ROLLOUT_RE =
|
|
6
|
+
/^rollout-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
|
|
7
|
+
|
|
8
|
+
export function defaultCodexHome() {
|
|
9
|
+
return process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function listCodexThreadCandidates({
|
|
13
|
+
codexHome = defaultCodexHome(),
|
|
14
|
+
projectPath = process.cwd(),
|
|
15
|
+
allProjects = false,
|
|
16
|
+
limit = 10,
|
|
17
|
+
} = {}) {
|
|
18
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
19
|
+
throw new Error('limit must be an integer >= 1');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const index = readSessionIndex(codexHome);
|
|
23
|
+
const rollouts = findRolloutFiles(join(codexHome, 'sessions'));
|
|
24
|
+
const normalizedProject = normalizePath(projectPath);
|
|
25
|
+
|
|
26
|
+
const candidates = rollouts
|
|
27
|
+
.map((rollout) => {
|
|
28
|
+
const meta = readSessionMeta(rollout.path);
|
|
29
|
+
const indexed = index.get(rollout.threadId) ?? {};
|
|
30
|
+
const cwd = meta?.cwd ?? null;
|
|
31
|
+
const matchesProject = cwd ? normalizePath(cwd) === normalizedProject : false;
|
|
32
|
+
return {
|
|
33
|
+
id: rollout.threadId,
|
|
34
|
+
threadName: indexed.thread_name ?? null,
|
|
35
|
+
updatedAt: rollout.mtimeIso,
|
|
36
|
+
indexedUpdatedAt: indexed.updated_at ?? null,
|
|
37
|
+
rolloutStartedAt: rollout.startedAt,
|
|
38
|
+
rolloutPath: rollout.path,
|
|
39
|
+
cwd,
|
|
40
|
+
source: meta?.source ?? null,
|
|
41
|
+
cliVersion: meta?.cli_version ?? null,
|
|
42
|
+
matchesProject,
|
|
43
|
+
mtimeMs: rollout.mtimeMs,
|
|
44
|
+
};
|
|
45
|
+
})
|
|
46
|
+
.filter((candidate) => allProjects || candidate.matchesProject)
|
|
47
|
+
.sort(compareCandidates);
|
|
48
|
+
|
|
49
|
+
return candidates.slice(0, limit);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function findCodexThreadCandidate({
|
|
53
|
+
threadId,
|
|
54
|
+
codexHome = defaultCodexHome(),
|
|
55
|
+
projectPath = process.cwd(),
|
|
56
|
+
requireProjectMatch = true,
|
|
57
|
+
} = {}) {
|
|
58
|
+
if (typeof threadId !== 'string' || threadId.length === 0) {
|
|
59
|
+
throw new Error('threadId is required');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const index = readSessionIndex(codexHome);
|
|
63
|
+
const rollouts = findRolloutFiles(join(codexHome, 'sessions'));
|
|
64
|
+
const normalizedProject = normalizePath(projectPath);
|
|
65
|
+
|
|
66
|
+
const matches = rollouts
|
|
67
|
+
.filter((rollout) => rollout.threadId === threadId)
|
|
68
|
+
.map((rollout) => {
|
|
69
|
+
const meta = readSessionMeta(rollout.path);
|
|
70
|
+
const indexed = index.get(rollout.threadId) ?? {};
|
|
71
|
+
const cwd = meta?.cwd ?? null;
|
|
72
|
+
const matchesProject = cwd ? normalizePath(cwd) === normalizedProject : false;
|
|
73
|
+
return {
|
|
74
|
+
id: rollout.threadId,
|
|
75
|
+
threadName: indexed.thread_name ?? null,
|
|
76
|
+
updatedAt: rollout.mtimeIso,
|
|
77
|
+
indexedUpdatedAt: indexed.updated_at ?? null,
|
|
78
|
+
rolloutStartedAt: rollout.startedAt,
|
|
79
|
+
rolloutPath: rollout.path,
|
|
80
|
+
cwd,
|
|
81
|
+
source: meta?.source ?? null,
|
|
82
|
+
cliVersion: meta?.cli_version ?? null,
|
|
83
|
+
matchesProject,
|
|
84
|
+
mtimeMs: rollout.mtimeMs,
|
|
85
|
+
};
|
|
86
|
+
})
|
|
87
|
+
.filter((candidate) => !requireProjectMatch || candidate.matchesProject)
|
|
88
|
+
.sort(compareCandidates);
|
|
89
|
+
|
|
90
|
+
return matches[0] ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readSessionIndex(codexHome = defaultCodexHome()) {
|
|
94
|
+
const path = join(codexHome, 'session_index.jsonl');
|
|
95
|
+
const index = new Map();
|
|
96
|
+
if (!existsSync(path)) return index;
|
|
97
|
+
|
|
98
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
99
|
+
if (!line.trim()) continue;
|
|
100
|
+
try {
|
|
101
|
+
const row = JSON.parse(line);
|
|
102
|
+
if (row && typeof row.id === 'string') {
|
|
103
|
+
index.set(row.id, row);
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Corrupt index rows are ignored; rollout files remain the source of candidates.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return index;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function findRolloutFiles(root) {
|
|
113
|
+
if (!existsSync(root)) return [];
|
|
114
|
+
|
|
115
|
+
const out = [];
|
|
116
|
+
const stack = [root];
|
|
117
|
+
while (stack.length > 0) {
|
|
118
|
+
const dir = stack.pop();
|
|
119
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
120
|
+
const path = join(dir, entry.name);
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
stack.push(path);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!entry.isFile()) continue;
|
|
126
|
+
const match = ROLLOUT_RE.exec(entry.name);
|
|
127
|
+
if (!match) continue;
|
|
128
|
+
const stat = statSync(path);
|
|
129
|
+
out.push({
|
|
130
|
+
path,
|
|
131
|
+
threadId: match[2],
|
|
132
|
+
startedAt: match[1],
|
|
133
|
+
mtimeMs: stat.mtimeMs,
|
|
134
|
+
mtimeIso: stat.mtime.toISOString(),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readSessionMeta(path) {
|
|
142
|
+
const firstLine = readFirstLine(path);
|
|
143
|
+
if (!firstLine?.trim()) return null;
|
|
144
|
+
try {
|
|
145
|
+
const row = JSON.parse(firstLine);
|
|
146
|
+
if (row?.type !== 'session_meta' || !row.payload) return null;
|
|
147
|
+
return row.payload;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readFirstLine(path) {
|
|
154
|
+
const fd = openSync(path, 'r');
|
|
155
|
+
try {
|
|
156
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
157
|
+
const bytes = readSync(fd, buffer, 0, buffer.length, 0);
|
|
158
|
+
return buffer.subarray(0, bytes).toString('utf8').split('\n', 1)[0];
|
|
159
|
+
} finally {
|
|
160
|
+
closeSync(fd);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function compareCandidates(a, b) {
|
|
165
|
+
if (a.mtimeMs !== b.mtimeMs) {
|
|
166
|
+
return b.mtimeMs - a.mtimeMs;
|
|
167
|
+
}
|
|
168
|
+
return String(b.rolloutPath).localeCompare(String(a.rolloutPath));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizePath(value) {
|
|
172
|
+
return resolve(value).split(sep).join('/').replace(/\/+$/, '').toLowerCase();
|
|
173
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
findCodexThreadCandidate,
|
|
9
|
+
listCodexThreadCandidates,
|
|
10
|
+
readSessionIndex,
|
|
11
|
+
} from './codex-thread-index.mjs';
|
|
12
|
+
|
|
13
|
+
test('readSessionIndex: reads JSONL rows and ignores corrupt rows', () => {
|
|
14
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
15
|
+
try {
|
|
16
|
+
writeFileSync(
|
|
17
|
+
join(home, 'session_index.jsonl'),
|
|
18
|
+
[
|
|
19
|
+
'{"id":"thread-a","thread_name":"Work A","updated_at":"2026-05-06T01:00:00Z"}',
|
|
20
|
+
'{broken',
|
|
21
|
+
'{"id":"thread-b","thread_name":"Work B","updated_at":"2026-05-06T02:00:00Z"}',
|
|
22
|
+
'',
|
|
23
|
+
].join('\n'),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const index = readSessionIndex(home);
|
|
27
|
+
assert.equal(index.get('thread-a').thread_name, 'Work A');
|
|
28
|
+
assert.equal(index.get('thread-b').updated_at, '2026-05-06T02:00:00Z');
|
|
29
|
+
} finally {
|
|
30
|
+
rmSync(home, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('listCodexThreadCandidates: filters rollouts to current project and merges index metadata', () => {
|
|
35
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
36
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
|
37
|
+
const otherProject = mkdtempSync(join(tmpdir(), 'tl-codex-other-'));
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(home, 'session_index.jsonl'),
|
|
41
|
+
'{"id":"019dfaba-f87e-7f41-a144-d5ca7c6dd7f9","thread_name":"Throughline work","updated_at":"2026-05-06T02:00:00Z"}\n',
|
|
42
|
+
);
|
|
43
|
+
writeRollout(home, {
|
|
44
|
+
day: '06',
|
|
45
|
+
started: '2026-05-06T09-40-50',
|
|
46
|
+
id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
|
|
47
|
+
cwd: project,
|
|
48
|
+
});
|
|
49
|
+
writeRollout(home, {
|
|
50
|
+
day: '06',
|
|
51
|
+
started: '2026-05-06T09-41-50',
|
|
52
|
+
id: '019dfabb-1111-7111-8111-111111111111',
|
|
53
|
+
cwd: otherProject,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const candidates = listCodexThreadCandidates({ codexHome: home, projectPath: project });
|
|
57
|
+
|
|
58
|
+
assert.equal(candidates.length, 1);
|
|
59
|
+
assert.equal(candidates[0].id, '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9');
|
|
60
|
+
assert.equal(candidates[0].threadName, 'Throughline work');
|
|
61
|
+
assert.equal(candidates[0].cwd, project);
|
|
62
|
+
assert.equal(candidates[0].matchesProject, true);
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(home, { recursive: true, force: true });
|
|
65
|
+
rmSync(project, { recursive: true, force: true });
|
|
66
|
+
rmSync(otherProject, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('listCodexThreadCandidates: sorts by rollout mtime rather than stale index updated_at', () => {
|
|
71
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
72
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
|
73
|
+
try {
|
|
74
|
+
writeFileSync(
|
|
75
|
+
join(home, 'session_index.jsonl'),
|
|
76
|
+
[
|
|
77
|
+
'{"id":"019dfaba-f87e-7f41-a144-d5ca7c6dd7f9","thread_name":"Old index newer","updated_at":"2026-05-06T20:00:00Z"}',
|
|
78
|
+
'{"id":"019dfa40-1cc8-7d13-a110-16b09364fa6a","thread_name":"Current file older index","updated_at":"2026-05-05T20:00:00Z"}',
|
|
79
|
+
'',
|
|
80
|
+
].join('\n'),
|
|
81
|
+
);
|
|
82
|
+
const oldPath = writeRollout(home, {
|
|
83
|
+
day: '06',
|
|
84
|
+
started: '2026-05-06T09-40-50',
|
|
85
|
+
id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
|
|
86
|
+
cwd: project,
|
|
87
|
+
});
|
|
88
|
+
const currentPath = writeRollout(home, {
|
|
89
|
+
day: '06',
|
|
90
|
+
started: '2026-05-06T07-26-38',
|
|
91
|
+
id: '019dfa40-1cc8-7d13-a110-16b09364fa6a',
|
|
92
|
+
cwd: project,
|
|
93
|
+
});
|
|
94
|
+
utimesSync(oldPath, new Date('2026-05-06T01:00:00Z'), new Date('2026-05-06T01:00:00Z'));
|
|
95
|
+
utimesSync(currentPath, new Date('2026-05-06T03:00:00Z'), new Date('2026-05-06T03:00:00Z'));
|
|
96
|
+
|
|
97
|
+
const candidates = listCodexThreadCandidates({ codexHome: home, projectPath: project });
|
|
98
|
+
|
|
99
|
+
assert.equal(candidates[0].id, '019dfa40-1cc8-7d13-a110-16b09364fa6a');
|
|
100
|
+
assert.equal(candidates[0].indexedUpdatedAt, '2026-05-05T20:00:00Z');
|
|
101
|
+
assert.equal(candidates[1].id, '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9');
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(home, { recursive: true, force: true });
|
|
104
|
+
rmSync(project, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('findCodexThreadCandidate: requires current project match for explicit thread id', () => {
|
|
109
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
110
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
|
111
|
+
const otherProject = mkdtempSync(join(tmpdir(), 'tl-codex-other-'));
|
|
112
|
+
try {
|
|
113
|
+
writeRollout(home, {
|
|
114
|
+
day: '06',
|
|
115
|
+
started: '2026-05-06T09-41-50',
|
|
116
|
+
id: '019dfabb-1111-7111-8111-111111111111',
|
|
117
|
+
cwd: otherProject,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.equal(
|
|
121
|
+
findCodexThreadCandidate({
|
|
122
|
+
threadId: '019dfabb-1111-7111-8111-111111111111',
|
|
123
|
+
codexHome: home,
|
|
124
|
+
projectPath: project,
|
|
125
|
+
}),
|
|
126
|
+
null,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const candidate = findCodexThreadCandidate({
|
|
130
|
+
threadId: '019dfabb-1111-7111-8111-111111111111',
|
|
131
|
+
codexHome: home,
|
|
132
|
+
projectPath: project,
|
|
133
|
+
requireProjectMatch: false,
|
|
134
|
+
});
|
|
135
|
+
assert.equal(candidate.id, '019dfabb-1111-7111-8111-111111111111');
|
|
136
|
+
assert.equal(candidate.cwd, otherProject);
|
|
137
|
+
} finally {
|
|
138
|
+
rmSync(home, { recursive: true, force: true });
|
|
139
|
+
rmSync(project, { recursive: true, force: true });
|
|
140
|
+
rmSync(otherProject, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
function writeRollout(home, { day, started, id, cwd }) {
|
|
145
|
+
const dir = join(home, 'sessions', '2026', '05', day);
|
|
146
|
+
mkdirSync(dir, { recursive: true });
|
|
147
|
+
const fileStarted = started.replace(/:/g, '-');
|
|
148
|
+
const path = join(dir, `rollout-${fileStarted}-${id}.jsonl`);
|
|
149
|
+
writeFileSync(
|
|
150
|
+
path,
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
timestamp: '2026-05-06T00:40:54.808Z',
|
|
153
|
+
type: 'session_meta',
|
|
154
|
+
payload: {
|
|
155
|
+
id,
|
|
156
|
+
timestamp: '2026-05-06T00:40:50.588Z',
|
|
157
|
+
cwd,
|
|
158
|
+
source: 'vscode',
|
|
159
|
+
cli_version: '0.128.0-alpha.1',
|
|
160
|
+
},
|
|
161
|
+
}) + '\n',
|
|
162
|
+
);
|
|
163
|
+
return path;
|
|
164
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
|
|
4
|
+
import { estimateTokens } from './token-estimator.mjs';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_CODEX_CONTEXT_WINDOW_SIZE = 200_000;
|
|
7
|
+
|
|
8
|
+
export function readLatestCodexUsage(rolloutPath) {
|
|
9
|
+
if (!rolloutPath) return null;
|
|
10
|
+
|
|
11
|
+
let raw;
|
|
12
|
+
try {
|
|
13
|
+
raw = readFileSync(rolloutPath, 'utf8');
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let latest = null;
|
|
19
|
+
let model = 'codex';
|
|
20
|
+
let provider = null;
|
|
21
|
+
for (const line of raw.split('\n')) {
|
|
22
|
+
if (!line.trim()) continue;
|
|
23
|
+
let row;
|
|
24
|
+
try {
|
|
25
|
+
row = JSON.parse(line);
|
|
26
|
+
} catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const payload = row?.payload;
|
|
31
|
+
if (row?.type === 'session_meta') {
|
|
32
|
+
provider = payload?.model_provider ?? provider;
|
|
33
|
+
model = payload?.model ?? model;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (row?.type === 'turn_context') {
|
|
38
|
+
model = payload?.model ?? model;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (row?.type !== 'event_msg' || payload?.type !== 'token_count') continue;
|
|
43
|
+
const info = payload.info ?? {};
|
|
44
|
+
const last = info.last_token_usage ?? {};
|
|
45
|
+
const tokens = Number(last.input_tokens);
|
|
46
|
+
if (!Number.isFinite(tokens) || tokens < 0) continue;
|
|
47
|
+
|
|
48
|
+
const windowSize = Number(info.model_context_window);
|
|
49
|
+
latest = {
|
|
50
|
+
tokens,
|
|
51
|
+
model: model === 'codex' && provider ? provider : model,
|
|
52
|
+
contextWindowSize:
|
|
53
|
+
Number.isFinite(windowSize) && windowSize > 0
|
|
54
|
+
? windowSize
|
|
55
|
+
: DEFAULT_CODEX_CONTEXT_WINDOW_SIZE,
|
|
56
|
+
contextWindowEstimated: !(Number.isFinite(windowSize) && windowSize > 0),
|
|
57
|
+
outputTokens: Number.isFinite(Number(last.output_tokens)) ? Number(last.output_tokens) : 0,
|
|
58
|
+
estimated: false,
|
|
59
|
+
source: 'codex-rollout-token-count',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return latest;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function estimateCodexUsageFromRollout(rolloutPath) {
|
|
67
|
+
if (!rolloutPath) return null;
|
|
68
|
+
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = parseCodexRolloutFile(rolloutPath);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const text = activeRolloutText(parsed);
|
|
77
|
+
if (!text.trim()) return null;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
tokens: estimateTokens(text),
|
|
81
|
+
model: 'codex',
|
|
82
|
+
contextWindowSize: DEFAULT_CODEX_CONTEXT_WINDOW_SIZE,
|
|
83
|
+
contextWindowEstimated: true,
|
|
84
|
+
outputTokens: 0,
|
|
85
|
+
estimated: true,
|
|
86
|
+
source: 'codex-rollout-chars-div-4',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function buildCodexMonitorUsage(rolloutPath) {
|
|
91
|
+
return readLatestCodexUsage(rolloutPath) ?? estimateCodexUsageFromRollout(rolloutPath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function activeRolloutText(parsed) {
|
|
95
|
+
const chunks = [];
|
|
96
|
+
for (const turn of parsed?.activeTurns ?? []) {
|
|
97
|
+
for (const message of turn.messages ?? []) {
|
|
98
|
+
if (message?.text) chunks.push(message.text);
|
|
99
|
+
}
|
|
100
|
+
for (const detail of turn.details ?? []) {
|
|
101
|
+
if (detail?.input_text) chunks.push(detail.input_text);
|
|
102
|
+
if (detail?.output_text) chunks.push(detail.output_text);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return chunks.join('\n\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const _internal = {
|
|
109
|
+
activeRolloutText,
|
|
110
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
buildCodexMonitorUsage,
|
|
9
|
+
estimateCodexUsageFromRollout,
|
|
10
|
+
readLatestCodexUsage,
|
|
11
|
+
} from './codex-usage.mjs';
|
|
12
|
+
|
|
13
|
+
test('readLatestCodexUsage: reads verified Codex token_count event shape', () => {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-codex-usage-'));
|
|
15
|
+
try {
|
|
16
|
+
const rollout = join(dir, 'rollout.jsonl');
|
|
17
|
+
writeFileSync(
|
|
18
|
+
rollout,
|
|
19
|
+
[
|
|
20
|
+
row('session_meta', {
|
|
21
|
+
id: '019dfaba-thread',
|
|
22
|
+
cwd: '/repo',
|
|
23
|
+
model_provider: 'openai',
|
|
24
|
+
}),
|
|
25
|
+
row('turn_context', {
|
|
26
|
+
turn_id: '019dfaba-turn',
|
|
27
|
+
model: 'gpt-5.5',
|
|
28
|
+
}),
|
|
29
|
+
event('token_count', {
|
|
30
|
+
info: {
|
|
31
|
+
last_token_usage: {
|
|
32
|
+
input_tokens: 151914,
|
|
33
|
+
cached_input_tokens: 143744,
|
|
34
|
+
output_tokens: 60,
|
|
35
|
+
total_tokens: 151974,
|
|
36
|
+
},
|
|
37
|
+
model_context_window: 258400,
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
]
|
|
41
|
+
.map((r) => JSON.stringify(r))
|
|
42
|
+
.join('\n') + '\n',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
assert.deepEqual(readLatestCodexUsage(rollout), {
|
|
46
|
+
tokens: 151914,
|
|
47
|
+
model: 'gpt-5.5',
|
|
48
|
+
contextWindowSize: 258400,
|
|
49
|
+
contextWindowEstimated: false,
|
|
50
|
+
outputTokens: 60,
|
|
51
|
+
estimated: false,
|
|
52
|
+
source: 'codex-rollout-token-count',
|
|
53
|
+
});
|
|
54
|
+
} finally {
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('readLatestCodexUsage: falls back to model_provider when no turn_context model exists', () => {
|
|
60
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-codex-usage-'));
|
|
61
|
+
try {
|
|
62
|
+
const rollout = join(dir, 'rollout.jsonl');
|
|
63
|
+
writeFileSync(
|
|
64
|
+
rollout,
|
|
65
|
+
[
|
|
66
|
+
row('session_meta', {
|
|
67
|
+
id: '019dfaba-thread',
|
|
68
|
+
cwd: '/repo',
|
|
69
|
+
model_provider: 'openai',
|
|
70
|
+
}),
|
|
71
|
+
event('token_count', {
|
|
72
|
+
info: {
|
|
73
|
+
last_token_usage: {
|
|
74
|
+
input_tokens: 151914,
|
|
75
|
+
output_tokens: 60,
|
|
76
|
+
},
|
|
77
|
+
model_context_window: 258400,
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
]
|
|
81
|
+
.map((r) => JSON.stringify(r))
|
|
82
|
+
.join('\n') + '\n',
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
assert.equal(readLatestCodexUsage(rollout)?.model, 'openai');
|
|
86
|
+
} finally {
|
|
87
|
+
rmSync(dir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('buildCodexMonitorUsage: estimates explicitly when token_count is absent', () => {
|
|
92
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-codex-usage-'));
|
|
93
|
+
try {
|
|
94
|
+
const rollout = join(dir, 'rollout.jsonl');
|
|
95
|
+
writeFileSync(
|
|
96
|
+
rollout,
|
|
97
|
+
[
|
|
98
|
+
row('session_meta', { id: '019dfaba-thread', cwd: '/repo' }),
|
|
99
|
+
event('user_message', { message: 'hello codex monitor' }),
|
|
100
|
+
event('task_started'),
|
|
101
|
+
event('agent_message', { message: 'working on it' }),
|
|
102
|
+
event('task_complete'),
|
|
103
|
+
]
|
|
104
|
+
.map((r) => JSON.stringify(r))
|
|
105
|
+
.join('\n') + '\n',
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const usage = buildCodexMonitorUsage(rollout);
|
|
109
|
+
assert.ok(usage);
|
|
110
|
+
assert.equal(usage.estimated, true);
|
|
111
|
+
assert.equal(usage.source, 'codex-rollout-chars-div-4');
|
|
112
|
+
assert.equal(usage.contextWindowEstimated, true);
|
|
113
|
+
assert.ok(usage.tokens > 0);
|
|
114
|
+
} finally {
|
|
115
|
+
rmSync(dir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('estimateCodexUsageFromRollout: returns null for empty active rollout text', () => {
|
|
120
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-codex-usage-'));
|
|
121
|
+
try {
|
|
122
|
+
const rollout = join(dir, 'rollout.jsonl');
|
|
123
|
+
writeFileSync(rollout, JSON.stringify(row('session_meta', { id: '019dfaba-thread', cwd: '/repo' })) + '\n');
|
|
124
|
+
assert.equal(estimateCodexUsageFromRollout(rollout), null);
|
|
125
|
+
} finally {
|
|
126
|
+
rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
function row(type, payload) {
|
|
131
|
+
return {
|
|
132
|
+
timestamp: '2026-05-06T00:40:50.000Z',
|
|
133
|
+
type,
|
|
134
|
+
payload,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function event(type, payload = {}) {
|
|
139
|
+
return row('event_msg', { type, ...payload });
|
|
140
|
+
}
|