pennyrouter 0.1.19 → 0.1.21

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.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "start": "node ./bin/pennyrouter.js",
27
- "check": "node --check ./bin/pennyrouter.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js"
27
+ "check": "node --check ./bin/pennyrouter.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js"
28
28
  },
29
29
  "license": "MIT"
30
30
  }
package/src/cli.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  forgetAnthropicToken,
21
21
  maskKey,
22
22
  pollForKey,
23
+ probeClaudeCodeGateway,
23
24
  sleep,
24
25
  storeAnthropicToken,
25
26
  } from "./session.js";
@@ -98,6 +99,7 @@ function parseArgs(argv) {
98
99
  else if (arg === "--yes" || arg === "-y") flags.yes = true;
99
100
  else if (arg === "--all") flags.all = true;
100
101
  else if (arg === "--dry-run") flags.dryRun = true;
102
+ else if (arg === "--allow-running-claude") flags.allowRunningClaude = true;
101
103
  else if (arg === "--no-browser") flags.noBrowser = true;
102
104
  else if (arg === "--existing-account") flags.existingAccount = true;
103
105
  else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
@@ -174,6 +176,23 @@ async function authProvider(flags) {
174
176
  return;
175
177
  }
176
178
 
179
+ // Claude Code's env settings are user-global and may be reloaded by already-open
180
+ // processes. Switching the base URL underneath an in-flight agent can transplant a
181
+ // multi-hundred-thousand-token tool loop into PennyRouter without a fresh human action.
182
+ // Make that blast radius opt-in instead of surprising every new installer.
183
+ if (selected.some((harness) => harness.id === "claude-code")
184
+ && !flags.allowRunningClaude) {
185
+ const running = runningClaudeCodeProcesses();
186
+ if (running.length > 0) {
187
+ throw new Error(
188
+ "Claude Code is currently running. Close all Claude Code sessions, then run install again. "
189
+ + "PennyRouter changes Claude's user-global API routing, so continuing could redirect "
190
+ + "an open session's full transcript. If you intentionally accept that risk, pass "
191
+ + "--allow-running-claude.\nDetected:\n " + running.slice(0, 5).join("\n "),
192
+ );
193
+ }
194
+ }
195
+
177
196
  const pennyKey = flags.pennyKey || await readClaudeCodePennyRouterKey();
178
197
  if (!pennyKey) {
179
198
  throw new Error(
@@ -370,6 +389,8 @@ async function install(flags) {
370
389
  apiKey: claim.api_key,
371
390
  token: claudeAnthropicAuth.token,
372
391
  });
392
+ console.log("Verifying modern Claude Code compatibility before changing global settings...");
393
+ await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
373
394
  record = await harness.installStoredToken({
374
395
  apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
375
396
  });
@@ -377,6 +398,10 @@ async function install(flags) {
377
398
  record.anthropic_token_stored = true;
378
399
  record.token_source = claudeAnthropicAuth.source;
379
400
  } else {
401
+ if (harness.id === "claude-code") {
402
+ console.log("Verifying modern Claude Code compatibility before changing global settings...");
403
+ await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
404
+ }
380
405
  record = await harness.install({
381
406
  apiKey: claim.api_key,
382
407
  // An explicit --local / --gateway-base-url overrides the URL the server hands back
@@ -1058,11 +1083,26 @@ function openBrowser(url) {
1058
1083
  spawnSync(command, args, { stdio: "ignore", detached: true });
1059
1084
  }
1060
1085
 
1086
+ function runningClaudeCodeProcesses() {
1087
+ if (process.platform === "win32") {
1088
+ const result = spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8" });
1089
+ if (result.status !== 0) return [];
1090
+ return String(result.stdout || "").split(/\r?\n/)
1091
+ .filter((line) => /^"claude(?:\.exe)?"/i.test(line.trim()));
1092
+ }
1093
+ const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
1094
+ if (result.status !== 0) return [];
1095
+ return String(result.stdout || "").split(/\r?\n/)
1096
+ .map((line) => line.trim())
1097
+ // Case-sensitive on purpose: match the Claude Code executable, not Claude.app.
1098
+ .filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
1099
+ }
1100
+
1061
1101
  function printHelp() {
1062
1102
  console.log(`PennyRouter CLI
1063
1103
 
1064
1104
  Usage:
1065
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]]
1105
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]] [--allow-running-claude]
1066
1106
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1067
1107
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1068
1108
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
package/src/session.js CHANGED
@@ -71,6 +71,59 @@ export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
71
71
  return response.json();
72
72
  }
73
73
 
74
+ // Exercise the exact modern Claude Code request feature that has historically broken at
75
+ // proxy boundaries BEFORE changing ~/.claude/settings.json (a user-global redirect). This
76
+ // is intentionally a real, tiny Messages call: a generic /health check cannot prove that
77
+ // Anthropic beta headers survive the gateway's subscription/native relay.
78
+ export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
79
+ const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/messages?beta=true`;
80
+ const controller = new AbortController();
81
+ const timer = setTimeout(() => controller.abort(), 20_000);
82
+ let response;
83
+ try {
84
+ response = await fetch(url, {
85
+ method: "POST",
86
+ signal: controller.signal,
87
+ headers: {
88
+ "content-type": "application/json",
89
+ authorization: `Bearer ${apiKey}`,
90
+ "anthropic-beta": "compact-2026-01-12",
91
+ },
92
+ body: JSON.stringify({
93
+ model: "Penny Sonnet",
94
+ max_tokens: 16,
95
+ stream: false,
96
+ messages: [{ role: "user", content: "Reply OK." }],
97
+ tools: [{
98
+ name: "pennyrouter_install_probe",
99
+ description: "No-op compatibility probe; do not call it.",
100
+ input_schema: { type: "object", properties: {} },
101
+ }],
102
+ context_management: { edits: [{ type: "compact_20260112" }] },
103
+ }),
104
+ });
105
+ } catch (error) {
106
+ if (error?.name === "AbortError") {
107
+ throw new Error("Gateway compatibility check timed out; no Claude Code settings were changed.");
108
+ }
109
+ throw new Error(`Gateway compatibility check failed; no Claude Code settings were changed: ${error?.message || error}`);
110
+ } finally {
111
+ clearTimeout(timer);
112
+ }
113
+ if (!response.ok) {
114
+ let detail = "";
115
+ try {
116
+ detail = (await response.text()).slice(0, 500).replace(/\s+/g, " ").trim();
117
+ } catch {
118
+ // Status alone is enough for a safe failure.
119
+ }
120
+ throw new Error(
121
+ `Gateway compatibility check failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}. No Claude Code settings were changed.`,
122
+ );
123
+ }
124
+ return true;
125
+ }
126
+
74
127
  // Best-effort "forget my Claude token" on uninstall. Never throws — a cleanup that
75
128
  // can't reach the network shouldn't block removing the local integration.
76
129
  export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import { probeClaudeCodeGateway } from "./session.js";
3
+
4
+ const originalFetch = globalThis.fetch;
5
+
6
+ try {
7
+ let seen;
8
+ globalThis.fetch = async (url, options) => {
9
+ seen = { url, options };
10
+ return { ok: true, status: 200 };
11
+ };
12
+ assert.equal(await probeClaudeCodeGateway({
13
+ gatewayBaseUrl: "https://gateway.example/", apiKey: "pr-test",
14
+ }), true);
15
+ assert.equal(seen.url, "https://gateway.example/v1/messages?beta=true");
16
+ assert.equal(seen.options.headers.authorization, "Bearer pr-test");
17
+ assert.equal(seen.options.headers["anthropic-beta"], "compact-2026-01-12");
18
+ const body = JSON.parse(seen.options.body);
19
+ assert.equal(body.context_management.edits[0].type, "compact_20260112");
20
+ assert.equal(body.tools[0].name, "pennyrouter_install_probe");
21
+
22
+ globalThis.fetch = async () => ({
23
+ ok: false, status: 502, text: async () => "context_management rejected",
24
+ });
25
+ await assert.rejects(
26
+ probeClaudeCodeGateway({ gatewayBaseUrl: "https://gateway.example", apiKey: "pr-test" }),
27
+ /HTTP 502.*No Claude Code settings were changed/,
28
+ );
29
+ } finally {
30
+ globalThis.fetch = originalFetch;
31
+ }
32
+
package/src/statusline.js CHANGED
@@ -153,13 +153,24 @@ function authSuffix(mode) {
153
153
  return mode === "sub" || mode === "byok" ? " (" + mode + ")" : "";
154
154
  }
155
155
 
156
+ // Teammate badges (@penny ask): 📨 questions waiting on you, 📬 answers that came back.
157
+ // Read from the structured fields rather than the gateway's prebuilt \`label\`, since this
158
+ // line is composed here. Absent on an older gateway, which renders nothing.
159
+ function askBadges(c) {
160
+ if (!c) return "";
161
+ let out = "";
162
+ if (c.asks_open > 0) out += " · 📨 " + c.asks_open;
163
+ if (c.answers_fresh > 0) out += " · 📬 " + c.answers_fresh;
164
+ return out;
165
+ }
166
+
156
167
  let penny;
157
168
  if (!pennyInstalled) {
158
169
  penny = "🪙 penny off";
159
170
  } else if (cache && cache.known && cache.bypass) {
160
171
  penny = "🪙 penny off";
161
172
  } else {
162
- penny = "🪙 penny" + authSuffix(cache && cache.auth_mode);
173
+ penny = "🪙 penny" + authSuffix(cache && cache.auth_mode) + askBadges(cache);
163
174
  }
164
175
 
165
176
  let lines;