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 +1 -1
- package/src/cli.js +8 -2
- package/src/mcp-register.js +9 -4
- package/src/mcp-register.test.js +2 -0
- package/src/mcp.js +23 -8
- package/src/mcp.test.js +15 -5
- package/src/thread-session-codex.js +95 -32
- package/src/thread-session-codex.test.js +29 -17
package/package.json
CHANGED
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({
|
|
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
|
package/src/mcp-register.js
CHANGED
|
@@ -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 {
|
|
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
|
|
package/src/mcp-register.test.js
CHANGED
|
@@ -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.
|
|
61
|
-
"
|
|
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
|
|
81
|
-
"
|
|
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
|
-
{
|
|
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
|
|
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"),
|
|
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.
|
|
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
|
+
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: "
|
|
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 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
|
|
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,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.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|