pennyrouter 0.2.15 → 0.2.18
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 +1 -1
- package/src/cli.js +3 -1
- package/src/mcp.js +101 -5
- package/src/mcp.test.js +5 -3
- package/src/thread-session-codex.js +98 -32
- package/src/thread-session-codex.test.js +33 -18
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1842,7 +1842,7 @@ function printHelp() {
|
|
|
1842
1842
|
|
|
1843
1843
|
Usage:
|
|
1844
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]
|
|
1845
|
-
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]
|
|
1846
1846
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
1847
1847
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1848
1848
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
@@ -1869,6 +1869,8 @@ Examples:
|
|
|
1869
1869
|
npx pennyrouter update
|
|
1870
1870
|
npx pennyrouter update --dry-run
|
|
1871
1871
|
npx pennyrouter update --harness claude-code
|
|
1872
|
+
npx pennyrouter update --harness codex --local
|
|
1873
|
+
npx pennyrouter update --all --local
|
|
1872
1874
|
npx pennyrouter install
|
|
1873
1875
|
npx pennyrouter install --make-default
|
|
1874
1876
|
npx pennyrouter install --penny-only
|
package/src/mcp.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
— the transcript never enters the caller's context, so moving a 200-turn
|
|
11
11
|
thread costs the caller nothing. That asymmetry is the whole reason for two
|
|
12
12
|
tools rather than one with a flag.
|
|
13
|
+
thread_transfer capture the current local session and write it for the other harness.
|
|
13
14
|
|
|
14
15
|
Because the destination is a parameter, "resume this Perplexity thread in Codex" and "…in
|
|
15
16
|
Claude Code" are the same call with a different `harness`. Each harness has its own writer:
|
|
@@ -25,6 +26,9 @@
|
|
|
25
26
|
|
|
26
27
|
import { createInterface } from "node:readline";
|
|
27
28
|
import { execFileSync } from "node:child_process";
|
|
29
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
28
32
|
|
|
29
33
|
import { writeSession, VERIFIED_MIN, VERIFIED_MAX } from "./thread-session.js";
|
|
30
34
|
import { writeSession as writeCodexSession } from "./thread-session-codex.js";
|
|
@@ -36,7 +40,8 @@ const SERVER_INSTRUCTIONS =
|
|
|
36
40
|
"command '@penny import session <link>', use thread_read when it is the first user turn so the " +
|
|
37
41
|
"conversation enters the current session; otherwise use thread_resume to create a fresh native " +
|
|
38
42
|
"session. Do not search the local workspace, fetch the link directly, or look for another resume " +
|
|
39
|
-
"utility first."
|
|
43
|
+
"utility first. For `@penny send session to claude` or `@penny send session to codex`, call " +
|
|
44
|
+
"thread_transfer; it reads the current local session and writes only the target harness file.";
|
|
40
45
|
|
|
41
46
|
const TOOLS = [
|
|
42
47
|
{
|
|
@@ -98,8 +103,59 @@ const TOOLS = [
|
|
|
98
103
|
required: ["url"],
|
|
99
104
|
},
|
|
100
105
|
},
|
|
106
|
+
{
|
|
107
|
+
name: "thread_transfer",
|
|
108
|
+
description:
|
|
109
|
+
"Capture the current local coding-agent session and write it as a native session for " +
|
|
110
|
+
"the other harness. Use when the user says `@penny send session to claude` or " +
|
|
111
|
+
"`@penny send session to codex`. This stays local: it never sends session contents to " +
|
|
112
|
+
"the gateway for storage. Returns a session id and the target harness's `/resume` command.",
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: {
|
|
116
|
+
target_harness: { type: "string", enum: ["claude-code", "codex"] },
|
|
117
|
+
directory: { type: "string", description: "Workspace of the current session." },
|
|
118
|
+
},
|
|
119
|
+
required: ["target_harness"],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
101
122
|
];
|
|
102
123
|
|
|
124
|
+
function flattenClaudeProject(cwd) {
|
|
125
|
+
return cwd.replace(/[/.\\]/g, "-");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function newestSessionFile(harness, cwd) {
|
|
129
|
+
const roots = harness === "codex"
|
|
130
|
+
? [join(homedir(), ".codex", "sessions")]
|
|
131
|
+
: [join(homedir(), ".claude", "projects", flattenClaudeProject(cwd))];
|
|
132
|
+
const candidates = [];
|
|
133
|
+
async function walk(dir, depth = 0) {
|
|
134
|
+
if (depth > 4) return;
|
|
135
|
+
let entries;
|
|
136
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const path = join(dir, entry.name);
|
|
139
|
+
if (entry.isDirectory()) await walk(path, depth + 1);
|
|
140
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
141
|
+
try {
|
|
142
|
+
const first = (await readFile(path, "utf8")).split("\n", 1)[0];
|
|
143
|
+
if (harness === "codex") {
|
|
144
|
+
const row = JSON.parse(first);
|
|
145
|
+
if (row?.payload?.cwd !== cwd) continue;
|
|
146
|
+
}
|
|
147
|
+
const info = await stat(path);
|
|
148
|
+
candidates.push({ path, mtimeMs: info.mtimeMs });
|
|
149
|
+
} catch { /* incomplete/foreign session; keep searching */ }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const root of roots) await walk(root);
|
|
154
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
155
|
+
if (!candidates[0]) throw new Error(`could not find the current ${harness} session for ${cwd}`);
|
|
156
|
+
return candidates[0].path;
|
|
157
|
+
}
|
|
158
|
+
|
|
103
159
|
/** `<tool> --version`, or null when the tool is not installed. The writers version-gate on this
|
|
104
160
|
and refuse rather than write a session file that only fails at resume time. */
|
|
105
161
|
function toolVersion(binary) {
|
|
@@ -134,6 +190,21 @@ async function fetchCanonical(url, { gatewayBaseUrl, apiKey }) {
|
|
|
134
190
|
return response.json();
|
|
135
191
|
}
|
|
136
192
|
|
|
193
|
+
async function fetchCanonicalFile(path, source, { gatewayBaseUrl, apiKey }) {
|
|
194
|
+
const payload = await readFile(path);
|
|
195
|
+
const response = await fetch(`${gatewayBaseUrl}/v1/penny/threads/canonical-file?source=${encodeURIComponent(source)}`, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
198
|
+
body: payload,
|
|
199
|
+
});
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
let detail = `HTTP ${response.status}`;
|
|
202
|
+
try { detail = (await response.json())?.detail || detail; } catch { /* status is enough */ }
|
|
203
|
+
throw new Error(detail);
|
|
204
|
+
}
|
|
205
|
+
return response.json();
|
|
206
|
+
}
|
|
207
|
+
|
|
137
208
|
function transcript(thread) {
|
|
138
209
|
const lines = [`# ${thread.title || "Imported thread"}`];
|
|
139
210
|
if (thread.source_url) lines.push(`Source: ${thread.source_url}`);
|
|
@@ -158,14 +229,15 @@ function transcript(thread) {
|
|
|
158
229
|
|
|
159
230
|
async function callTool(name, args, context) {
|
|
160
231
|
const url = String(args?.url || "").trim();
|
|
161
|
-
if (!url) throw new Error("a share link is required");
|
|
162
232
|
|
|
163
233
|
if (name === "thread_read") {
|
|
234
|
+
if (!url) throw new Error("a share link is required");
|
|
164
235
|
const thread = await fetchCanonical(url, context);
|
|
165
236
|
return transcript(thread);
|
|
166
237
|
}
|
|
167
238
|
|
|
168
239
|
if (name === "thread_resume") {
|
|
240
|
+
if (!url) throw new Error("a share link is required");
|
|
169
241
|
const cwd = String(args?.directory || process.cwd());
|
|
170
242
|
const harness = String(args?.harness || context.harness || "claude-code").toLowerCase();
|
|
171
243
|
if (harness !== "claude-code" && harness !== "codex") {
|
|
@@ -186,9 +258,7 @@ async function callTool(name, args, context) {
|
|
|
186
258
|
` ${path}`,
|
|
187
259
|
"",
|
|
188
260
|
"Resume it with:",
|
|
189
|
-
|
|
190
|
-
? ` cd ${cwd} && codex resume ${sessionId}`
|
|
191
|
-
: ` cd ${cwd} && claude --resume ${sessionId}`,
|
|
261
|
+
` /resume ${sessionId}`,
|
|
192
262
|
"",
|
|
193
263
|
codex
|
|
194
264
|
? "Tool calls were rendered as prose: Codex and the source harness do not share a tool " +
|
|
@@ -198,6 +268,32 @@ async function callTool(name, args, context) {
|
|
|
198
268
|
].join("\n");
|
|
199
269
|
}
|
|
200
270
|
|
|
271
|
+
if (name === "thread_transfer") {
|
|
272
|
+
const cwd = String(args?.directory || process.cwd());
|
|
273
|
+
const sourceHarness = String(context.harness || "codex").toLowerCase();
|
|
274
|
+
const targetHarness = String(args?.target_harness || "").toLowerCase();
|
|
275
|
+
if (!["claude-code", "codex"].includes(targetHarness)) {
|
|
276
|
+
throw new Error(`unknown target harness "${targetHarness}"; expected claude-code or codex`);
|
|
277
|
+
}
|
|
278
|
+
if (!["claude-code", "codex"].includes(sourceHarness)) {
|
|
279
|
+
throw new Error(`unknown source harness "${sourceHarness}"`);
|
|
280
|
+
}
|
|
281
|
+
if (sourceHarness === targetHarness) throw new Error("target harness is already active");
|
|
282
|
+
const sourcePath = await newestSessionFile(sourceHarness, cwd);
|
|
283
|
+
const thread = await fetchCanonicalFile(
|
|
284
|
+
sourcePath,
|
|
285
|
+
sourceHarness === "codex" ? "codex_jsonl" : "claude_code_jsonl",
|
|
286
|
+
context,
|
|
287
|
+
);
|
|
288
|
+
const version = toolVersion(targetHarness === "codex" ? "codex" : "claude");
|
|
289
|
+
const write = targetHarness === "codex" ? writeCodexSession : writeSession;
|
|
290
|
+
const { sessionId, path, messageCount } = write(thread, { cwd, version });
|
|
291
|
+
return [
|
|
292
|
+
`Transferred "${thread.title}" to ${targetHarness === "codex" ? "Codex" : "Claude Code"}.`,
|
|
293
|
+
`Wrote ${messageCount} records:`, ` ${path}`, "", ` /resume ${sessionId}`,
|
|
294
|
+
].join("\n");
|
|
295
|
+
}
|
|
296
|
+
|
|
201
297
|
throw new Error(`unknown tool: ${name}`);
|
|
202
298
|
}
|
|
203
299
|
|
package/src/mcp.test.js
CHANGED
|
@@ -116,7 +116,7 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
|
|
|
116
116
|
|
|
117
117
|
assert.deepEqual(
|
|
118
118
|
byId.get(2).result.tools.map((t) => t.name),
|
|
119
|
-
["thread_read", "thread_resume"],
|
|
119
|
+
["thread_read", "thread_resume", "thread_transfer"],
|
|
120
120
|
);
|
|
121
121
|
|
|
122
122
|
// Observed in a real session: a blocked tool call is indistinguishable from an empty one, so
|
|
@@ -146,7 +146,7 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
|
|
|
146
146
|
const resumed = byId.get(4).result.content[0].text;
|
|
147
147
|
assert.ok(!resumed.includes("UNIQUE-QUESTION"), "thread_resume must not return the transcript");
|
|
148
148
|
assert.ok(!resumed.includes("UNIQUE-ANSWER"), "thread_resume must not return the transcript");
|
|
149
|
-
assert.
|
|
149
|
+
assert.match(resumed, /\/resume [0-9a-f-]{36}/, "it must tell the user how to resume");
|
|
150
150
|
assert.ok(resumed.includes("Kickplate removal"));
|
|
151
151
|
assert.equal(byId.get(4).result.isError, undefined);
|
|
152
152
|
}
|
|
@@ -196,7 +196,9 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
|
|
|
196
196
|
if (codex.isError) {
|
|
197
197
|
assert.match(codex.content[0].text, /Codex .*outside the range|ENOENT|not.*install/i);
|
|
198
198
|
} else {
|
|
199
|
-
assert.
|
|
199
|
+
assert.match(codex.content[0].text, /\/resume [0-9a-f-]{36}/,
|
|
200
|
+
"prints the native Codex resume command");
|
|
201
|
+
assert.ok(!codex.content[0].text.includes("penny codex resume"));
|
|
200
202
|
assert.ok(!codex.content[0].text.includes("claude --resume"));
|
|
201
203
|
assert.ok(!codex.content[0].text.includes("UNIQUE-QUESTION"), "still returns no transcript");
|
|
202
204
|
}
|
|
@@ -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.
|
|
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
|
|
12
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
74
|
-
const mm = String(date.
|
|
75
|
-
const dd = String(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");
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
// 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
|
+
}
|
|
97
115
|
|
|
98
116
|
const records = [
|
|
99
117
|
envelope("session_meta", {
|
|
@@ -105,18 +123,66 @@ 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: "
|
|
109
|
-
history_mode: "
|
|
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
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
}));
|
|
120
186
|
}
|
|
121
187
|
|
|
122
188
|
return records;
|
|
@@ -45,16 +45,23 @@ const thread = {
|
|
|
45
45
|
|
|
46
46
|
// -- paths ----------------------------------------------------------------
|
|
47
47
|
{
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
55
|
-
assert.equal(versionSupported("codex-cli 0.
|
|
56
|
-
assert.equal(versionSupported("0.
|
|
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,48 @@ 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.
|
|
74
|
+
version: "codex-cli 0.147.0",
|
|
68
75
|
});
|
|
69
76
|
|
|
70
|
-
//
|
|
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.
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
93
|
-
["
|
|
101
|
+
messages.map((r) => r.payload.role),
|
|
102
|
+
["developer", "user", "assistant"],
|
|
94
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);
|
|
95
110
|
|
|
96
111
|
const serialized = JSON.stringify(records);
|
|
97
112
|
assert.ok(!serialized.includes("REASONING-TRACE"), "thinking must never reach a handoff");
|
|
98
113
|
assert.ok(!serialized.includes("SYSTEM-PREAMBLE"), "system turns are not replayed");
|
|
99
114
|
assert.ok(serialized.includes("USER-TURN") && serialized.includes("ASSISTANT-TURN"));
|
|
100
|
-
assert.ok(
|
|
115
|
+
assert.ok(!messages.some((r) => r.payload.role === "user" && r.payload.content[0].text.includes("This session was imported")));
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
// -- writing --------------------------------------------------------------
|
|
@@ -106,7 +121,7 @@ assert.equal(versionSupported(null), false);
|
|
|
106
121
|
const originalHome = process.env.HOME;
|
|
107
122
|
process.env.HOME = home;
|
|
108
123
|
try {
|
|
109
|
-
const result = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.
|
|
124
|
+
const result = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.147.0" });
|
|
110
125
|
|
|
111
126
|
// The filename carries both the stamp and the id; Codex's picker reads both.
|
|
112
127
|
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 +134,7 @@ assert.equal(versionSupported(null), false);
|
|
|
119
134
|
assert.equal(parsed[0].payload.id, result.sessionId);
|
|
120
135
|
|
|
121
136
|
// Never overwrite an existing session.
|
|
122
|
-
const again = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.
|
|
137
|
+
const again = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.147.0" });
|
|
123
138
|
assert.notEqual(again.sessionId, result.sessionId);
|
|
124
139
|
assert.notEqual(again.path, result.path);
|
|
125
140
|
|