pennyrouter 0.2.14 → 0.2.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.14",
3
+ "version": "0.2.17",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
package/src/cli.js CHANGED
@@ -690,7 +690,11 @@ async function serveMcp(flags) {
690
690
  || process.env.PENNYROUTER_GATEWAY_BASE_URL
691
691
  || "https://api.pennyrouter.com";
692
692
 
693
- await runMcpServer({ gatewayBaseUrl, apiKey });
693
+ await runMcpServer({
694
+ gatewayBaseUrl,
695
+ apiKey,
696
+ harness: process.env.PENNYROUTER_MCP_HARNESS,
697
+ });
694
698
  }
695
699
 
696
700
  // Refresh the managed local files to this package version WITHOUT re-running any of the
@@ -1838,7 +1842,7 @@ function printHelp() {
1838
1842
 
1839
1843
  Usage:
1840
1844
  pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]] [--allow-running-harnesses]
1841
- pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL] [--no-mcp] [--allow-running-harnesses]
1845
+ pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]] [--no-mcp] [--allow-running-harnesses]
1842
1846
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1843
1847
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1844
1848
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -1865,6 +1869,8 @@ Examples:
1865
1869
  npx pennyrouter update
1866
1870
  npx pennyrouter update --dry-run
1867
1871
  npx pennyrouter update --harness claude-code
1872
+ npx pennyrouter update --harness codex --local
1873
+ npx pennyrouter update --all --local
1868
1874
  npx pennyrouter install
1869
1875
  npx pennyrouter install --make-default
1870
1876
  npx pennyrouter install --penny-only
@@ -30,8 +30,12 @@ const MANAGED_START = "# --- PennyRouter managed MCP server ---";
30
30
  const MANAGED_END = "# --- end PennyRouter managed MCP server ---";
31
31
 
32
32
  /** What we register. Shared by both harnesses so the two configs cannot drift apart. */
33
- export function serverEntry() {
34
- return { command: "npx", args: ["-y", "pennyrouter", "mcp"] };
33
+ export function serverEntry(harness = "claude-code") {
34
+ return {
35
+ command: "npx",
36
+ args: ["-y", "pennyrouter", "mcp"],
37
+ env: { PENNYROUTER_MCP_HARNESS: harness },
38
+ };
35
39
  }
36
40
 
37
41
  /** True when an entry is one of ours, so re-installing updates rather than duplicating and
@@ -59,7 +63,7 @@ export function applyClaudeRegistry(config, { remove = false } = {}) {
59
63
  "write. Rename it before installing so your configuration is not overwritten.",
60
64
  );
61
65
  }
62
- servers[SERVER_NAME] = serverEntry();
66
+ servers[SERVER_NAME] = serverEntry("claude-code");
63
67
  }
64
68
 
65
69
  if (Object.keys(servers).length) next.mcpServers = servers;
@@ -96,12 +100,13 @@ export function renderCodexMcp(text, { remove = false } = {}) {
96
100
  );
97
101
  }
98
102
 
99
- const entry = serverEntry();
103
+ const entry = serverEntry("codex");
100
104
  const block = [
101
105
  MANAGED_START,
102
106
  `[mcp_servers.${SERVER_NAME}]`,
103
107
  `command = "${entry.command}"`,
104
108
  `args = [${entry.args.map((a) => `"${a}"`).join(", ")}]`,
109
+ `env = { PENNYROUTER_MCP_HARNESS = "${entry.env.PENNYROUTER_MCP_HARNESS}" }`,
105
110
  MANAGED_END,
106
111
  ].join("\n");
107
112
 
@@ -17,6 +17,7 @@ import {
17
17
  const entry = serverEntry();
18
18
  assert.equal(entry.command, "npx");
19
19
  assert.deepEqual(entry.args, ["-y", "pennyrouter", "mcp"]);
20
+ assert.equal(entry.env.PENNYROUTER_MCP_HARNESS, "claude-code");
20
21
  assert.ok(!JSON.stringify(entry).includes("/"), "no absolute paths");
21
22
  assert.ok(!/pennyrouter@/.test(JSON.stringify(entry)), "not version-pinned");
22
23
  }
@@ -85,6 +86,7 @@ assert.equal(isManagedEntry({}), false);
85
86
  assert.ok(written.includes(`[mcp_servers.${SERVER_NAME}]`));
86
87
  assert.ok(written.includes('command = "npx"'));
87
88
  assert.ok(written.includes('args = ["-y", "pennyrouter", "mcp"]'));
89
+ assert.ok(written.includes('PENNYROUTER_MCP_HARNESS = "codex"'));
88
90
  assert.ok(written.includes("[mcp_servers.node_repl]"), "existing servers survive");
89
91
 
90
92
  // A TOML table header captures every key after it, so our block must be appended — splicing
package/src/mcp.js CHANGED
@@ -30,6 +30,13 @@ import { writeSession, VERIFIED_MIN, VERIFIED_MAX } from "./thread-session.js";
30
30
  import { writeSession as writeCodexSession } from "./thread-session-codex.js";
31
31
 
32
32
  const PROTOCOL_VERSION = "2024-11-05";
33
+ const SERVER_INSTRUCTIONS =
34
+ "When the user provides an AI share link and asks to resume, continue, import, or build from " +
35
+ "that conversation in a coding agent, call a PennyRouter thread tool immediately. For the exact " +
36
+ "command '@penny import session <link>', use thread_read when it is the first user turn so the " +
37
+ "conversation enters the current session; otherwise use thread_resume to create a fresh native " +
38
+ "session. Do not search the local workspace, fetch the link directly, or look for another resume " +
39
+ "utility first.";
33
40
 
34
41
  const TOOLS = [
35
42
  {
@@ -39,6 +46,8 @@ const TOOLS = [
39
46
  "return its transcript. Use when the user wants to discuss, summarize or ask about a " +
40
47
  "past conversation. The transcript enters your context — for long threads that are " +
41
48
  "meant to be continued as work, prefer thread_resume. " +
49
+ "For `@penny import session <link>` on the FIRST user turn, call this tool immediately: " +
50
+ "the desired result is to continue in the current session. " +
42
51
  "This tool is the only supported way to read a share link: those pages are " +
43
52
  "JavaScript-rendered shells that return markup, not conversation, to a plain fetch. Never " +
44
53
  "fall back to fetching the URL directly, and never guess at the contents. If this call " +
@@ -57,8 +66,13 @@ const TOOLS = [
57
66
  description:
58
67
  "Fetch an AI conversation from a share link and write it as a resumable coding-agent " +
59
68
  "session (Claude Code or Codex), so the user can resume into it and continue coding with " +
60
- "that conversation as history. Use when the user wants to CONTINUE or BUILD FROM a past " +
61
- "chat rather than talk about it. Returns a session id and path; the transcript is " +
69
+ "that conversation as history. When the user asks to RESUME, CONTINUE, IMPORT, or BUILD " +
70
+ "FROM a share link, call this tool immediately — do not search the workspace or look for " +
71
+ "another resume utility first. Use this instead of thread_read when they want to work from " +
72
+ "the conversation rather than discuss it. For `@penny import session <link>` AFTER the " +
73
+ "first user turn, call this tool: a new native session avoids injecting a large transcript " +
74
+ "into an active conversation. The configured harness is the default; do not guess the other " +
75
+ "one. Returns a session id and path; the transcript is " +
62
76
  "deliberately NOT returned, so importing a long thread costs you no context. That is by " +
63
77
  "design and is not a failure — do not read the thread another way to 'check' it, and do " +
64
78
  "not fetch the share URL directly. Report the resume command to the user. If this call " +
@@ -77,8 +91,8 @@ const TOOLS = [
77
91
  type: "string",
78
92
  enum: ["claude-code", "codex"],
79
93
  description:
80
- "Which agent should be able to resume it. Defaults to claude-code. Use codex when " +
81
- "the user asks to continue the conversation in Codex.",
94
+ "Which agent should be able to resume it. Defaults to the harness that launched this " +
95
+ "MCP server. Supply this only to override that active harness.",
82
96
  },
83
97
  },
84
98
  required: ["url"],
@@ -153,7 +167,7 @@ async function callTool(name, args, context) {
153
167
 
154
168
  if (name === "thread_resume") {
155
169
  const cwd = String(args?.directory || process.cwd());
156
- const harness = String(args?.harness || "claude-code").toLowerCase();
170
+ const harness = String(args?.harness || context.harness || "claude-code").toLowerCase();
157
171
  if (harness !== "claude-code" && harness !== "codex") {
158
172
  throw new Error(`unknown harness "${harness}"; expected claude-code or codex`);
159
173
  }
@@ -173,7 +187,7 @@ async function callTool(name, args, context) {
173
187
  "",
174
188
  "Resume it with:",
175
189
  codex
176
- ? ` cd ${cwd} && codex resume ${sessionId}`
190
+ ? ` cd ${cwd} && penny codex resume ${sessionId}`
177
191
  : ` cd ${cwd} && claude --resume ${sessionId}`,
178
192
  "",
179
193
  codex
@@ -204,7 +218,7 @@ function toolError(id, error) {
204
218
  });
205
219
  }
206
220
 
207
- export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
221
+ export async function runMcpServer({ gatewayBaseUrl, apiKey, harness = process.env.PENNYROUTER_MCP_HARNESS }) {
208
222
  if (!apiKey) {
209
223
  process.stderr.write(
210
224
  "pennyrouter mcp: no API key. Run `npx pennyrouter install` first.\n",
@@ -213,7 +227,7 @@ export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
213
227
  return;
214
228
  }
215
229
 
216
- const context = { gatewayBaseUrl, apiKey };
230
+ const context = { gatewayBaseUrl, apiKey, harness };
217
231
  const rl = createInterface({ input: process.stdin });
218
232
 
219
233
  for await (const line of rl) {
@@ -233,6 +247,7 @@ export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
233
247
  protocolVersion: PROTOCOL_VERSION,
234
248
  capabilities: { tools: {} },
235
249
  serverInfo: { name: "pennyrouter", version: "1.0.0" },
250
+ instructions: SERVER_INSTRUCTIONS,
236
251
  });
237
252
  } else if (method === "tools/list") {
238
253
  reply(id, { tools: TOOLS });
package/src/mcp.test.js CHANGED
@@ -41,12 +41,15 @@ function stubGateway(handler) {
41
41
  }
42
42
 
43
43
  /** Drive the server over stdio and collect the responses to `requests`. */
44
- function rpc(requests, { port, home }) {
44
+ function rpc(requests, { port, home, harness }) {
45
45
  return new Promise((resolve, reject) => {
46
46
  const child = spawn(
47
47
  process.execPath,
48
48
  [binary, "mcp", "--penny-key", "pr-test", "--gateway-base-url", `http://127.0.0.1:${port}`],
49
- { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, HOME: home } },
49
+ {
50
+ stdio: ["pipe", "pipe", "pipe"],
51
+ env: { ...process.env, HOME: home, ...(harness ? { PENNYROUTER_MCP_HARNESS: harness } : {}) },
52
+ },
50
53
  );
51
54
  let out = "";
52
55
  let err = "";
@@ -108,6 +111,8 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
108
111
 
109
112
  assert.equal(byId.get(1).result.protocolVersion, "2024-11-05");
110
113
  assert.equal(byId.get(1).result.serverInfo.name, "pennyrouter");
114
+ assert.match(byId.get(1).result.instructions, /thread_read when it is the first user turn/i);
115
+ assert.match(byId.get(1).result.instructions, /otherwise use thread_resume/i);
111
116
 
112
117
  assert.deepEqual(
113
118
  byId.get(2).result.tools.map((t) => t.name),
@@ -122,6 +127,10 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
122
127
  assert.match(tool.description, /not fetch|Never fall back to fetching/i, tool.name);
123
128
  assert.match(tool.description, /blocked/i, tool.name);
124
129
  }
130
+ assert.match(
131
+ byId.get(2).result.tools.find((tool) => tool.name === "thread_resume").description,
132
+ /do not search the workspace/i,
133
+ );
125
134
 
126
135
  // The URL is forwarded to the gateway under the caller's key; no provider logic runs here.
127
136
  assert.equal(JSON.parse(requestBody).url, "https://www.perplexity.ai/search/x");
@@ -161,7 +170,7 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
161
170
  method: "tools/call",
162
171
  params: {
163
172
  name: "thread_resume",
164
- arguments: { url: "https://x/y", directory: workspace, harness: "codex" },
173
+ arguments: { url: "https://x/y", directory: workspace },
165
174
  },
166
175
  },
167
176
  {
@@ -174,7 +183,7 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
174
183
  },
175
184
  },
176
185
  ],
177
- { port, home },
186
+ { port, home, harness: "codex" },
178
187
  );
179
188
  server.close();
180
189
 
@@ -187,7 +196,8 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
187
196
  if (codex.isError) {
188
197
  assert.match(codex.content[0].text, /Codex .*outside the range|ENOENT|not.*install/i);
189
198
  } else {
190
- assert.ok(codex.content[0].text.includes("codex resume"), "prints the codex resume command");
199
+ assert.ok(codex.content[0].text.includes("penny codex resume"),
200
+ "prints the Penny-profile Codex resume command");
191
201
  assert.ok(!codex.content[0].text.includes("claude --resume"));
192
202
  assert.ok(!codex.content[0].text.includes("UNIQUE-QUESTION"), "still returns no transcript");
193
203
  }
@@ -1,15 +1,16 @@
1
1
  /* Write a canonical thread into a Codex rollout it will resume from.
2
2
 
3
- `codex resume <session-id>` reads ~/.codex/sessions/YYYY/MM/DD/rollout-<stamp>-<uuid>.jsonl,
3
+ `penny codex resume <session-id>` reads ~/.codex/sessions/YYYY/MM/DD/rollout-<stamp>-<uuid>.jsonl,
4
4
  so a synthesized rollout in that tree resumes like any other session. Verified end-to-end on
5
- codex-cli 0.146.0: a file written by this shape resumed and the model answered from history it
5
+ codex-cli 0.147.0: a file written by this shape resumed and the model answered from history it
6
6
  never lived.
7
7
 
8
8
  The format differs from Claude Code's in every structural respect, which is why this is a
9
9
  separate writer rather than a flag on the other one:
10
10
 
11
- - Records are a {timestamp, type, payload} envelope, not flat message objects.
12
- - There is no parentUuid chain. Order in the file IS the order; nothing to link.
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.
13
14
  - The first record is a `session_meta` header carrying cwd, cli_version and the id.
14
15
  - User content is `input_text`; assistant content is `output_text`. Using the wrong key
15
16
  for a role is the one mistake that produces a file which loads but renders empty.
@@ -28,7 +29,7 @@ import { join } from "node:path";
28
29
  import { orientation, renderMessage } from "./thread-session.js";
29
30
 
30
31
  /** Versions this writer has been verified against. */
31
- export const VERIFIED_MIN = "0.100.0";
32
+ export const VERIFIED_MIN = "0.147.0";
32
33
  export const VERIFIED_MAX = "0.999.999";
33
34
 
34
35
  /** UUIDv7: 48-bit big-endian millisecond timestamp, then version/variant bits, then random.
@@ -62,38 +63,55 @@ function compare(a, b) {
62
63
 
63
64
  export function versionSupported(version) {
64
65
  if (!version) return false;
65
- // `codex --version` prints "codex-cli 0.146.0"; take the numeric field wherever it sits.
66
+ // `codex --version` prints "codex-cli 0.147.0"; take the numeric field wherever it sits.
66
67
  const match = String(version).match(/(\d+\.\d+\.\d+)/);
67
68
  if (!match) return false;
68
69
  return compare(match[1], VERIFIED_MIN) >= 0 && compare(match[1], VERIFIED_MAX) <= 0;
69
70
  }
70
71
 
71
- /** ~/.codex/sessions/YYYY/MM/DD — Codex partitions rollouts by date. */
72
+ /** ~/.codex/sessions/YYYY/MM/DD — Codex partitions rollouts by local date. */
72
73
  export function sessionDirFor(date = new Date()) {
73
- const yyyy = String(date.getUTCFullYear());
74
- const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
75
- const dd = String(date.getUTCDate()).padStart(2, "0");
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");
76
77
  return join(homedir(), ".codex", "sessions", yyyy, mm, dd);
77
78
  }
78
79
 
79
- /** The stamp embedded in a rollout filename: 2026-08-09T22-15-30. */
80
+ /** The local-wall-time stamp embedded in a rollout filename: 2026-08-09T22-15-30. */
80
81
  export function fileStamp(date = new Date()) {
81
- return date.toISOString().replace(/\.\d+Z$/, "").replace(/:/g, "-");
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())}`;
82
85
  }
83
86
 
84
87
  export function buildRecords(thread, { cwd, sessionId, version, timestamp = new Date() }) {
85
88
  const ts = timestamp.toISOString();
86
- const envelope = (type, payload) => ({ timestamp: ts, type, payload });
87
-
88
- const message = (role, text) =>
89
- envelope("response_item", {
90
- type: "message",
91
- id: `msg_${uuidv7(timestamp.getTime())}`,
92
- role,
93
- // The content key is role-dependent. Swapping them yields a file that loads and shows
94
- // nothing, which is the worst failure mode available here.
95
- content: [{ type: role === "assistant" ? "output_text" : "input_text", text }],
96
- });
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
+ const turns = [{
103
+ user: orientation(thread),
104
+ assistants: ["Understood — I have the imported conversation and will continue from it."],
105
+ }];
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
+ }
97
115
 
98
116
  const records = [
99
117
  envelope("session_meta", {
@@ -105,18 +123,63 @@ export function buildRecords(thread, { cwd, sessionId, version, timestamp = new
105
123
  cli_version: String(version || "").match(/(\d+\.\d+\.\d+)/)?.[1] || "0.0.0",
106
124
  source: "cli",
107
125
  thread_source: "user",
108
- model_provider: "openai",
109
- history_mode: "legacy",
126
+ model_provider: "pennyrouter",
127
+ history_mode: "paginated",
110
128
  }),
111
- message("user", orientation(thread)),
112
- message("assistant", "Understood — I have the imported conversation and will continue from it."),
113
129
  ];
114
130
 
115
- for (const entry of thread.messages || []) {
116
- const text = renderMessage(entry);
117
- if (!text) continue;
118
- if (entry.role === "assistant") records.push(message("assistant", text));
119
- else if (entry.role === "user") records.push(message("user", text));
131
+ for (const turn of turns) {
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
+ const userMessage = message("user", turn.user, turnId);
141
+ records.push(envelope("response_item", userMessage));
142
+ records.push(envelope("event_msg", {
143
+ type: "item_completed",
144
+ thread_id: sessionId,
145
+ turn_id: turnId,
146
+ item: {
147
+ type: "UserMessage",
148
+ id: uuidv7(timestamp.getTime()),
149
+ content: [{ type: "text", text: turn.user, text_elements: [] }],
150
+ },
151
+ started_at_ms: timestamp.getTime(),
152
+ completed_at_ms: timestamp.getTime(),
153
+ }));
154
+
155
+ let lastAssistant = "";
156
+ for (const text of turn.assistants) {
157
+ lastAssistant = text;
158
+ const assistantMessage = message("assistant", text, turnId, { final: true });
159
+ records.push(envelope("event_msg", {
160
+ type: "item_completed",
161
+ thread_id: sessionId,
162
+ turn_id: turnId,
163
+ item: {
164
+ type: "AgentMessage",
165
+ id: assistantMessage.id,
166
+ content: [{ type: "Text", text }],
167
+ phase: "final_answer",
168
+ },
169
+ started_at_ms: timestamp.getTime(),
170
+ completed_at_ms: timestamp.getTime(),
171
+ }));
172
+ records.push(envelope("response_item", assistantMessage));
173
+ }
174
+ records.push(envelope("event_msg", {
175
+ type: "task_complete",
176
+ turn_id: turnId,
177
+ last_agent_message: lastAssistant,
178
+ started_at: epochSeconds,
179
+ completed_at: epochSeconds,
180
+ duration_ms: 0,
181
+ time_to_first_token_ms: 0,
182
+ }));
120
183
  }
121
184
 
122
185
  return records;
@@ -45,16 +45,23 @@ const thread = {
45
45
 
46
46
  // -- paths ----------------------------------------------------------------
47
47
  {
48
- const date = new Date(Date.UTC(2026, 7, 9, 22, 15, 30));
49
- assert.ok(sessionDirFor(date).endsWith(join(".codex", "sessions", "2026", "08", "09")));
50
- assert.equal(fileStamp(date), "2026-08-09T22-15-30");
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
+ }
51
59
  }
52
60
 
53
61
  // -- version gate ---------------------------------------------------------
54
- // `codex --version` prints "codex-cli 0.146.0", so the number has to be found, not assumed.
55
- assert.equal(versionSupported("codex-cli 0.146.0"), true);
56
- assert.equal(versionSupported("0.100.0"), true);
57
- assert.equal(versionSupported("0.99.0"), false);
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);
58
65
  assert.equal(versionSupported("1.0.0"), false);
59
66
  assert.equal(versionSupported("not-a-version"), false);
60
67
  assert.equal(versionSupported(null), false);
@@ -64,40 +71,45 @@ assert.equal(versionSupported(null), false);
64
71
  const records = buildRecords(thread, {
65
72
  cwd: "/tmp/ws",
66
73
  sessionId: "sid-1",
67
- version: "codex-cli 0.146.0",
74
+ version: "codex-cli 0.147.0",
68
75
  });
69
76
 
70
- // Every record is the {timestamp, type, payload} envelope.
77
+ // Codex 0.147 uses an ordinal-bearing paginated rollout envelope.
71
78
  for (const record of records) {
72
- assert.deepEqual(Object.keys(record).sort(), ["payload", "timestamp", "type"]);
79
+ assert.deepEqual(Object.keys(record).sort(), ["ordinal", "payload", "timestamp", "type"]);
73
80
  }
74
81
 
75
82
  const [meta, ...rest] = records;
76
83
  assert.equal(meta.type, "session_meta");
77
84
  assert.equal(meta.payload.session_id, "sid-1");
78
85
  assert.equal(meta.payload.cwd, "/tmp/ws");
79
- assert.equal(meta.payload.cli_version, "0.146.0", "the bare number, not the whole string");
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");
80
88
 
81
89
  // Content key is role-dependent. Getting this wrong yields a rollout that loads and renders
82
90
  // empty, which is the failure mode least likely to be noticed before a user hits it.
83
- for (const record of rest) {
84
- assert.equal(record.type, "response_item");
91
+ const messages = rest.filter((record) => record.type === "response_item");
92
+ for (const record of messages) {
85
93
  assert.equal(record.payload.type, "message");
86
94
  const key = record.payload.content[0].type;
87
95
  assert.equal(key, record.payload.role === "assistant" ? "output_text" : "input_text");
88
96
  assert.match(record.payload.id, /^msg_/);
97
+ assert.ok(record.payload.internal_chat_message_metadata_passthrough.turn_id);
89
98
  }
90
99
 
91
100
  assert.deepEqual(
92
- rest.map((r) => r.payload.role),
101
+ messages.map((r) => r.payload.role),
93
102
  ["user", "assistant", "user", "assistant"],
94
103
  );
104
+ assert.equal(rest.filter((r) => r.payload.type === "task_started").length, 2);
105
+ assert.equal(rest.filter((r) => r.payload.type === "task_complete").length, 2);
106
+ assert.equal(rest.filter((r) => r.payload.type === "item_completed").length, 4);
95
107
 
96
108
  const serialized = JSON.stringify(records);
97
109
  assert.ok(!serialized.includes("REASONING-TRACE"), "thinking must never reach a handoff");
98
110
  assert.ok(!serialized.includes("SYSTEM-PREAMBLE"), "system turns are not replayed");
99
111
  assert.ok(serialized.includes("USER-TURN") && serialized.includes("ASSISTANT-TURN"));
100
- assert.ok(rest[0].payload.content[0].text.includes("Perplexity"), "orientation names origin");
112
+ assert.ok(messages[0].payload.content[0].text.includes("Perplexity"), "orientation names origin");
101
113
  }
102
114
 
103
115
  // -- writing --------------------------------------------------------------
@@ -106,7 +118,7 @@ assert.equal(versionSupported(null), false);
106
118
  const originalHome = process.env.HOME;
107
119
  process.env.HOME = home;
108
120
  try {
109
- const result = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.146.0" });
121
+ const result = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.147.0" });
110
122
 
111
123
  // The filename carries both the stamp and the id; Codex's picker reads both.
112
124
  assert.match(result.path, /rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-[0-9a-f-]{36}\.jsonl$/);
@@ -119,7 +131,7 @@ assert.equal(versionSupported(null), false);
119
131
  assert.equal(parsed[0].payload.id, result.sessionId);
120
132
 
121
133
  // Never overwrite an existing session.
122
- const again = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.146.0" });
134
+ const again = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.147.0" });
123
135
  assert.notEqual(again.sessionId, result.sessionId);
124
136
  assert.notEqual(again.path, result.path);
125
137