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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
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
- return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
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
  }
@@ -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, ["--profile", "pennyrouter", "resume", "--last"]);
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");
@@ -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
- for (const record of records) {
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
- // Orientation pair, then one record per replayable turn. The empty user turn and the system
63
- // turn are dropped: neither is something a resumed agent should replay as its own.
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
- assert.ok(records[0].message.content.includes("Perplexity"), "orientation names the origin");
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
- assert.equal(records[0].parentUuid, null);
89
- assert.equal(records[1].parentUuid, records[0].uuid);
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" }]),