pennyrouter 0.2.17 → 0.2.19
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/harnesses/claude-code.js +11 -7
- package/src/launch.test.js +5 -2
- package/src/mcp.js +101 -5
- package/src/mcp.test.js +5 -4
- package/src/thread-session-codex.js +8 -5
- package/src/thread-session-codex.test.js +8 -5
package/package.json
CHANGED
|
@@ -4,15 +4,19 @@ import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../s
|
|
|
4
4
|
const CONFIG_PATH = "~/.claude/settings.json";
|
|
5
5
|
const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
|
|
6
6
|
const CONNECTOR_SETTING = "disableClaudeAiConnectors";
|
|
7
|
-
// The slot values remain genuine Anthropic ids so Claude Code recognizes
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// The slot values remain genuine Anthropic ids so Claude Code recognizes them and sends the
|
|
8
|
+
// native id on the wire. Claude Code separately supports *_MODEL_NAME and *_MODEL_DESCRIPTION
|
|
9
|
+
// for the picker, so Penny can present a friendly alias without sacrificing model recognition.
|
|
10
|
+
//
|
|
11
|
+
// The 1M-context variant is a `[1m]` suffix on the id — Claude Code does NOT infer a 1M window
|
|
12
|
+
// from a bare id, so a bare `claude-sonnet-5` gets 200K and auto-compacts around 150K. The
|
|
13
|
+
// suffix rides the wire; the gateway strips it for the upstream model and turns it into the
|
|
14
|
+
// `context-1m` beta header. Haiku has no 1M variant, so it stays bare.
|
|
11
15
|
export const CLAUDE_CODE_MODELS = {
|
|
12
16
|
haiku: "claude-haiku-4-5-20251001",
|
|
13
|
-
sonnet: "claude-sonnet-5",
|
|
14
|
-
opus: "claude-opus-5",
|
|
15
|
-
fable: "claude-fable-5",
|
|
17
|
+
sonnet: "claude-sonnet-5[1m]",
|
|
18
|
+
opus: "claude-opus-5[1m]",
|
|
19
|
+
fable: "claude-fable-5[1m]",
|
|
16
20
|
custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
|
|
17
21
|
};
|
|
18
22
|
const CLAUDE_CODE_MODEL_PRESENTATION = {
|
package/src/launch.test.js
CHANGED
|
@@ -103,7 +103,10 @@ async function main() {
|
|
|
103
103
|
assert.ok(!claude.args.includes("--plugin-dir"));
|
|
104
104
|
assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
|
|
105
105
|
assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
106
|
-
|
|
106
|
+
// 1M-capable slots carry the `[1m]` suffix; Haiku has no 1M variant and stays bare.
|
|
107
|
+
// Per-slot suffixes, not the global CLAUDE_CODE_MAX_CONTEXT_TOKENS override, which
|
|
108
|
+
// would wrongly hand Haiku a 1M window too.
|
|
109
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5[1m]");
|
|
107
110
|
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
|
|
108
111
|
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, "Haiku 4.5 (PennyRouter)");
|
|
109
112
|
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION, "Fast, economical Claude model · 200K context");
|
|
@@ -128,7 +131,7 @@ async function main() {
|
|
|
128
131
|
});
|
|
129
132
|
const persistedClaude = JSON.parse(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"));
|
|
130
133
|
assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
|
|
131
|
-
assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "claude-sonnet-5");
|
|
134
|
+
assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "claude-sonnet-5[1m]");
|
|
132
135
|
assert.equal("CLAUDE_CODE_MAX_CONTEXT_TOKENS" in persistedClaude.env, false);
|
|
133
136
|
|
|
134
137
|
await removePennyHarness("claude-code");
|
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} && penny 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,8 +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.
|
|
200
|
-
"prints the
|
|
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"));
|
|
201
202
|
assert.ok(!codex.content[0].text.includes("claude --resume"));
|
|
202
203
|
assert.ok(!codex.content[0].text.includes("UNIQUE-QUESTION"), "still returns no transcript");
|
|
203
204
|
}
|
|
@@ -99,10 +99,10 @@ export function buildRecords(thread, { cwd, sessionId, version, timestamp = new
|
|
|
99
99
|
internal_chat_message_metadata_passthrough: { turn_id: turnId },
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
106
|
for (const entry of thread.messages || []) {
|
|
107
107
|
const text = renderMessage(entry);
|
|
108
108
|
if (!text) continue;
|
|
@@ -128,7 +128,7 @@ export function buildRecords(thread, { cwd, sessionId, version, timestamp = new
|
|
|
128
128
|
}),
|
|
129
129
|
];
|
|
130
130
|
|
|
131
|
-
for (const turn of turns) {
|
|
131
|
+
for (const [turnIndex, turn] of turns.entries()) {
|
|
132
132
|
const turnId = uuidv7(timestamp.getTime());
|
|
133
133
|
records.push(envelope("event_msg", {
|
|
134
134
|
type: "task_started",
|
|
@@ -137,6 +137,9 @@ export function buildRecords(thread, { cwd, sessionId, version, timestamp = new
|
|
|
137
137
|
model_context_window: null,
|
|
138
138
|
collaboration_mode_kind: "default",
|
|
139
139
|
}));
|
|
140
|
+
if (turnIndex === 0) {
|
|
141
|
+
records.push(envelope("response_item", message("developer", orientation(thread), turnId)));
|
|
142
|
+
}
|
|
140
143
|
const userMessage = message("user", turn.user, turnId);
|
|
141
144
|
records.push(envelope("response_item", userMessage));
|
|
142
145
|
records.push(envelope("event_msg", {
|
|
@@ -99,17 +99,20 @@ assert.equal(versionSupported(null), false);
|
|
|
99
99
|
|
|
100
100
|
assert.deepEqual(
|
|
101
101
|
messages.map((r) => r.payload.role),
|
|
102
|
-
["
|
|
102
|
+
["developer", "user", "assistant"],
|
|
103
103
|
);
|
|
104
|
-
assert.equal(
|
|
105
|
-
assert.
|
|
106
|
-
assert.equal(
|
|
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);
|
|
107
110
|
|
|
108
111
|
const serialized = JSON.stringify(records);
|
|
109
112
|
assert.ok(!serialized.includes("REASONING-TRACE"), "thinking must never reach a handoff");
|
|
110
113
|
assert.ok(!serialized.includes("SYSTEM-PREAMBLE"), "system turns are not replayed");
|
|
111
114
|
assert.ok(serialized.includes("USER-TURN") && serialized.includes("ASSISTANT-TURN"));
|
|
112
|
-
assert.ok(messages
|
|
115
|
+
assert.ok(!messages.some((r) => r.payload.role === "user" && r.payload.content[0].text.includes("This session was imported")));
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
// -- writing --------------------------------------------------------------
|