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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.15",
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
@@ -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"), "prints the codex resume command");
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.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