pennyrouter 0.3.0 → 0.3.1
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/README.md +14 -9
- package/package.json +2 -2
- package/src/cli.js +563 -132
- package/src/cli.test.js +196 -0
- package/src/harnesses/claude-code.js +81 -32
- package/src/harnesses/opencode.js +82 -8
- package/src/harnesses/opencode.test.js +75 -0
- package/src/launch.js +260 -28
- package/src/launch.test.js +47 -1
- package/src/mcp-register.js +212 -0
- package/src/mcp-register.test.js +200 -0
- package/src/mcp.js +375 -0
- package/src/mcp.test.js +259 -0
- package/src/session.js +9 -5
- package/src/statusline.js +73 -5
- package/src/thread-session-codex.js +218 -0
- package/src/thread-session-codex.test.js +151 -0
- package/src/thread-session.js +215 -0
- package/src/thread-session.test.js +151 -0
package/src/statusline.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
// authed with the pr- key already in Claude Code's settings). The gateway keys that
|
|
12
12
|
// endpoint on the CC session uuid it sees in every request's metadata.user_id — the
|
|
13
13
|
// same uuid Claude Code hands the statusline script on stdin.
|
|
14
|
+
//
|
|
15
|
+
// A wrapped user statusline command is cached the same way: it can be slow to spawn
|
|
16
|
+
// (npx especially, and especially on Windows — see WRAP_REFRESH_MS below), so it's never
|
|
17
|
+
// run inline on Claude Code's ~1s render loop.
|
|
14
18
|
|
|
15
19
|
import { statePath, atomicWrite, readJsonFile, writeJsonFile } from "./state.js";
|
|
16
20
|
|
|
@@ -46,6 +50,7 @@ export async function configureClaudeCodeStatusline(current) {
|
|
|
46
50
|
current.statusLine = {
|
|
47
51
|
type: "command",
|
|
48
52
|
command: `node "${statuslineScriptPath()}"`,
|
|
53
|
+
refreshInterval: 1,
|
|
49
54
|
...(existing?.padding !== undefined ? { padding: existing.padding } : {}),
|
|
50
55
|
};
|
|
51
56
|
return { script_path: statuslineScriptPath(), wrapped: Boolean(config.wrap_command) };
|
|
@@ -86,6 +91,14 @@ const stateDir = join(
|
|
|
86
91
|
"pennyrouter",
|
|
87
92
|
);
|
|
88
93
|
const cacheDir = join(stateDir, "statusline-cache");
|
|
94
|
+
// A wrapped user statusline command can be slow to spawn (npx especially: on Windows
|
|
95
|
+
// it goes through an extra cmd.exe/npx.cmd hop and can take 2-4s per invocation, vs
|
|
96
|
+
// well under 1s on macOS/Linux). Claude Code re-invokes this script every ~1s
|
|
97
|
+
// (refreshInterval), so running the wrap command inline on every render blocked past
|
|
98
|
+
// that interval on Windows, renders piled up, and the statusline never finished in
|
|
99
|
+
// time to display at all. Cache its output and refresh it out-of-band instead, same
|
|
100
|
+
// as the Penny gateway segment below.
|
|
101
|
+
const WRAP_REFRESH_MS = 2000;
|
|
89
102
|
|
|
90
103
|
function readJson(path) {
|
|
91
104
|
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
|
|
@@ -108,6 +121,8 @@ function claudeEnv() {
|
|
|
108
121
|
return { base, key };
|
|
109
122
|
}
|
|
110
123
|
|
|
124
|
+
const config = readJson(join(stateDir, "claude-statusline.json")) || {};
|
|
125
|
+
|
|
111
126
|
// --refresh <sessionId>: fetch gateway status into the cache file, then exit.
|
|
112
127
|
if (process.argv[2] === "--refresh") {
|
|
113
128
|
const sessionId = process.argv[3];
|
|
@@ -120,6 +135,9 @@ if (process.argv[2] === "--refresh") {
|
|
|
120
135
|
});
|
|
121
136
|
if (resp.ok) {
|
|
122
137
|
const data = await resp.json();
|
|
138
|
+
if (data.cache_expires_at && typeof data.cache_expires_at === "number") {
|
|
139
|
+
data.cache_expires_at = Math.round(data.cache_expires_at / 10) * 10;
|
|
140
|
+
}
|
|
123
141
|
mkdirSync(cacheDir, { recursive: true });
|
|
124
142
|
writeFileSync(join(cacheDir, sessionId + ".json"), JSON.stringify(data));
|
|
125
143
|
}
|
|
@@ -127,11 +145,28 @@ if (process.argv[2] === "--refresh") {
|
|
|
127
145
|
process.exit(0);
|
|
128
146
|
}
|
|
129
147
|
|
|
148
|
+
// --refresh-wrap <sessionId>: run the (possibly slow) wrapped statusline command out of
|
|
149
|
+
// band and cache its output. Reads the stdin JSON the main render last saw, persisted to
|
|
150
|
+
// a per-session file since a detached child has no stdin of its own to read.
|
|
151
|
+
if (process.argv[2] === "--refresh-wrap") {
|
|
152
|
+
const sessionId = process.argv[3];
|
|
153
|
+
if (!sessionId || !config.wrap_command) process.exit(0);
|
|
154
|
+
let wrapInput = "{}";
|
|
155
|
+
try { wrapInput = readFileSync(join(cacheDir, sessionId + ".wrap-input.json"), "utf8"); } catch { /* none yet */ }
|
|
156
|
+
const run = spawnSync(config.wrap_command, {
|
|
157
|
+
shell: true, input: wrapInput, encoding: "utf8", timeout: 20000, windowsHide: true,
|
|
158
|
+
});
|
|
159
|
+
let wrapLines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
|
|
160
|
+
if (wrapLines.length === 1 && wrapLines[0] === "") wrapLines = [];
|
|
161
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
162
|
+
writeFileSync(join(cacheDir, sessionId + ".wrap.json"), JSON.stringify({ lines: wrapLines, at: Date.now() }));
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
|
|
130
166
|
const raw = readFileSync(0, "utf8");
|
|
131
167
|
let input = {};
|
|
132
168
|
try { input = JSON.parse(raw); } catch { /* keep {} */ }
|
|
133
169
|
const sessionId = input.session_id || "";
|
|
134
|
-
const config = readJson(join(stateDir, "claude-statusline.json")) || {};
|
|
135
170
|
const refreshMs = (config.refresh_seconds ?? 15) * 1000;
|
|
136
171
|
|
|
137
172
|
const cachePath = join(cacheDir, sessionId + ".json");
|
|
@@ -144,7 +179,7 @@ const { base, key } = claudeEnv();
|
|
|
144
179
|
const pennyInstalled = Boolean(base) && Boolean(key);
|
|
145
180
|
if (sessionId && pennyInstalled && cacheAge > refreshMs) {
|
|
146
181
|
spawn(process.execPath, [process.argv[1], "--refresh", sessionId], {
|
|
147
|
-
detached: true, stdio: "ignore",
|
|
182
|
+
detached: true, stdio: "ignore", windowsHide: true,
|
|
148
183
|
}).unref();
|
|
149
184
|
}
|
|
150
185
|
|
|
@@ -164,19 +199,52 @@ function askBadges(c) {
|
|
|
164
199
|
return out;
|
|
165
200
|
}
|
|
166
201
|
|
|
202
|
+
// Cache warmth countdown: when a prompt cache prefix is live, show time remaining.
|
|
203
|
+
// The gateway sends cache_expires_at (absolute Unix timestamp); we interpolate locally
|
|
204
|
+
// so it ticks down smoothly every statusline render (every ~1s), only "jumping" when
|
|
205
|
+
// the gateway refreshes (~15s) with a fresh timestamp. Show ❄️ cold when expired.
|
|
206
|
+
function cacheWarmth(c) {
|
|
207
|
+
if (!c || typeof c.cache_expires_at !== "number" || c.cache_expires_at <= 0) {
|
|
208
|
+
return "";
|
|
209
|
+
}
|
|
210
|
+
const remaining = c.cache_expires_at - Date.now() / 1000;
|
|
211
|
+
if (remaining <= 0) return " · ❄️ cold";
|
|
212
|
+
const minutes = Math.floor(remaining / 60);
|
|
213
|
+
const seconds = Math.floor(remaining % 60);
|
|
214
|
+
return " · 🔥 warm " + minutes + ":" + String(seconds).padStart(2, "0");
|
|
215
|
+
}
|
|
216
|
+
|
|
167
217
|
let penny;
|
|
168
218
|
if (!pennyInstalled) {
|
|
169
219
|
penny = "🪙 penny off";
|
|
170
220
|
} else if (cache && cache.known && cache.bypass) {
|
|
171
221
|
penny = "🪙 penny off";
|
|
172
222
|
} else {
|
|
173
|
-
penny = "🪙 penny" + authSuffix(cache && cache.auth_mode) + askBadges(cache);
|
|
223
|
+
penny = "🪙 penny" + authSuffix(cache && cache.auth_mode) + askBadges(cache) + cacheWarmth(cache);
|
|
174
224
|
}
|
|
175
225
|
|
|
176
226
|
let lines;
|
|
177
|
-
if (config.wrap_command) {
|
|
227
|
+
if (config.wrap_command && sessionId) {
|
|
228
|
+
const wrapCachePath = join(cacheDir, sessionId + ".wrap.json");
|
|
229
|
+
const wrapCache = readJson(wrapCachePath);
|
|
230
|
+
const wrapAge = wrapCache && typeof wrapCache.at === "number" ? Date.now() - wrapCache.at : Infinity;
|
|
231
|
+
if (wrapAge > WRAP_REFRESH_MS) {
|
|
232
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
233
|
+
writeFileSync(join(cacheDir, sessionId + ".wrap-input.json"), raw);
|
|
234
|
+
// Bump the cache timestamp before spawning: Claude Code polls every ~1s, so
|
|
235
|
+
// without this every render in the next couple seconds would see a still-stale
|
|
236
|
+
// cache and fire its own overlapping refresh while the first one is still running.
|
|
237
|
+
writeFileSync(wrapCachePath, JSON.stringify({ lines: (wrapCache && wrapCache.lines) || [], at: Date.now() }));
|
|
238
|
+
spawn(process.execPath, [process.argv[1], "--refresh-wrap", sessionId], {
|
|
239
|
+
detached: true, stdio: "ignore", windowsHide: true,
|
|
240
|
+
}).unref();
|
|
241
|
+
}
|
|
242
|
+
lines = (wrapCache && wrapCache.lines) || [];
|
|
243
|
+
} else if (config.wrap_command) {
|
|
244
|
+
// No session id (e.g. a manual/interactive invocation outside Claude Code): there's
|
|
245
|
+
// no cache key to keep state under, so run inline like before caching existed.
|
|
178
246
|
const run = spawnSync(config.wrap_command, {
|
|
179
|
-
shell: true, input: raw, encoding: "utf8", timeout: 5000,
|
|
247
|
+
shell: true, input: raw, encoding: "utf8", timeout: 5000, windowsHide: true,
|
|
180
248
|
});
|
|
181
249
|
lines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
|
|
182
250
|
if (lines.length === 1 && lines[0] === "") lines = [];
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/* Write a canonical thread into a Codex rollout it will resume from.
|
|
2
|
+
|
|
3
|
+
`penny codex resume <session-id>` reads ~/.codex/sessions/YYYY/MM/DD/rollout-<stamp>-<uuid>.jsonl,
|
|
4
|
+
so a synthesized rollout in that tree resumes like any other session. Verified end-to-end on
|
|
5
|
+
codex-cli 0.147.0: a file written by this shape resumed and the model answered from history it
|
|
6
|
+
never lived.
|
|
7
|
+
|
|
8
|
+
The format differs from Claude Code's in every structural respect, which is why this is a
|
|
9
|
+
separate writer rather than a flag on the other one:
|
|
10
|
+
|
|
11
|
+
- Records are an ordinal-bearing {timestamp, type, payload} envelope, not flat messages.
|
|
12
|
+
- Paginated history uses task and item events grouped by turn id; response_item records alone
|
|
13
|
+
produce an empty thread in 0.147 even though the file itself can be found.
|
|
14
|
+
- The first record is a `session_meta` header carrying cwd, cli_version and the id.
|
|
15
|
+
- User content is `input_text`; assistant content is `output_text`. Using the wrong key
|
|
16
|
+
for a role is the one mistake that produces a file which loads but renders empty.
|
|
17
|
+
- Ids are UUIDv7 (time-ordered), and both the filename stamp and the id encode when the
|
|
18
|
+
session began. Codex sorts its picker by these, so a random v4 sorts to 1970.
|
|
19
|
+
|
|
20
|
+
THE FORMAT IS UNDOCUMENTED, exactly as with Claude Code, and the same three rules apply:
|
|
21
|
+
never overwrite, version-gate, and fail loudly rather than write something that only reveals
|
|
22
|
+
itself as broken at resume time. */
|
|
23
|
+
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
25
|
+
import { mkdirSync, existsSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
import { orientation, renderMessage } from "./thread-session.js";
|
|
30
|
+
|
|
31
|
+
/** Versions this writer has been verified against. */
|
|
32
|
+
export const VERIFIED_MIN = "0.147.0";
|
|
33
|
+
export const VERIFIED_MAX = "0.999.999";
|
|
34
|
+
|
|
35
|
+
/** UUIDv7: 48-bit big-endian millisecond timestamp, then version/variant bits, then random.
|
|
36
|
+
Codex orders sessions by this, so a v4 would sort as if it began in 1970. */
|
|
37
|
+
export function uuidv7(now = Date.now()) {
|
|
38
|
+
const bytes = randomBytes(16);
|
|
39
|
+
const ms = BigInt(now);
|
|
40
|
+
for (let i = 0; i < 6; i += 1) {
|
|
41
|
+
bytes[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn);
|
|
42
|
+
}
|
|
43
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
44
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
45
|
+
const hex = bytes.toString("hex");
|
|
46
|
+
return [
|
|
47
|
+
hex.slice(0, 8),
|
|
48
|
+
hex.slice(8, 12),
|
|
49
|
+
hex.slice(12, 16),
|
|
50
|
+
hex.slice(16, 20),
|
|
51
|
+
hex.slice(20),
|
|
52
|
+
].join("-");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function compare(a, b) {
|
|
56
|
+
const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
|
|
57
|
+
const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
|
|
58
|
+
for (let i = 0; i < 3; i += 1) {
|
|
59
|
+
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0) ? -1 : 1;
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function versionSupported(version) {
|
|
65
|
+
if (!version) return false;
|
|
66
|
+
// `codex --version` prints "codex-cli 0.147.0"; take the numeric field wherever it sits.
|
|
67
|
+
const match = String(version).match(/(\d+\.\d+\.\d+)/);
|
|
68
|
+
if (!match) return false;
|
|
69
|
+
return compare(match[1], VERIFIED_MIN) >= 0 && compare(match[1], VERIFIED_MAX) <= 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** ~/.codex/sessions/YYYY/MM/DD — Codex partitions rollouts by local date. */
|
|
73
|
+
export function sessionDirFor(date = new Date()) {
|
|
74
|
+
const yyyy = String(date.getFullYear());
|
|
75
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
76
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
77
|
+
return join(homedir(), ".codex", "sessions", yyyy, mm, dd);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The local-wall-time stamp embedded in a rollout filename: 2026-08-09T22-15-30. */
|
|
81
|
+
export function fileStamp(date = new Date()) {
|
|
82
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
83
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
84
|
+
`T${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildRecords(thread, { cwd, sessionId, version, timestamp = new Date() }) {
|
|
88
|
+
const ts = timestamp.toISOString();
|
|
89
|
+
const epochSeconds = Math.floor(timestamp.getTime() / 1000);
|
|
90
|
+
let ordinal = 0;
|
|
91
|
+
const envelope = (type, payload) => ({ timestamp: ts, ordinal: ordinal++, type, payload });
|
|
92
|
+
|
|
93
|
+
const message = (role, text, turnId, { final = false } = {}) => ({
|
|
94
|
+
type: "message",
|
|
95
|
+
id: `msg_${uuidv7(timestamp.getTime())}`,
|
|
96
|
+
role,
|
|
97
|
+
content: [{ type: role === "assistant" ? "output_text" : "input_text", text }],
|
|
98
|
+
...(final ? { phase: "final_answer" } : {}),
|
|
99
|
+
internal_chat_message_metadata_passthrough: { turn_id: turnId },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Keep the import handoff out of the visible conversation. Codex accepts a developer
|
|
103
|
+
// response_item in a rollout; it is injected as context but is not rendered as a user turn.
|
|
104
|
+
// The imported transcript should begin with the source's actual first user message.
|
|
105
|
+
const turns = [];
|
|
106
|
+
for (const entry of thread.messages || []) {
|
|
107
|
+
const text = renderMessage(entry);
|
|
108
|
+
if (!text) continue;
|
|
109
|
+
if (entry.role === "user") turns.push({ user: text, assistants: [] });
|
|
110
|
+
else if (entry.role === "assistant") {
|
|
111
|
+
if (!turns.length) turns.push({ user: "Continue the imported conversation.", assistants: [] });
|
|
112
|
+
turns.at(-1).assistants.push(text);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const records = [
|
|
117
|
+
envelope("session_meta", {
|
|
118
|
+
session_id: sessionId,
|
|
119
|
+
id: sessionId,
|
|
120
|
+
timestamp: ts,
|
|
121
|
+
cwd,
|
|
122
|
+
originator: "pennyrouter",
|
|
123
|
+
cli_version: String(version || "").match(/(\d+\.\d+\.\d+)/)?.[1] || "0.0.0",
|
|
124
|
+
source: "cli",
|
|
125
|
+
thread_source: "user",
|
|
126
|
+
model_provider: "pennyrouter",
|
|
127
|
+
history_mode: "paginated",
|
|
128
|
+
}),
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
for (const [turnIndex, turn] of turns.entries()) {
|
|
132
|
+
const turnId = uuidv7(timestamp.getTime());
|
|
133
|
+
records.push(envelope("event_msg", {
|
|
134
|
+
type: "task_started",
|
|
135
|
+
turn_id: turnId,
|
|
136
|
+
started_at: epochSeconds,
|
|
137
|
+
model_context_window: null,
|
|
138
|
+
collaboration_mode_kind: "default",
|
|
139
|
+
}));
|
|
140
|
+
if (turnIndex === 0) {
|
|
141
|
+
records.push(envelope("response_item", message("developer", orientation(thread), turnId)));
|
|
142
|
+
}
|
|
143
|
+
const userMessage = message("user", turn.user, turnId);
|
|
144
|
+
records.push(envelope("response_item", userMessage));
|
|
145
|
+
records.push(envelope("event_msg", {
|
|
146
|
+
type: "item_completed",
|
|
147
|
+
thread_id: sessionId,
|
|
148
|
+
turn_id: turnId,
|
|
149
|
+
item: {
|
|
150
|
+
type: "UserMessage",
|
|
151
|
+
id: uuidv7(timestamp.getTime()),
|
|
152
|
+
content: [{ type: "text", text: turn.user, text_elements: [] }],
|
|
153
|
+
},
|
|
154
|
+
started_at_ms: timestamp.getTime(),
|
|
155
|
+
completed_at_ms: timestamp.getTime(),
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
let lastAssistant = "";
|
|
159
|
+
for (const text of turn.assistants) {
|
|
160
|
+
lastAssistant = text;
|
|
161
|
+
const assistantMessage = message("assistant", text, turnId, { final: true });
|
|
162
|
+
records.push(envelope("event_msg", {
|
|
163
|
+
type: "item_completed",
|
|
164
|
+
thread_id: sessionId,
|
|
165
|
+
turn_id: turnId,
|
|
166
|
+
item: {
|
|
167
|
+
type: "AgentMessage",
|
|
168
|
+
id: assistantMessage.id,
|
|
169
|
+
content: [{ type: "Text", text }],
|
|
170
|
+
phase: "final_answer",
|
|
171
|
+
},
|
|
172
|
+
started_at_ms: timestamp.getTime(),
|
|
173
|
+
completed_at_ms: timestamp.getTime(),
|
|
174
|
+
}));
|
|
175
|
+
records.push(envelope("response_item", assistantMessage));
|
|
176
|
+
}
|
|
177
|
+
records.push(envelope("event_msg", {
|
|
178
|
+
type: "task_complete",
|
|
179
|
+
turn_id: turnId,
|
|
180
|
+
last_agent_message: lastAssistant,
|
|
181
|
+
started_at: epochSeconds,
|
|
182
|
+
completed_at: epochSeconds,
|
|
183
|
+
duration_ms: 0,
|
|
184
|
+
time_to_first_token_ms: 0,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return records;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Write `thread` as a resumable Codex rollout.
|
|
193
|
+
* Returns { sessionId, path, messageCount }.
|
|
194
|
+
*/
|
|
195
|
+
export function writeSession(thread, { cwd, version, timestamp = new Date() }) {
|
|
196
|
+
if (!versionSupported(version)) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Codex ${version || "(unknown version)"} is outside the range this importer has been ` +
|
|
199
|
+
`verified against (${VERIFIED_MIN}–${VERIFIED_MAX}). Refusing to write a rollout that ` +
|
|
200
|
+
`may not resume.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const dir = sessionDirFor(timestamp);
|
|
205
|
+
mkdirSync(dir, { recursive: true });
|
|
206
|
+
|
|
207
|
+
const sessionId = uuidv7(timestamp.getTime());
|
|
208
|
+
const path = join(dir, `rollout-${fileStamp(timestamp)}-${sessionId}.jsonl`);
|
|
209
|
+
if (existsSync(path)) throw new Error("session id collision; try again");
|
|
210
|
+
|
|
211
|
+
const records = buildRecords(thread, { cwd, sessionId, version, timestamp });
|
|
212
|
+
writeFileSync(path, records.map((r) => JSON.stringify(r)).join("\n") + "\n", {
|
|
213
|
+
encoding: "utf8",
|
|
214
|
+
flag: "wx", // fail rather than clobber, even against a race
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return { sessionId, path, messageCount: records.length };
|
|
218
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildRecords,
|
|
8
|
+
fileStamp,
|
|
9
|
+
sessionDirFor,
|
|
10
|
+
uuidv7,
|
|
11
|
+
versionSupported,
|
|
12
|
+
writeSession,
|
|
13
|
+
} from "./thread-session-codex.js";
|
|
14
|
+
|
|
15
|
+
const thread = {
|
|
16
|
+
title: "Kickplate removal",
|
|
17
|
+
source: "perplexity_share",
|
|
18
|
+
origin_app: "Perplexity",
|
|
19
|
+
source_url: "https://www.perplexity.ai/search/x",
|
|
20
|
+
messages: [
|
|
21
|
+
{ role: "user", blocks: [{ type: "text", text: "USER-TURN" }] },
|
|
22
|
+
{
|
|
23
|
+
role: "assistant",
|
|
24
|
+
blocks: [
|
|
25
|
+
{ type: "thinking", text: "REASONING-TRACE" },
|
|
26
|
+
{ type: "text", text: "ASSISTANT-TURN" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{ role: "system", blocks: [{ type: "text", text: "SYSTEM-PREAMBLE" }] },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// -- uuidv7 ---------------------------------------------------------------
|
|
34
|
+
// Codex sorts its session picker by these ids. A random v4 would sort as if the session began
|
|
35
|
+
// in 1970, so the timestamp prefix is not cosmetic.
|
|
36
|
+
{
|
|
37
|
+
const when = Date.UTC(2026, 7, 9, 22, 15, 30);
|
|
38
|
+
const id = uuidv7(when);
|
|
39
|
+
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
40
|
+
assert.equal(parseInt(id.slice(0, 8) + id.slice(9, 13), 16), when, "ms timestamp is encoded");
|
|
41
|
+
assert.notEqual(uuidv7(when), uuidv7(when), "the random tail still varies");
|
|
42
|
+
// Time-ordering is the property Codex depends on.
|
|
43
|
+
assert.ok(uuidv7(when) < uuidv7(when + 1000));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// -- paths ----------------------------------------------------------------
|
|
47
|
+
{
|
|
48
|
+
const originalTz = process.env.TZ;
|
|
49
|
+
process.env.TZ = "America/New_York";
|
|
50
|
+
try {
|
|
51
|
+
// Codex filenames use local wall time, including when UTC crossed into the next day.
|
|
52
|
+
const date = new Date(Date.UTC(2026, 7, 10, 2, 15, 30));
|
|
53
|
+
assert.ok(sessionDirFor(date).endsWith(join(".codex", "sessions", "2026", "08", "09")));
|
|
54
|
+
assert.equal(fileStamp(date), "2026-08-09T22-15-30");
|
|
55
|
+
} finally {
|
|
56
|
+
if (originalTz === undefined) delete process.env.TZ;
|
|
57
|
+
else process.env.TZ = originalTz;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// -- version gate ---------------------------------------------------------
|
|
62
|
+
// `codex --version` prints "codex-cli 0.147.0", so the number has to be found, not assumed.
|
|
63
|
+
assert.equal(versionSupported("codex-cli 0.147.0"), true);
|
|
64
|
+
assert.equal(versionSupported("0.146.0"), false);
|
|
65
|
+
assert.equal(versionSupported("1.0.0"), false);
|
|
66
|
+
assert.equal(versionSupported("not-a-version"), false);
|
|
67
|
+
assert.equal(versionSupported(null), false);
|
|
68
|
+
|
|
69
|
+
// -- record construction --------------------------------------------------
|
|
70
|
+
{
|
|
71
|
+
const records = buildRecords(thread, {
|
|
72
|
+
cwd: "/tmp/ws",
|
|
73
|
+
sessionId: "sid-1",
|
|
74
|
+
version: "codex-cli 0.147.0",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Codex 0.147 uses an ordinal-bearing paginated rollout envelope.
|
|
78
|
+
for (const record of records) {
|
|
79
|
+
assert.deepEqual(Object.keys(record).sort(), ["ordinal", "payload", "timestamp", "type"]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [meta, ...rest] = records;
|
|
83
|
+
assert.equal(meta.type, "session_meta");
|
|
84
|
+
assert.equal(meta.payload.session_id, "sid-1");
|
|
85
|
+
assert.equal(meta.payload.cwd, "/tmp/ws");
|
|
86
|
+
assert.equal(meta.payload.cli_version, "0.147.0", "the bare number, not the whole string");
|
|
87
|
+
assert.equal(meta.payload.history_mode, "paginated");
|
|
88
|
+
|
|
89
|
+
// Content key is role-dependent. Getting this wrong yields a rollout that loads and renders
|
|
90
|
+
// empty, which is the failure mode least likely to be noticed before a user hits it.
|
|
91
|
+
const messages = rest.filter((record) => record.type === "response_item");
|
|
92
|
+
for (const record of messages) {
|
|
93
|
+
assert.equal(record.payload.type, "message");
|
|
94
|
+
const key = record.payload.content[0].type;
|
|
95
|
+
assert.equal(key, record.payload.role === "assistant" ? "output_text" : "input_text");
|
|
96
|
+
assert.match(record.payload.id, /^msg_/);
|
|
97
|
+
assert.ok(record.payload.internal_chat_message_metadata_passthrough.turn_id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
assert.deepEqual(
|
|
101
|
+
messages.map((r) => r.payload.role),
|
|
102
|
+
["developer", "user", "assistant"],
|
|
103
|
+
);
|
|
104
|
+
assert.equal(messages[0].payload.role, "developer");
|
|
105
|
+
assert.ok(messages[0].payload.content[0].text.includes("Perplexity"), "handoff is developer context");
|
|
106
|
+
assert.equal(messages[1].payload.role, "user");
|
|
107
|
+
assert.equal(rest.filter((r) => r.payload.type === "task_started").length, 1);
|
|
108
|
+
assert.equal(rest.filter((r) => r.payload.type === "task_complete").length, 1);
|
|
109
|
+
assert.equal(rest.filter((r) => r.payload.type === "item_completed").length, 2);
|
|
110
|
+
|
|
111
|
+
const serialized = JSON.stringify(records);
|
|
112
|
+
assert.ok(!serialized.includes("REASONING-TRACE"), "thinking must never reach a handoff");
|
|
113
|
+
assert.ok(!serialized.includes("SYSTEM-PREAMBLE"), "system turns are not replayed");
|
|
114
|
+
assert.ok(serialized.includes("USER-TURN") && serialized.includes("ASSISTANT-TURN"));
|
|
115
|
+
assert.ok(!messages.some((r) => r.payload.role === "user" && r.payload.content[0].text.includes("This session was imported")));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// -- writing --------------------------------------------------------------
|
|
119
|
+
{
|
|
120
|
+
const home = mkdtempSync(join(tmpdir(), "pr-codex-home-"));
|
|
121
|
+
const originalHome = process.env.HOME;
|
|
122
|
+
process.env.HOME = home;
|
|
123
|
+
try {
|
|
124
|
+
const result = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.147.0" });
|
|
125
|
+
|
|
126
|
+
// The filename carries both the stamp and the id; Codex's picker reads both.
|
|
127
|
+
assert.match(result.path, /rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-[0-9a-f-]{36}\.jsonl$/);
|
|
128
|
+
assert.ok(result.path.includes(result.sessionId));
|
|
129
|
+
|
|
130
|
+
const lines = readFileSync(result.path, "utf8").trim().split("\n");
|
|
131
|
+
assert.equal(lines.length, result.messageCount);
|
|
132
|
+
const parsed = lines.map((l) => JSON.parse(l));
|
|
133
|
+
assert.equal(parsed[0].type, "session_meta");
|
|
134
|
+
assert.equal(parsed[0].payload.id, result.sessionId);
|
|
135
|
+
|
|
136
|
+
// Never overwrite an existing session.
|
|
137
|
+
const again = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.147.0" });
|
|
138
|
+
assert.notEqual(again.sessionId, result.sessionId);
|
|
139
|
+
assert.notEqual(again.path, result.path);
|
|
140
|
+
|
|
141
|
+
assert.throws(
|
|
142
|
+
() => writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 2.0.0" }),
|
|
143
|
+
/outside the range/,
|
|
144
|
+
);
|
|
145
|
+
assert.throws(() => writeSession(thread, { cwd: "/tmp/ws", version: null }), /outside the range/);
|
|
146
|
+
} finally {
|
|
147
|
+
process.env.HOME = originalHome;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log("thread-session-codex tests passed");
|