pennyrouter 0.2.24 → 0.2.26

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.24",
3
+ "version": "0.2.26",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
package/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
2
3
  import { emitKeypressEvents } from "node:readline";
3
4
  import { createInterface } from "node:readline/promises";
4
5
  import { stdin as input, stdout as output } from "node:process";
@@ -25,6 +26,7 @@ import {
25
26
  APP_URL,
26
27
  createCliSession,
27
28
  forgetAnthropicToken,
29
+ forgetUpstreamCredential,
28
30
  maskKey,
29
31
  pollForKey,
30
32
  probeClaudeCodeGateway,
@@ -71,6 +73,7 @@ const CLAUDE_PROVIDER_OPTIONS = [
71
73
  { id: "subscription", name: "Claude subscription" },
72
74
  { id: "anthropic-api", name: "Anthropic API key" },
73
75
  { id: "openrouter", name: "OpenRouter API key" },
76
+ { id: "anthropic-proxy", name: "Other proxy (custom base URL)" },
74
77
  ];
75
78
  const CODEX_PROVIDER_OPTIONS = [
76
79
  { id: "subscription", name: "ChatGPT subscription" },
@@ -168,6 +171,10 @@ function parseArgs(argv) {
168
171
  else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
169
172
  else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
170
173
  else if (arg === "--openrouter-api-key") flags.openrouterApiKey = rest[++i] || "";
174
+ else if (arg === "--proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
175
+ else if (arg.startsWith("--proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--proxy-base-url=".length);
176
+ else if (arg === "--proxy-api-key") flags.proxyApiKey = rest[++i] || "";
177
+ else if (arg.startsWith("--proxy-api-key=")) flags.proxyApiKey = arg.slice("--proxy-api-key=".length);
171
178
  else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
172
179
  else if (arg === "--harness") flags.harness = rest[++i] || "";
173
180
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
@@ -181,15 +188,98 @@ function parseArgs(argv) {
181
188
  // `--local=<url>` overrides the port/host.
182
189
  else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
183
190
  else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
191
+ // --prod: point the installed harness back at the default production gateway
192
+ // (https://api.pennyrouter.com). Undoes --local.
193
+ else if (arg === "--prod") flags.gatewayBaseUrl = DEFAULT_GATEWAY_BASE_URL;
184
194
  else if (arg === "--forget") flags.forget = true;
185
195
  else if (arg === "--forget-token") flags.forgetToken = true;
196
+ else if (arg === "--config") flags.configPath = rest[++i] || "";
197
+ else if (arg.startsWith("--config=")) flags.configPath = arg.slice("--config=".length);
186
198
  else if (!arg.startsWith("-")) flags.args.push(arg);
187
199
  else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
188
200
  }
189
201
 
202
+ if (flags.configPath) applyConfigFile(flags);
203
+
190
204
  return { command, flags };
191
205
  }
192
206
 
207
+ // Lets a GUI installer (e.g. the macOS DMG) answer the install questions up front instead of
208
+ // over a TTY: pick harnesses, pick funding per harness, optionally supply keys. Populates the
209
+ // same `flags` fields the equivalent CLI flags set, so it flows through the existing
210
+ // non-interactive paths unchanged. CLI flags win over the config file where both are given.
211
+ // Funding choices imply --yes (skip remaining prompts) but, unlike bare --yes, do NOT suppress
212
+ // the Claude subscription browser sign-in — see maybeConfigureClaudeAnthropicAuth.
213
+ function applyConfigFile(flags) {
214
+ let config;
215
+ try {
216
+ config = JSON.parse(readFileSync(flags.configPath, "utf8"));
217
+ } catch (error) {
218
+ throw new Error(`Could not read --config file "${flags.configPath}": ${error.message}`);
219
+ }
220
+
221
+ if (config.harnesses && flags.harness === undefined) {
222
+ flags.harness = config.harnesses.join(",");
223
+ }
224
+ const claudeFunding = new Set(config.claudeFunding || []);
225
+ const codexFunding = new Set(config.codexFunding || []);
226
+ if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
227
+ flags.anthropicAuth = true;
228
+ }
229
+ if (claudeFunding.has("anthropic-api") && flags.anthropicApiKey === undefined) {
230
+ flags.anthropicApiKey = config.anthropicApiKey || "";
231
+ }
232
+ if ((claudeFunding.has("openrouter") || codexFunding.has("openrouter"))
233
+ && flags.openrouterApiKey === undefined) {
234
+ flags.openrouterApiKey = config.openrouterApiKey || "";
235
+ }
236
+ if (codexFunding.has("openai-api") && flags.openaiApiKey === undefined) {
237
+ flags.openaiApiKey = config.openaiApiKey || "";
238
+ }
239
+ if (claudeFunding.has("anthropic-proxy")) {
240
+ if (flags.proxyBaseUrl === undefined) flags.proxyBaseUrl = config.proxyBaseUrl || "";
241
+ if (flags.proxyApiKey === undefined) flags.proxyApiKey = config.proxyApiKey || "";
242
+ }
243
+
244
+ flags.yes = true;
245
+ flags.fromConfig = true;
246
+ flags.configClaudeFunding = claudeFunding;
247
+ flags.configCodexFunding = codexFunding;
248
+ }
249
+
250
+ // A harness needs the account-claim/funding flow only if it's new, or if the funding the
251
+ // config asked for differs from what's already stored on it — an unchanged re-run of
252
+ // --config should behave like `update`, not force a fresh Claude/PennyRouter sign-in.
253
+ // Plain (non--config) installs call every selected harness "changed" by always passing an
254
+ // empty requested set here, so this only takes effect on the --config path (see `needsAuth`
255
+ // in install()).
256
+ export function harnessFundingChanged(flags, harness, manifest) {
257
+ const existingRecord = (manifest.installations || []).find(
258
+ (candidate) => candidate.harness === harness.id,
259
+ );
260
+ if (!existingRecord) return true;
261
+
262
+ if (harness.id === "claude-code") {
263
+ const requested = flags.configClaudeFunding || new Set();
264
+ if (requested.size === 0) return false;
265
+ const current = existingRecord.user_funded_provider === "claude-subscription"
266
+ ? "subscription"
267
+ : existingRecord.user_funded_provider;
268
+ return requested.size !== 1 || !requested.has(current);
269
+ }
270
+
271
+ if (harness.id === "codex") {
272
+ const requested = flags.configCodexFunding || new Set();
273
+ if (requested.size === 0) return false;
274
+ const current = existingRecord.openai_subscription_auth
275
+ ? "subscription"
276
+ : existingRecord.user_funded_provider;
277
+ return requested.size !== 1 || !requested.has(current);
278
+ }
279
+
280
+ return false;
281
+ }
282
+
193
283
  async function authProvider(flags) {
194
284
  const provider = (flags.args[0] || "anthropic").toLowerCase();
195
285
  if (provider !== "anthropic" && provider !== "claude") {
@@ -204,11 +294,21 @@ async function authProvider(flags) {
204
294
  "No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
205
295
  );
206
296
  }
207
- const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || DEFAULT_GATEWAY_BASE_URL;
297
+ // Resolve against the INSTALLED config before falling back to the prod default.
298
+ // This is a destructive call against whichever gateway it reaches, so defaulting to
299
+ // production while the user is working against a local gateway would silently delete
300
+ // the wrong account's token (and leave the local one in place, looking like a no-op).
301
+ const globalAuth = await readClaudeCodeGatewayAuth();
302
+ const gatewayBaseUrl = flags.gatewayBaseUrl
303
+ || process.env.PENNYROUTER_GATEWAY_BASE_URL
304
+ || launchConfig.gateway_base_url
305
+ || globalAuth.gatewayBaseUrl
306
+ || DEFAULT_GATEWAY_BASE_URL;
208
307
  if (flags.dryRun) {
209
308
  console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
210
309
  return;
211
310
  }
311
+ console.log(`Gateway: ${gatewayBaseUrl}`);
212
312
  const ok = await forgetAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey });
213
313
  if (ok) {
214
314
  console.log("Successfully forgot stored Anthropic token on server.");
@@ -436,40 +536,75 @@ export async function install(flags = {}) {
436
536
  return;
437
537
  }
438
538
 
439
- const session = await createCliSession({
440
- appUrl: flags.appUrl || APP_URL,
441
- harnesses: selected.map((harness) => harness.id),
442
- accountMode: "existing",
443
- });
539
+ // A --config re-run against an existing install only needs a fresh account claim and
540
+ // funding popups for harnesses that are actually new or whose requested funding differs
541
+ // from what's already stored — an unchanged harness should behave like `update`, not
542
+ // trigger the Claude/PennyRouter sign-in windows again. Plain (non-config) installs always
543
+ // treat everything selected as needing auth, matching the original behavior.
544
+ const needsAuth = flags.fromConfig
545
+ ? selected.filter((harness) => harnessFundingChanged(flags, harness, manifest))
546
+ : selected;
444
547
 
445
- console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
446
- console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
447
- if (!flags.noBrowser) openBrowser(session.authorize_url);
448
- console.log("");
449
- console.log("Waiting for approval...");
548
+ let claim;
549
+ let claudeAnthropicAuth = null;
550
+ let funding = { credentials: [], codexSubscriptionAuth: false, claudeCredentialProvider: null, codexCredentialProvider: null };
551
+ let gatewayBaseUrl;
450
552
 
451
- const claim = await pollForKey(session);
452
- console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
453
- console.log("");
454
-
455
- const claudeProviders = await selectClaudeFundingProviders(flags, selected);
456
- const codexProviders = await selectCodexFundingProviders(flags, selected);
457
- const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
458
- flags, selected, claudeProviders.has("subscription"));
459
- const funding = await configureUserFunding(
460
- flags, selected, claudeAnthropicAuth, claudeProviders, codexProviders);
461
- const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
462
-
463
- for (const credential of funding.credentials) {
464
- await storeUpstreamCredential({
465
- gatewayBaseUrl,
466
- apiKey: claim.api_key,
467
- provider: credential.provider,
468
- token: credential.token,
553
+ if (needsAuth.length === 0) {
554
+ const launchConfig = await loadPennyConfig();
555
+ const globalAuth = await readClaudeCodeGatewayAuth();
556
+ const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
557
+ if (!apiKey) {
558
+ throw new Error(
559
+ "Could not find your PennyRouter key in the local config. Pass `--penny-key pr-...`, "
560
+ + "or remove --config to re-authorize.",
561
+ );
562
+ }
563
+ console.log("No new tools or funding requested — refreshing your existing setup.");
564
+ console.log("");
565
+ claim = { api_key: apiKey, gateway_base_url: null };
566
+ gatewayBaseUrl = flags.gatewayBaseUrl
567
+ || launchConfig.gateway_base_url
568
+ || globalAuth.gatewayBaseUrl
569
+ || process.env.PENNYROUTER_GATEWAY_BASE_URL
570
+ || DEFAULT_GATEWAY_BASE_URL;
571
+ } else {
572
+ const session = await createCliSession({
573
+ appUrl: flags.appUrl || APP_URL,
574
+ harnesses: selected.map((harness) => harness.id),
575
+ accountMode: "existing",
469
576
  });
577
+
578
+ console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
579
+ console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
580
+ if (!flags.noBrowser) openBrowser(session.authorize_url);
581
+ console.log("");
582
+ console.log("Waiting for approval...");
583
+
584
+ claim = await pollForKey(session);
585
+ console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
586
+ console.log("");
587
+
588
+ const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
589
+ const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
590
+ claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
591
+ flags, needsAuth, claudeProviders.has("subscription"));
592
+ funding = await configureUserFunding(
593
+ flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
594
+ gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
595
+
596
+ for (const credential of funding.credentials) {
597
+ await storeUpstreamCredential({
598
+ gatewayBaseUrl,
599
+ apiKey: claim.api_key,
600
+ provider: credential.provider,
601
+ token: credential.token,
602
+ metadata: credential.metadata,
603
+ });
604
+ }
470
605
  }
471
606
 
472
- if (selected.some((harness) => harness.id === "claude-code")) {
607
+ if (needsAuth.some((harness) => harness.id === "claude-code")) {
473
608
  console.log("Verifying modern Claude Code compatibility before changing local settings...");
474
609
  await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
475
610
  console.log("");
@@ -501,8 +636,9 @@ export async function install(flags = {}) {
501
636
  assertNoRunningHarness(harness.id, flags);
502
637
  }
503
638
 
639
+ const harnessNeededAuth = needsAuth.includes(harness);
504
640
  const codexSubscriptionAuth = harness.id === "codex"
505
- ? funding.codexSubscriptionAuth
641
+ ? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
506
642
  : false;
507
643
  const claudeSubscriptionAuth = harness.id === "claude-code"
508
644
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
@@ -550,11 +686,20 @@ export async function install(flags = {}) {
550
686
  record.anthropic_token_stored = true;
551
687
  record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
552
688
  }
553
- if (harness.id === "claude-code" && funding.claudeCredentialProvider) {
554
- record.user_funded_provider = funding.claudeCredentialProvider;
689
+ if (harness.id === "claude-code") {
690
+ record.user_funded_provider = harnessNeededAuth
691
+ ? (funding.claudeCredentialProvider || existingRecord?.user_funded_provider)
692
+ : existingRecord?.user_funded_provider;
693
+ // Host only, never the key — the key lives encrypted on the gateway.
694
+ const proxyBaseUrl = harnessNeededAuth
695
+ ? (funding.claudeProxyBaseUrl ?? existingRecord?.proxy_base_url ?? null)
696
+ : (existingRecord?.proxy_base_url ?? null);
697
+ if (proxyBaseUrl) record.proxy_base_url = proxyBaseUrl;
555
698
  }
556
- if (harness.id === "codex" && funding.codexCredentialProvider) {
557
- record.user_funded_provider = funding.codexCredentialProvider;
699
+ if (harness.id === "codex") {
700
+ record.user_funded_provider = harnessNeededAuth
701
+ ? (funding.codexCredentialProvider || existingRecord?.user_funded_provider)
702
+ : existingRecord?.user_funded_provider;
558
703
  }
559
704
  if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
560
705
  records.push(record);
@@ -936,14 +1081,19 @@ async function selectClaudeFundingProviders(flags, selected) {
936
1081
  || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
937
1082
  if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
938
1083
  if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
1084
+ if ((flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL)
1085
+ && (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
1086
+ supplied.add("anthropic-proxy");
1087
+ }
939
1088
 
940
1089
  // Explicit flags are the automation selector. Interactive installs always default to the
941
1090
  // subscription alone; environment keys are used if the user checks their provider.
942
1091
  const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
943
- || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey;
1092
+ || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
1093
+ || flags.proxyBaseUrl || flags.proxyApiKey;
944
1094
  if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
945
1095
 
946
- console.log("PennyRouter can use your Claude subscription, an Anthropic API key, or an OpenRouter API key to run Claude Code.");
1096
+ console.log("PennyRouter can use your Claude subscription, an Anthropic API key, an OpenRouter API key, or your own Anthropic-compatible proxy to run Claude Code.");
947
1097
  console.log("Choose one or more providers. PennyRouter will try them in order when available.");
948
1098
  return promptFundingProviderCheckboxes(
949
1099
  CLAUDE_PROVIDER_OPTIONS,
@@ -982,7 +1132,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
982
1132
  if (tokenResult.token) return tokenResult;
983
1133
  }
984
1134
 
985
- if (flags.yes || !input.isTTY || !output.isTTY) {
1135
+ // --config drives --yes to skip prompts, but it still expects the subscription browser
1136
+ // sign-in to run — the caller (e.g. the macOS installer) attaches a pty for exactly this,
1137
+ // so treat it like the interactive path rather than the plain --yes/non-TTY automation path.
1138
+ if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
986
1139
  if (flags.anthropicAuth) {
987
1140
  throw new Error(
988
1141
  "Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
@@ -998,7 +1151,8 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
998
1151
  if (commandExists("claude")) {
999
1152
  // The user already opted into subscription auth above, so run `claude setup-token`
1000
1153
  // directly and capture the token — no copy-paste, no second confirm. The command
1001
- // opens the browser for approval inline. On failure we fall through to manual paste.
1154
+ // opens the browser for approval inline. On failure we fall through to manual paste,
1155
+ // unless we came from --config, where there's no terminal to paste into.
1002
1156
  console.log("Launching Claude sign-in — approve in your browser…");
1003
1157
  const captured = await runClaudeSetupToken();
1004
1158
  if (captured) {
@@ -1006,8 +1160,13 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
1006
1160
  console.log("");
1007
1161
  return { token: captured, source: "setup-token" };
1008
1162
  }
1163
+ if (flags.fromConfig) {
1164
+ throw new Error("Claude sign-in did not complete. Run the installer again.");
1165
+ }
1009
1166
  console.log("");
1010
1167
  console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
1168
+ } else if (flags.fromConfig) {
1169
+ throw new Error("Claude Code was not found on PATH, so subscription sign-in could not run.");
1011
1170
  } else {
1012
1171
  console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
1013
1172
  }
@@ -1037,6 +1196,12 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1037
1196
  let openrouterApiKey = String(
1038
1197
  flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
1039
1198
  ).trim();
1199
+ let proxyBaseUrl = String(
1200
+ flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL || "",
1201
+ ).trim();
1202
+ let proxyApiKey = String(
1203
+ flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY || "",
1204
+ ).trim();
1040
1205
  const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
1041
1206
  ? await detectCodexChatgptAuth()
1042
1207
  : false;
@@ -1049,6 +1214,19 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1049
1214
  if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
1050
1215
  openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
1051
1216
  }
1217
+ if (wantsClaude && claudeProviders.has("anthropic-proxy") && !(proxyBaseUrl && proxyApiKey)) {
1218
+ if (!interactive) {
1219
+ throw new Error(
1220
+ "Other proxy was selected, but --proxy-base-url and --proxy-api-key were not both provided.",
1221
+ );
1222
+ }
1223
+ // The URL is not a secret and is easy to mistype, so it echoes; the key does not.
1224
+ if (!proxyBaseUrl) proxyBaseUrl = await promptText("Proxy base URL (e.g. https://proxy.example.com): ");
1225
+ if (!proxyApiKey) proxyApiKey = (await promptSecretish("Proxy API key: ")).trim();
1226
+ if (!proxyBaseUrl || !proxyApiKey) {
1227
+ throw new Error("Other proxy requires both a base URL and an API key.");
1228
+ }
1229
+ }
1052
1230
  if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
1053
1231
  throw new Error(
1054
1232
  "ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
@@ -1063,7 +1241,9 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1063
1241
  openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
1064
1242
  }
1065
1243
 
1066
- if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey) {
1244
+ const claudeProxyConfigured = Boolean(proxyBaseUrl && proxyApiKey);
1245
+ if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey
1246
+ && !claudeProxyConfigured) {
1067
1247
  if (!interactive) {
1068
1248
  throw new Error(
1069
1249
  "Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
@@ -1116,13 +1296,23 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1116
1296
  if ((wantsClaude || wantsCodex) && openrouterApiKey) {
1117
1297
  credentials.push({ provider: "openrouter", token: openrouterApiKey });
1118
1298
  }
1299
+ if (wantsClaude && claudeProxyConfigured) {
1300
+ credentials.push({
1301
+ provider: "anthropic_proxy",
1302
+ token: proxyApiKey,
1303
+ metadata: { base_url: proxyBaseUrl },
1304
+ });
1305
+ }
1119
1306
 
1120
1307
  return {
1121
1308
  credentials,
1122
1309
  codexSubscriptionAuth,
1123
1310
  claudeCredentialProvider: claudeAnthropicAuth
1124
1311
  ? "claude-subscription"
1125
- : anthropicApiKey ? "anthropic-api" : openrouterApiKey ? "openrouter" : null,
1312
+ : anthropicApiKey ? "anthropic-api"
1313
+ : claudeProxyConfigured ? "anthropic-proxy"
1314
+ : openrouterApiKey ? "openrouter" : null,
1315
+ claudeProxyBaseUrl: claudeProxyConfigured ? proxyBaseUrl : null,
1126
1316
  codexCredentialProvider: codexSubscriptionAuth
1127
1317
  ? "chatgpt-subscription"
1128
1318
  : openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
@@ -1295,7 +1485,7 @@ async function uninstall(flags) {
1295
1485
  const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
1296
1486
  if (flags.forgetToken) {
1297
1487
  await forgetStoredAnthropicToken(record);
1298
- } else if (record.anthropic_token_stored) {
1488
+ } else if (record.anthropic_token_stored || record.proxy_base_url) {
1299
1489
  anthropicTokenNotice = true;
1300
1490
  }
1301
1491
  if (record.launch_available) await removePennyHarness(record.harness);
@@ -1315,7 +1505,7 @@ async function uninstall(flags) {
1315
1505
  await runHarnessJob(failures, record.harness, "uninstall", async () => {
1316
1506
  if (flags.forgetToken) {
1317
1507
  await forgetStoredAnthropicToken(record);
1318
- } else if (record.anthropic_token_stored) {
1508
+ } else if (record.anthropic_token_stored || record.proxy_base_url) {
1319
1509
  anthropicTokenNotice = true;
1320
1510
  }
1321
1511
  if (record.launch_available) await removePennyHarness(record.harness);
@@ -1409,6 +1599,10 @@ async function status() {
1409
1599
  if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
1410
1600
  if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
1411
1601
  if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
1602
+ if (record.proxy_base_url) {
1603
+ const note = record.anthropic_oauth ? " (subscription takes precedence)" : "";
1604
+ console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
1605
+ }
1412
1606
  if (record.launch_available) {
1413
1607
  const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
1414
1608
  console.log(` command: ${command}`);
@@ -1719,14 +1913,24 @@ function formatError(error) {
1719
1913
  // live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
1720
1914
  // must not be blocked by a network hiccup; the record is going away regardless.
1721
1915
  async function forgetStoredAnthropicToken(record) {
1722
- if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
1916
+ if (record.harness !== "claude-code") return;
1917
+ if (!record.anthropic_token_stored && !record.proxy_base_url) return;
1723
1918
  try {
1724
1919
  const launchConfig = await loadPennyConfig();
1725
1920
  const globalAuth = await readClaudeCodeGatewayAuth();
1726
1921
  const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
1727
1922
  const apiKey = launchConfig.api_key || globalAuth.apiKey;
1728
1923
  if (!gatewayBaseUrl || !apiKey) return;
1729
- await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
1924
+ if (record.anthropic_token_stored) {
1925
+ await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
1926
+ }
1927
+ // The proxy key is stored server-side exactly like the subscription token, so the
1928
+ // same "forget my credentials" gesture has to clear it too.
1929
+ if (record.proxy_base_url) {
1930
+ await forgetUpstreamCredential({
1931
+ gatewayBaseUrl, apiKey, provider: "anthropic_proxy",
1932
+ });
1933
+ }
1730
1934
  } catch {
1731
1935
  // swallow — see note above
1732
1936
  }
@@ -1739,6 +1943,15 @@ async function confirm(question) {
1739
1943
  return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
1740
1944
  }
1741
1945
 
1946
+ // Visible-echo input, for values that are not secrets. A mistyped proxy URL is a
1947
+ // confusing failure much later, so the user gets to see what they typed.
1948
+ async function promptText(question) {
1949
+ const rl = createInterface({ input, output });
1950
+ const answer = await rl.question(question);
1951
+ rl.close();
1952
+ return answer.trim();
1953
+ }
1954
+
1742
1955
  async function promptSecretish(question) {
1743
1956
  if (!input.isTTY || !output.isTTY) {
1744
1957
  const rl = createInterface({ input, output });
@@ -1858,8 +2071,8 @@ function printHelp() {
1858
2071
  console.log(`PennyRouter CLI
1859
2072
 
1860
2073
  Usage:
1861
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]] [--allow-running-harnesses]
1862
- pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]] [--no-mcp] [--allow-running-harnesses]
2074
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--local[=URL]|--prod] [--config PATH] [--allow-running-harnesses]
2075
+ pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp] [--allow-running-harnesses]
1863
2076
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1864
2077
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1865
2078
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -1888,16 +2101,19 @@ Examples:
1888
2101
  npx pennyrouter update --harness claude-code
1889
2102
  npx pennyrouter update --harness codex --local
1890
2103
  npx pennyrouter update --all --local
2104
+ npx pennyrouter update --all --prod
1891
2105
  npx pennyrouter install
1892
2106
  npx pennyrouter install --make-default
1893
2107
  npx pennyrouter install --penny-only
1894
2108
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1895
2109
  npx pennyrouter install --harness claude-code --anthropic-api-key <key>
1896
2110
  npx pennyrouter install --harness codex --openai-api-key <key>
2111
+ npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
1897
2112
  npx pennyrouter install --openrouter-api-key <key>
1898
2113
  npx pennyrouter auth anthropic
1899
2114
  npx pennyrouter install --all
1900
2115
  npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
2116
+ npx pennyrouter install --config ./penny_install.json
1901
2117
  npx pennyrouter disable
1902
2118
  npx pennyrouter enable
1903
2119
  npx pennyrouter uninstall
@@ -0,0 +1,196 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { harnessFundingChanged, main } from "./cli.js";
7
+
8
+ // --config lets a GUI installer (the macOS DMG) answer install questions up front instead of
9
+ // over a TTY. These tests drive it through `main()` in --dry-run, which prints its plan and
10
+ // returns before any network call or filesystem write, so no mocking is needed.
11
+
12
+ function withConfigFile(config, run) {
13
+ const dir = mkdtempSync(join(tmpdir(), "pennyrouter-config-"));
14
+ const path = join(dir, "penny_install.json");
15
+ writeFileSync(path, JSON.stringify(config));
16
+ try {
17
+ return run(path);
18
+ } finally {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ }
21
+ }
22
+
23
+ function captureLogs(run) {
24
+ const original = console.log;
25
+ const lines = [];
26
+ console.log = (...args) => lines.push(args.join(" "));
27
+ return run().finally(() => { console.log = original; }).then(() => lines);
28
+ }
29
+
30
+ test("--config selects harnesses without prompting", async () => {
31
+ await withConfigFile({ harnesses: ["claude-code"] }, async (path) => {
32
+ const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
33
+ assert.ok(lines.some((line) => line.includes("Claude Code")));
34
+ assert.ok(!lines.some((line) => line.includes("Codex")));
35
+ });
36
+ });
37
+
38
+ test("--config with claudeFunding subscription sets --anthropic-auth", async () => {
39
+ await withConfigFile(
40
+ { harnesses: ["claude-code"], claudeFunding: ["subscription"] },
41
+ async (path) => {
42
+ const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
43
+ assert.ok(lines.some((line) => line.includes("Claude Code provider picker")));
44
+ },
45
+ );
46
+ });
47
+
48
+ test("--config rejects an unreadable file", async () => {
49
+ await assert.rejects(
50
+ () => main(["install", "--config", "/nonexistent/penny_install.json", "--dry-run"]),
51
+ /Could not read --config file/,
52
+ );
53
+ });
54
+
55
+ test("explicit --harness overrides --config harnesses", async () => {
56
+ await withConfigFile({ harnesses: ["codex"] }, async (path) => {
57
+ const lines = await captureLogs(
58
+ () => main(["install", "--harness", "claude-code", "--config", path, "--dry-run"]),
59
+ );
60
+ assert.ok(lines.some((line) => line.includes("Claude Code")));
61
+ assert.ok(!lines.some((line) => line.includes("Codex")));
62
+ });
63
+ });
64
+
65
+ // harnessFundingChanged decides whether a --config re-run needs to re-open the account-claim
66
+ // and subscription-auth popups for a harness, or can treat it like `update` (skip both). This
67
+ // only kicks in on --config re-runs against an existing manifest; a live end-to-end run against
68
+ // a real account confirmed the same three cases these unit tests cover.
69
+
70
+ const claudeHarness = { id: "claude-code" };
71
+ const codexHarness = { id: "codex" };
72
+
73
+ test("harnessFundingChanged: brand new harness always needs auth", () => {
74
+ const manifest = { installations: [] };
75
+ assert.equal(harnessFundingChanged({}, claudeHarness, manifest), true);
76
+ });
77
+
78
+ test("harnessFundingChanged: claude subscription unchanged", () => {
79
+ const manifest = {
80
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
81
+ };
82
+ const flags = { configClaudeFunding: new Set(["subscription"]) };
83
+ assert.equal(harnessFundingChanged(flags, claudeHarness, manifest), false);
84
+ });
85
+
86
+ test("harnessFundingChanged: claude subscription to anthropic-api is a change", () => {
87
+ const manifest = {
88
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
89
+ };
90
+ const flags = { configClaudeFunding: new Set(["anthropic-api"]) };
91
+ assert.equal(harnessFundingChanged(flags, claudeHarness, manifest), true);
92
+ });
93
+
94
+ test("harnessFundingChanged: codex subscription unchanged (openai_subscription_auth flag)", () => {
95
+ const manifest = {
96
+ installations: [{
97
+ harness: "codex",
98
+ user_funded_provider: "chatgpt-subscription",
99
+ openai_subscription_auth: true,
100
+ }],
101
+ };
102
+ const flags = { configCodexFunding: new Set(["subscription"]) };
103
+ assert.equal(harnessFundingChanged(flags, codexHarness, manifest), false);
104
+ });
105
+
106
+ test("harnessFundingChanged: codex openrouter unchanged", () => {
107
+ const manifest = {
108
+ installations: [{
109
+ harness: "codex",
110
+ user_funded_provider: "openrouter",
111
+ openai_subscription_auth: false,
112
+ }],
113
+ };
114
+ const flags = { configCodexFunding: new Set(["openrouter"]) };
115
+ assert.equal(harnessFundingChanged(flags, codexHarness, manifest), false);
116
+ });
117
+
118
+ test("harnessFundingChanged: no requested funding for an existing harness is not a change", () => {
119
+ const manifest = {
120
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
121
+ };
122
+ assert.equal(harnessFundingChanged({}, claudeHarness, manifest), false);
123
+ });
124
+
125
+ // --- Other proxy (user-supplied Anthropic-compatible base URL) ----------------
126
+
127
+ test("--proxy-base-url and --proxy-api-key parse in both forms", async () => {
128
+ for (const args of [
129
+ ["--proxy-base-url", "https://proxy.example.com", "--proxy-api-key", "k"],
130
+ ["--proxy-base-url=https://proxy.example.com", "--proxy-api-key=k"],
131
+ ]) {
132
+ const lines = await captureLogs(() => main([
133
+ "install", "--harness", "claude-code", "--dry-run", ...args,
134
+ ]));
135
+ assert.ok(lines.length > 0);
136
+ }
137
+ });
138
+
139
+ test("--config maps anthropic-proxy funding to the proxy flags", async () => {
140
+ await withConfigFile(
141
+ {
142
+ harnesses: ["claude-code"],
143
+ claudeFunding: ["anthropic-proxy"],
144
+ proxyBaseUrl: "https://proxy.example.com",
145
+ proxyApiKey: "secret-key",
146
+ },
147
+ async (path) => {
148
+ const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
149
+ assert.ok(lines.some((line) => line.includes("Claude Code")));
150
+ // The key must never be echoed to the terminal.
151
+ assert.ok(!lines.some((line) => line.includes("secret-key")));
152
+ },
153
+ );
154
+ });
155
+
156
+ test("harnessFundingChanged sees a switch to the proxy as a change", () => {
157
+ const manifest = {
158
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
159
+ };
160
+ const changed = harnessFundingChanged(
161
+ { configClaudeFunding: new Set(["anthropic-proxy"]) },
162
+ { id: "claude-code" },
163
+ manifest,
164
+ );
165
+ assert.equal(changed, true);
166
+ });
167
+
168
+ test("harnessFundingChanged sees an unchanged proxy install as unchanged", () => {
169
+ const manifest = {
170
+ installations: [{ harness: "claude-code", user_funded_provider: "anthropic-proxy" }],
171
+ };
172
+ const changed = harnessFundingChanged(
173
+ { configClaudeFunding: new Set(["anthropic-proxy"]) },
174
+ { id: "claude-code" },
175
+ manifest,
176
+ );
177
+ assert.equal(changed, false);
178
+ });
179
+
180
+ test("auth anthropic --forget targets the installed gateway, not prod", async () => {
181
+ // Regression: this defaulted to https://api.pennyrouter.com even when the machine was
182
+ // installed against a local gateway, silently deleting the wrong account's token.
183
+ const lines = await captureLogs(() => main([
184
+ "auth", "anthropic", "--forget", "--dry-run",
185
+ "--penny-key", "pr-test", "--gateway-base-url", "http://localhost:8400",
186
+ ]));
187
+ assert.ok(lines.some((line) => line.includes("http://localhost:8400")));
188
+ assert.ok(!lines.some((line) => line.includes("api.pennyrouter.com")));
189
+ });
190
+
191
+ test("auth anthropic --forget honors --local", async () => {
192
+ const lines = await captureLogs(() => main([
193
+ "auth", "anthropic", "--forget", "--dry-run", "--local", "--penny-key", "pr-test",
194
+ ]));
195
+ assert.ok(lines.some((line) => line.includes("localhost:8400")));
196
+ });
package/src/launch.js CHANGED
@@ -438,6 +438,11 @@ export function buildPennyInvocation(harness, args, config) {
438
438
  if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = upgradeClaudeCodeModelValue(models.sonnet);
439
439
  if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = upgradeClaudeCodeModelValue(models.opus);
440
440
  if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = upgradeClaudeCodeModelValue(models.fable);
441
+ if (models.custom) {
442
+ env.ANTHROPIC_CUSTOM_MODEL_OPTION = upgradeClaudeCodeModelValue(models.custom);
443
+ if (models.custom_name) env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME = models.custom_name;
444
+ if (models.custom_description) env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION = models.custom_description;
445
+ }
441
446
  writeClaudeCodeModelPresentation(env);
442
447
  // Per-model limits must come from Claude Code's recognized native model ids. This
443
448
  // global override would incorrectly give Haiku the 1M window.
package/src/session.js CHANGED
@@ -49,7 +49,7 @@ export function sleep(ms) {
49
49
  // Encrypt and store a user-owned upstream credential against the account server-side,
50
50
  // so the pr- key alone unlocks the selected harness and the provider secret is never
51
51
  // written to harness configuration.
52
- export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider, token }) {
52
+ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider, token, metadata }) {
53
53
  const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials`;
54
54
  const response = await fetch(url, {
55
55
  method: "POST",
@@ -57,7 +57,7 @@ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider
57
57
  "content-type": "application/json",
58
58
  authorization: `Bearer ${apiKey}`,
59
59
  },
60
- body: JSON.stringify({ provider, token }),
60
+ body: JSON.stringify({ provider, token, ...(metadata ? { metadata } : {}) }),
61
61
  });
62
62
  if (!response.ok) {
63
63
  let detail = "";
@@ -135,10 +135,10 @@ export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
135
135
 
136
136
  // Best-effort "forget my Claude token" on uninstall. Never throws — a cleanup that
137
137
  // can't reach the network shouldn't block removing the local integration.
138
- export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
138
+ export async function forgetUpstreamCredential({ gatewayBaseUrl, apiKey, provider }) {
139
139
  if (!gatewayBaseUrl || !apiKey) return false;
140
140
  try {
141
- const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials/anthropic`;
141
+ const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials/${encodeURIComponent(provider)}`;
142
142
  const response = await fetch(url, {
143
143
  method: "DELETE",
144
144
  headers: { authorization: `Bearer ${apiKey}` },
@@ -149,6 +149,10 @@ export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
149
149
  }
150
150
  }
151
151
 
152
+ export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
153
+ return forgetUpstreamCredential({ gatewayBaseUrl, apiKey, provider: "anthropic" });
154
+ }
155
+
152
156
  function platformName() {
153
157
  if (process.platform === "darwin") return "Mac";
154
158
  if (process.platform === "win32") return "Windows";
package/src/statusline.js CHANGED
@@ -11,6 +11,10 @@
11
11
  // authed with the pr- key already in Claude Code's settings). The gateway keys that
12
12
  // endpoint on the CC session uuid it sees in every request's metadata.user_id — the
13
13
  // same uuid Claude Code hands the statusline script on stdin.
14
+ //
15
+ // A wrapped user statusline command is cached the same way: it can be slow to spawn
16
+ // (npx especially, and especially on Windows — see WRAP_REFRESH_MS below), so it's never
17
+ // run inline on Claude Code's ~1s render loop.
14
18
 
15
19
  import { statePath, atomicWrite, readJsonFile, writeJsonFile } from "./state.js";
16
20
 
@@ -87,6 +91,14 @@ const stateDir = join(
87
91
  "pennyrouter",
88
92
  );
89
93
  const cacheDir = join(stateDir, "statusline-cache");
94
+ // A wrapped user statusline command can be slow to spawn (npx especially: on Windows
95
+ // it goes through an extra cmd.exe/npx.cmd hop and can take 2-4s per invocation, vs
96
+ // well under 1s on macOS/Linux). Claude Code re-invokes this script every ~1s
97
+ // (refreshInterval), so running the wrap command inline on every render blocked past
98
+ // that interval on Windows, renders piled up, and the statusline never finished in
99
+ // time to display at all. Cache its output and refresh it out-of-band instead, same
100
+ // as the Penny gateway segment below.
101
+ const WRAP_REFRESH_MS = 2000;
90
102
 
91
103
  function readJson(path) {
92
104
  try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
@@ -109,6 +121,8 @@ function claudeEnv() {
109
121
  return { base, key };
110
122
  }
111
123
 
124
+ const config = readJson(join(stateDir, "claude-statusline.json")) || {};
125
+
112
126
  // --refresh <sessionId>: fetch gateway status into the cache file, then exit.
113
127
  if (process.argv[2] === "--refresh") {
114
128
  const sessionId = process.argv[3];
@@ -131,11 +145,28 @@ if (process.argv[2] === "--refresh") {
131
145
  process.exit(0);
132
146
  }
133
147
 
148
+ // --refresh-wrap <sessionId>: run the (possibly slow) wrapped statusline command out of
149
+ // band and cache its output. Reads the stdin JSON the main render last saw, persisted to
150
+ // a per-session file since a detached child has no stdin of its own to read.
151
+ if (process.argv[2] === "--refresh-wrap") {
152
+ const sessionId = process.argv[3];
153
+ if (!sessionId || !config.wrap_command) process.exit(0);
154
+ let wrapInput = "{}";
155
+ try { wrapInput = readFileSync(join(cacheDir, sessionId + ".wrap-input.json"), "utf8"); } catch { /* none yet */ }
156
+ const run = spawnSync(config.wrap_command, {
157
+ shell: true, input: wrapInput, encoding: "utf8", timeout: 20000, windowsHide: true,
158
+ });
159
+ let wrapLines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
160
+ if (wrapLines.length === 1 && wrapLines[0] === "") wrapLines = [];
161
+ mkdirSync(cacheDir, { recursive: true });
162
+ writeFileSync(join(cacheDir, sessionId + ".wrap.json"), JSON.stringify({ lines: wrapLines, at: Date.now() }));
163
+ process.exit(0);
164
+ }
165
+
134
166
  const raw = readFileSync(0, "utf8");
135
167
  let input = {};
136
168
  try { input = JSON.parse(raw); } catch { /* keep {} */ }
137
169
  const sessionId = input.session_id || "";
138
- const config = readJson(join(stateDir, "claude-statusline.json")) || {};
139
170
  const refreshMs = (config.refresh_seconds ?? 15) * 1000;
140
171
 
141
172
  const cachePath = join(cacheDir, sessionId + ".json");
@@ -148,7 +179,7 @@ const { base, key } = claudeEnv();
148
179
  const pennyInstalled = Boolean(base) && Boolean(key);
149
180
  if (sessionId && pennyInstalled && cacheAge > refreshMs) {
150
181
  spawn(process.execPath, [process.argv[1], "--refresh", sessionId], {
151
- detached: true, stdio: "ignore",
182
+ detached: true, stdio: "ignore", windowsHide: true,
152
183
  }).unref();
153
184
  }
154
185
 
@@ -193,9 +224,27 @@ if (!pennyInstalled) {
193
224
  }
194
225
 
195
226
  let lines;
196
- if (config.wrap_command) {
227
+ if (config.wrap_command && sessionId) {
228
+ const wrapCachePath = join(cacheDir, sessionId + ".wrap.json");
229
+ const wrapCache = readJson(wrapCachePath);
230
+ const wrapAge = wrapCache && typeof wrapCache.at === "number" ? Date.now() - wrapCache.at : Infinity;
231
+ if (wrapAge > WRAP_REFRESH_MS) {
232
+ mkdirSync(cacheDir, { recursive: true });
233
+ writeFileSync(join(cacheDir, sessionId + ".wrap-input.json"), raw);
234
+ // Bump the cache timestamp before spawning: Claude Code polls every ~1s, so
235
+ // without this every render in the next couple seconds would see a still-stale
236
+ // cache and fire its own overlapping refresh while the first one is still running.
237
+ writeFileSync(wrapCachePath, JSON.stringify({ lines: (wrapCache && wrapCache.lines) || [], at: Date.now() }));
238
+ spawn(process.execPath, [process.argv[1], "--refresh-wrap", sessionId], {
239
+ detached: true, stdio: "ignore", windowsHide: true,
240
+ }).unref();
241
+ }
242
+ lines = (wrapCache && wrapCache.lines) || [];
243
+ } else if (config.wrap_command) {
244
+ // No session id (e.g. a manual/interactive invocation outside Claude Code): there's
245
+ // no cache key to keep state under, so run inline like before caching existed.
197
246
  const run = spawnSync(config.wrap_command, {
198
- shell: true, input: raw, encoding: "utf8", timeout: 5000,
247
+ shell: true, input: raw, encoding: "utf8", timeout: 5000, windowsHide: true,
199
248
  });
200
249
  lines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
201
250
  if (lines.length === 1 && lines[0] === "") lines = [];