pennyrouter 0.3.0 → 0.3.2

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/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");