pennyrouter 0.2.12 → 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/launch.js +38 -1
- package/src/launch.test.js +14 -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
package/src/launch.js
CHANGED
|
@@ -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 };
|
|
@@ -449,7 +478,15 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
449
478
|
};
|
|
450
479
|
}
|
|
451
480
|
if (harness === "codex") {
|
|
452
|
-
|
|
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
|
+
};
|
|
453
490
|
}
|
|
454
491
|
throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
|
|
455
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,
|
|
@@ -110,7 +111,16 @@ async function main() {
|
|
|
110
111
|
assert.equal(claude.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
|
|
111
112
|
|
|
112
113
|
const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
|
|
113
|
-
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
|
+
]);
|
|
114
124
|
assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
|
|
115
125
|
assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
|
|
116
126
|
assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
|
|
@@ -155,6 +165,9 @@ async function main() {
|
|
|
155
165
|
});
|
|
156
166
|
assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
|
|
157
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"']);
|
|
158
171
|
} finally {
|
|
159
172
|
for (const key of ENV_KEYS) {
|
|
160
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" }]),
|