pennyrouter 0.2.11 → 0.2.13
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 +3 -1
- package/src/launch.js +40 -2
- package/src/launch.test.js +16 -1
- package/src/mcp.js +10 -2
- package/src/mcp.test.js +9 -0
- package/src/thread-session.js +12 -1
- package/src/thread-session.test.js +18 -7
package/package.json
CHANGED
|
@@ -36,7 +36,9 @@ const LEGACY_PENNY_LABELS = [
|
|
|
36
36
|
"Penny Opus", "Penny Sonnet", "Penny Haiku", "Penny Fable",
|
|
37
37
|
];
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// Used both for globally-installed Claude Code and `penny claude`, whose isolated
|
|
40
|
+
// launch environment otherwise bypasses ~/.claude/settings.json entirely.
|
|
41
|
+
export function writeClaudeCodeModelPresentation(env) {
|
|
40
42
|
for (const [slot, { name, description }] of Object.entries(CLAUDE_CODE_MODEL_PRESENTATION)) {
|
|
41
43
|
const prefix = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
|
|
42
44
|
env[`${prefix}_NAME`] = name;
|
package/src/launch.js
CHANGED
|
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import { basename, dirname, join, resolve } from "node:path";
|
|
14
14
|
|
|
15
15
|
import { configureClaudeCodeStatusline } from "./statusline.js";
|
|
16
|
-
import { upgradeClaudeCodeModelValue } from "./harnesses/claude-code.js";
|
|
16
|
+
import { upgradeClaudeCodeModelValue, writeClaudeCodeModelPresentation } from "./harnesses/claude-code.js";
|
|
17
17
|
import { atomicWrite, homeDir, readJsonFile, readText } from "./state.js";
|
|
18
18
|
|
|
19
19
|
const CONFIG_VERSION = 1;
|
|
@@ -257,6 +257,35 @@ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
|
257
257
|
].join("\n");
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Per-command settings for `penny codex`.
|
|
262
|
+
*
|
|
263
|
+
* A named Codex profile causes a second configuration/MCP refresh during startup on
|
|
264
|
+
* some Codex builds. Keep the same provider contract, but pass it as CLI overrides
|
|
265
|
+
* so it is part of the initial configuration load instead of a later profile layer.
|
|
266
|
+
* These values deliberately contain no PennyRouter secret: the key remains in the
|
|
267
|
+
* process environment and is read by Codex through env_key/env_http_headers.
|
|
268
|
+
*/
|
|
269
|
+
export function codexLaunchOverrides({ gatewayBaseUrl, subscriptionAuth }) {
|
|
270
|
+
const baseUrl = `${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`;
|
|
271
|
+
const overrides = [
|
|
272
|
+
'model_provider="pennyrouter"',
|
|
273
|
+
'model_providers.pennyrouter.name="PennyRouter"',
|
|
274
|
+
`model_providers.pennyrouter.base_url=${JSON.stringify(baseUrl)}`,
|
|
275
|
+
'model_providers.pennyrouter.wire_api="responses"',
|
|
276
|
+
'model_providers.pennyrouter.supports_websockets=false',
|
|
277
|
+
];
|
|
278
|
+
if (subscriptionAuth) {
|
|
279
|
+
overrides.push(
|
|
280
|
+
'model_providers.pennyrouter.requires_openai_auth=true',
|
|
281
|
+
'model_providers.pennyrouter.env_http_headers={ "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }',
|
|
282
|
+
);
|
|
283
|
+
} else {
|
|
284
|
+
overrides.push('model_providers.pennyrouter.env_key="PENNYROUTER_API_KEY"');
|
|
285
|
+
}
|
|
286
|
+
return overrides;
|
|
287
|
+
}
|
|
288
|
+
|
|
260
289
|
async function writeClaudeLaunchSettings() {
|
|
261
290
|
const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
|
|
262
291
|
const overlay = { disableClaudeAiConnectors: true };
|
|
@@ -438,6 +467,7 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
438
467
|
if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = upgradeClaudeCodeModelValue(models.sonnet);
|
|
439
468
|
if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = upgradeClaudeCodeModelValue(models.opus);
|
|
440
469
|
if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = upgradeClaudeCodeModelValue(models.fable);
|
|
470
|
+
writeClaudeCodeModelPresentation(env);
|
|
441
471
|
// Per-model limits must come from Claude Code's recognized native model ids. This
|
|
442
472
|
// global override would incorrectly give Haiku the 1M window.
|
|
443
473
|
delete env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
@@ -448,7 +478,15 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
448
478
|
};
|
|
449
479
|
}
|
|
450
480
|
if (harness === "codex") {
|
|
451
|
-
|
|
481
|
+
const overrides = codexLaunchOverrides({
|
|
482
|
+
gatewayBaseUrl: config.gateway_base_url,
|
|
483
|
+
subscriptionAuth: Boolean(item.subscription_auth),
|
|
484
|
+
});
|
|
485
|
+
return {
|
|
486
|
+
command: "codex",
|
|
487
|
+
args: overrides.flatMap((override) => ["-c", override]).concat(args),
|
|
488
|
+
env,
|
|
489
|
+
};
|
|
452
490
|
}
|
|
453
491
|
throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
|
|
454
492
|
}
|
package/src/launch.test.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
claudeLaunchSettingsPath,
|
|
9
9
|
claudeUserSkillPath,
|
|
10
10
|
codexLaunchProfilePath,
|
|
11
|
+
codexLaunchOverrides,
|
|
11
12
|
configurePennyHarness,
|
|
12
13
|
fileMode,
|
|
13
14
|
installPennyRuntime,
|
|
@@ -105,10 +106,21 @@ async function main() {
|
|
|
105
106
|
assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
106
107
|
assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5");
|
|
107
108
|
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
|
|
109
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, "Haiku 4.5 (PennyRouter)");
|
|
110
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION, "Fast, economical Claude model · 200K context");
|
|
108
111
|
assert.equal(claude.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
|
|
109
112
|
|
|
110
113
|
const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
|
|
111
|
-
assert.deepEqual(codex.args, [
|
|
114
|
+
assert.deepEqual(codex.args, [
|
|
115
|
+
"-c", 'model_provider="pennyrouter"',
|
|
116
|
+
"-c", 'model_providers.pennyrouter.name="PennyRouter"',
|
|
117
|
+
"-c", 'model_providers.pennyrouter.base_url="https://gateway.example/v1"',
|
|
118
|
+
"-c", 'model_providers.pennyrouter.wire_api="responses"',
|
|
119
|
+
"-c", 'model_providers.pennyrouter.supports_websockets=false',
|
|
120
|
+
"-c", 'model_providers.pennyrouter.requires_openai_auth=true',
|
|
121
|
+
"-c", 'model_providers.pennyrouter.env_http_headers={ "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }',
|
|
122
|
+
"resume", "--last",
|
|
123
|
+
]);
|
|
112
124
|
assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
|
|
113
125
|
assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
|
|
114
126
|
assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
|
|
@@ -153,6 +165,9 @@ async function main() {
|
|
|
153
165
|
});
|
|
154
166
|
assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
|
|
155
167
|
assert.ok(!paid.includes("requires_openai_auth"));
|
|
168
|
+
assert.deepEqual(codexLaunchOverrides({
|
|
169
|
+
gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,
|
|
170
|
+
}).slice(-1), ['model_providers.pennyrouter.env_key="PENNYROUTER_API_KEY"']);
|
|
156
171
|
} finally {
|
|
157
172
|
for (const key of ENV_KEYS) {
|
|
158
173
|
if (previous[key] === undefined) delete process.env[key];
|
package/src/mcp.js
CHANGED
|
@@ -38,7 +38,12 @@ const TOOLS = [
|
|
|
38
38
|
"Fetch an AI conversation from a share link (ChatGPT, Claude, Gemini, Perplexity) and " +
|
|
39
39
|
"return its transcript. Use when the user wants to discuss, summarize or ask about a " +
|
|
40
40
|
"past conversation. The transcript enters your context — for long threads that are " +
|
|
41
|
-
"meant to be continued as work, prefer thread_resume."
|
|
41
|
+
"meant to be continued as work, prefer thread_resume. " +
|
|
42
|
+
"This tool is the only supported way to read a share link: those pages are " +
|
|
43
|
+
"JavaScript-rendered shells that return markup, not conversation, to a plain fetch. Never " +
|
|
44
|
+
"fall back to fetching the URL directly, and never guess at the contents. If this call " +
|
|
45
|
+
"does not return a transcript it was blocked, still awaiting approval, or errored — say " +
|
|
46
|
+
"so plainly and stop, rather than inferring a reason such as a size or display limit.",
|
|
42
47
|
inputSchema: {
|
|
43
48
|
type: "object",
|
|
44
49
|
properties: {
|
|
@@ -54,7 +59,10 @@ const TOOLS = [
|
|
|
54
59
|
"session (Claude Code or Codex), so the user can resume into it and continue coding with " +
|
|
55
60
|
"that conversation as history. Use when the user wants to CONTINUE or BUILD FROM a past " +
|
|
56
61
|
"chat rather than talk about it. Returns a session id and path; the transcript is " +
|
|
57
|
-
"deliberately NOT returned, so importing a long thread costs you no context."
|
|
62
|
+
"deliberately NOT returned, so importing a long thread costs you no context. That is by " +
|
|
63
|
+
"design and is not a failure — do not read the thread another way to 'check' it, and do " +
|
|
64
|
+
"not fetch the share URL directly. Report the resume command to the user. If this call " +
|
|
65
|
+
"does not return, it was blocked, still awaiting approval, or errored — say so and stop.",
|
|
58
66
|
inputSchema: {
|
|
59
67
|
type: "object",
|
|
60
68
|
properties: {
|
package/src/mcp.test.js
CHANGED
|
@@ -114,6 +114,15 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
|
|
|
114
114
|
["thread_read", "thread_resume"],
|
|
115
115
|
);
|
|
116
116
|
|
|
117
|
+
// Observed in a real session: a blocked tool call is indistinguishable from an empty one, so
|
|
118
|
+
// the model explained the silence as a "size/display limitation" and fell back to fetching
|
|
119
|
+
// the share URL directly — pulling 574KB of JS-shell markup into the context that
|
|
120
|
+
// thread_resume exists to protect. The descriptions have to rule that out explicitly.
|
|
121
|
+
for (const tool of byId.get(2).result.tools) {
|
|
122
|
+
assert.match(tool.description, /not fetch|Never fall back to fetching/i, tool.name);
|
|
123
|
+
assert.match(tool.description, /blocked/i, tool.name);
|
|
124
|
+
}
|
|
125
|
+
|
|
117
126
|
// The URL is forwarded to the gateway under the caller's key; no provider logic runs here.
|
|
118
127
|
assert.equal(JSON.parse(requestBody).url, "https://www.perplexity.ai/search/x");
|
|
119
128
|
assert.equal(authHeader, "Bearer pr-test");
|
package/src/thread-session.js
CHANGED
|
@@ -126,6 +126,15 @@ export function buildRecords(thread, { cwd, sessionId, version, gitBranch = "" }
|
|
|
126
126
|
parentUuid = uuid;
|
|
127
127
|
};
|
|
128
128
|
|
|
129
|
+
// The resume picker labels a session with its `ai-title` and falls back to the first user
|
|
130
|
+
// message, which here is the orientation preamble — so without this every import reads as
|
|
131
|
+
// "This session was imported by PennyRouter from a…". The source thread already has a real
|
|
132
|
+
// title; use it. Deliberately outside the parentUuid chain: this record type carries no
|
|
133
|
+
// uuid/parentUuid in Claude Code's own sessions, and threading it would break the walk.
|
|
134
|
+
if (thread.title) {
|
|
135
|
+
records.push({ type: "ai-title", aiTitle: thread.title, sessionId });
|
|
136
|
+
}
|
|
137
|
+
|
|
129
138
|
push("user", { role: "user", content: orientation(thread) });
|
|
130
139
|
push("assistant", {
|
|
131
140
|
role: "assistant",
|
|
@@ -160,7 +169,9 @@ export function buildRecords(thread, { cwd, sessionId, version, gitBranch = "" }
|
|
|
160
169
|
export function assertChained(records) {
|
|
161
170
|
const seen = new Set();
|
|
162
171
|
let roots = 0;
|
|
163
|
-
|
|
172
|
+
// Metadata records (`ai-title`) sit outside the conversation and carry no uuid, matching
|
|
173
|
+
// Claude Code's own sessions. Only the message chain is walked.
|
|
174
|
+
for (const record of records.filter((r) => r.uuid)) {
|
|
164
175
|
if (record.parentUuid === null) roots += 1;
|
|
165
176
|
else if (!seen.has(record.parentUuid)) {
|
|
166
177
|
throw new Error(`session records are not chained (orphan ${record.uuid})`);
|
|
@@ -59,21 +59,31 @@ const records = buildRecords(thread, {
|
|
|
59
59
|
version: "2.1.226",
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
//
|
|
63
|
-
// turn are dropped: neither is something a resumed agent should
|
|
62
|
+
// A title record, then the orientation pair, then one record per replayable turn. The empty
|
|
63
|
+
// user turn and the system turn are dropped: neither is something a resumed agent should
|
|
64
|
+
// replay as its own.
|
|
64
65
|
assert.deepEqual(
|
|
65
66
|
records.map((r) => r.type),
|
|
66
|
-
["user", "assistant", "user", "assistant"],
|
|
67
|
+
["ai-title", "user", "assistant", "user", "assistant"],
|
|
67
68
|
);
|
|
68
69
|
|
|
70
|
+
// The resume picker labels a session with ai-title and otherwise falls back to the first user
|
|
71
|
+
// message — which is the orientation preamble, so every import would read as "This session was
|
|
72
|
+
// imported by PennyRouter from a…" instead of naming the conversation.
|
|
73
|
+
const title = records.find((r) => r.type === "ai-title");
|
|
74
|
+
assert.equal(title.aiTitle, "Kickplate removal");
|
|
75
|
+
assert.equal(title.sessionId, "sid");
|
|
76
|
+
assert.ok(!title.uuid && !("parentUuid" in title), "metadata sits outside the message chain");
|
|
77
|
+
|
|
69
78
|
const serialized = JSON.stringify(records);
|
|
70
79
|
assert.ok(!serialized.includes("REASONING-TRACE"), "thinking must never reach a handoff");
|
|
71
80
|
assert.ok(!serialized.includes("SYSTEM-PREAMBLE"), "system turns are not replayed");
|
|
72
81
|
assert.ok(serialized.includes("print('hi')"), "code blocks survive as fenced text");
|
|
73
|
-
|
|
82
|
+
const firstTurn = records.find((r) => r.type === "user");
|
|
83
|
+
assert.ok(firstTurn.message.content.includes("Perplexity"), "orientation names the origin");
|
|
74
84
|
assert.ok(orientation(thread).includes("Do not re-litigate"));
|
|
75
85
|
|
|
76
|
-
for (const record of records) {
|
|
86
|
+
for (const record of records.filter((r) => r.type !== "ai-title")) {
|
|
77
87
|
assert.equal(record.sessionId, "sid");
|
|
78
88
|
assert.equal(record.cwd, "/tmp/ws");
|
|
79
89
|
assert.equal(record.isSidechain, false);
|
|
@@ -85,8 +95,9 @@ for (const record of records) {
|
|
|
85
95
|
// A break in the parentUuid list truncates history silently, which reads as a working import
|
|
86
96
|
// that lost half the conversation. That has to fail loudly instead.
|
|
87
97
|
assertChained(records);
|
|
88
|
-
|
|
89
|
-
assert.equal(
|
|
98
|
+
const chain = records.filter((r) => r.uuid);
|
|
99
|
+
assert.equal(chain[0].parentUuid, null);
|
|
100
|
+
assert.equal(chain[1].parentUuid, chain[0].uuid);
|
|
90
101
|
|
|
91
102
|
assert.throws(
|
|
92
103
|
() => assertChained([{ uuid: "a", parentUuid: "nope" }]),
|