pennyrouter 0.2.15 → 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 +3 -1
- package/src/mcp.js +1 -1
- package/src/mcp.test.js +2 -1
- 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
|
@@ -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
|
@@ -187,7 +187,7 @@ async function callTool(name, args, context) {
|
|
|
187
187
|
"",
|
|
188
188
|
"Resume it with:",
|
|
189
189
|
codex
|
|
190
|
-
? ` cd ${cwd} && codex resume ${sessionId}`
|
|
190
|
+
? ` cd ${cwd} && penny codex resume ${sessionId}`
|
|
191
191
|
: ` cd ${cwd} && claude --resume ${sessionId}`,
|
|
192
192
|
"",
|
|
193
193
|
codex
|
package/src/mcp.test.js
CHANGED
|
@@ -196,7 +196,8 @@ 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.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");
|
|
200
201
|
assert.ok(!codex.content[0].text.includes("claude --resume"));
|
|
201
202
|
assert.ok(!codex.content[0].text.includes("UNIQUE-QUESTION"), "still returns no transcript");
|
|
202
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
|
|