pennyrouter 0.3.32 → 0.3.34

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/README.md CHANGED
@@ -97,6 +97,7 @@ directory to the user PATH:
97
97
 
98
98
  ```bash
99
99
  penny claude
100
+ penny claude --stats
100
101
  penny claude desktop
101
102
  penny claude desktop gateway
102
103
  penny claude desktop native
@@ -108,6 +109,9 @@ penny codex desktop native
108
109
  penny opencode
109
110
  ```
110
111
 
112
+ `penny claude --stats` launches Claude Code with Penny Compare's live single-session token and
113
+ cost readout. Other arguments still pass through, so `penny claude --stats --resume` works.
114
+
111
115
  Arguments are passed through to the native harness, so commands such as `penny claude --resume`,
112
116
  `penny codex resume --last`, and `penny opencode --continue` work normally. By default, bare
113
117
  `claude`, `codex`, and `opencode` remain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.32",
3
+ "version": "0.3.34",
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
@@ -1595,9 +1595,7 @@ async function selectCodexFundingProviders(flags, selected) {
1595
1595
 
1596
1596
  console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, an OpenAI-compatible proxy, or an OpenRouter API key to run Codex.");
1597
1597
  console.log("Choose one or more providers. PennyRouter will try them in order when available.");
1598
- const options = CODEX_PROVIDER_OPTIONS.filter(
1599
- ({ id }) => id !== "subscription" || hasChatgptLogin,
1600
- );
1598
+ const options = CODEX_PROVIDER_OPTIONS;
1601
1599
  return promptFundingProviderCheckboxes(
1602
1600
  options,
1603
1601
  "Which funding source should PennyRouter use for Codex?",
@@ -1703,7 +1701,7 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1703
1701
  || process.env.PENNYROUTER_ANTHROPIC_PROXY_API_KEY
1704
1702
  || process.env.PENNYROUTER_PROXY_API_KEY || "",
1705
1703
  ).trim();
1706
- const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
1704
+ let codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
1707
1705
  ? await detectCodexChatgptAuth()
1708
1706
  : false;
1709
1707
 
@@ -1729,9 +1727,15 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1729
1727
  }
1730
1728
  }
1731
1729
  if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
1732
- throw new Error(
1733
- "ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
1734
- );
1730
+ console.log("Codex needs to sign in to your ChatGPT account. Starting `codex login`...");
1731
+ const login = spawnSync("codex", ["login"], {
1732
+ stdio: "inherit",
1733
+ shell: process.platform === "win32",
1734
+ });
1735
+ if (login.error || login.status !== 0 || !(await detectCodexChatgptAuth())) {
1736
+ throw new Error("Codex login did not complete. Run `codex login`, then rerun `npx pennyrouter install`.");
1737
+ }
1738
+ codexSubscriptionAuth = true;
1735
1739
  }
1736
1740
  if (wantsCodex && codexProviders.has("openai-api") && !openaiApiKey) {
1737
1741
  if (!interactive) throw new Error("OpenAI API was selected, but no key was provided.");
@@ -2129,14 +2133,17 @@ async function selectHarnesses(flags) {
2129
2133
  }
2130
2134
 
2131
2135
  const detected = [];
2136
+ const available = [];
2132
2137
  for (const id of DEFAULT_INSTALL_HARNESS_IDS) {
2133
2138
  const harness = HARNESS_BY_ID[id];
2134
- if (await safeDetect(harness)) detected.push(harness);
2139
+ const installed = await safeDetect(harness);
2140
+ if (installed) detected.push(harness);
2141
+ available.push({ ...harness, installed });
2135
2142
  }
2136
2143
 
2137
2144
  if (flags.yes) return detected;
2138
2145
 
2139
- return promptHarnessCheckboxes(detected);
2146
+ return promptHarnessCheckboxes(available, detected);
2140
2147
  }
2141
2148
 
2142
2149
  async function printAdditionalHarnessesHint(selected) {
@@ -2153,10 +2160,14 @@ async function printAdditionalHarnessesHint(selected) {
2153
2160
  console.log(`To use PennyRouter with ${names}, install ${available.length === 1 ? "it" : "them"} and re-run \`npx pennyrouter install\`.`);
2154
2161
  }
2155
2162
 
2156
- async function promptHarnessCheckboxes(detected) {
2163
+ async function promptHarnessCheckboxes(available, detected) {
2157
2164
  const detectedIds = new Set(detected.map((harness) => harness.id));
2158
2165
  const selectedIds = new Set(detectedIds);
2159
- const harnesses = orderedHarnessesForPrompt(detectedIds);
2166
+ const harnesses = orderedHarnessesForPrompt(new Set(available.map((harness) => harness.id)))
2167
+ .map((harness) => ({
2168
+ ...harness,
2169
+ installed: available.find((item) => item.id === harness.id)?.installed ?? false,
2170
+ }));
2160
2171
  if (!input.isTTY || !output.isTTY) return promptHarnessCheckboxesByText(harnesses, selectedIds);
2161
2172
 
2162
2173
  return promptHarnessCheckboxesByKeys(harnesses, selectedIds);
@@ -2178,7 +2189,8 @@ async function promptHarnessCheckboxesByKeys(harnesses, selectedIds) {
2178
2189
  harnesses.forEach((harness, index) => {
2179
2190
  const pointer = index === cursor ? color(">", "cyan") : " ";
2180
2191
  const checked = selectedIds.has(harness.id) ? color("x", "green") : " ";
2181
- const name = index === cursor ? color(harness.name, "cyan") : harness.name;
2192
+ const label = harness.installed ? harness.name : `${harness.name} (install ${harness.name} first)`;
2193
+ const name = harness.installed && index === cursor ? color(label, "cyan") : label;
2182
2194
  output.write(` ${pointer} [${checked}] ${name}\n`);
2183
2195
  });
2184
2196
  output.write("\nEnter to continue.\n");
@@ -2215,7 +2227,12 @@ async function promptHarnessCheckboxesByKeys(harnesses, selectedIds) {
2215
2227
  }
2216
2228
 
2217
2229
  if (key.name === "space") {
2218
- const id = harnesses[cursor].id;
2230
+ const harness = harnesses[cursor];
2231
+ if (!harness.installed) {
2232
+ render();
2233
+ return;
2234
+ }
2235
+ const id = harness.id;
2219
2236
  if (selectedIds.has(id)) selectedIds.delete(id);
2220
2237
  else selectedIds.add(id);
2221
2238
  render();
@@ -2242,7 +2259,8 @@ async function promptHarnessCheckboxesByText(harnesses, selectedIds) {
2242
2259
  console.log(color("Where should PennyRouter be installed?", "bold"));
2243
2260
  for (const harness of harnesses) {
2244
2261
  const checked = selectedIds.has(harness.id) ? color("x", "green") : " ";
2245
- console.log(` [${checked}] ${harness.name}`);
2262
+ const label = harness.installed ? harness.name : `${harness.name} (install ${harness.name} first)`;
2263
+ console.log(` [${checked}] ${label}`);
2246
2264
  }
2247
2265
  console.log("");
2248
2266
  const answer = await rl.question(
@@ -2261,6 +2279,11 @@ async function promptHarnessCheckboxesByText(harnesses, selectedIds) {
2261
2279
  console.log(`Unknown tool "${token}".`);
2262
2280
  continue;
2263
2281
  }
2282
+ if (!harnesses.find((harness) => harness.id === id)?.installed) {
2283
+ const unavailable = HARNESS_BY_ID[id];
2284
+ console.log(`Install ${unavailable.name} first to select it.`);
2285
+ continue;
2286
+ }
2264
2287
  if (selectedIds.has(id)) selectedIds.delete(id);
2265
2288
  else selectedIds.add(id);
2266
2289
  }
package/src/launch.js CHANGED
@@ -1240,6 +1240,25 @@ export function extractProfileArgs(args) {
1240
1240
  return { profile, args: rest };
1241
1241
  }
1242
1242
 
1243
+ /**
1244
+ * Remove the launcher-only stats flag while preserving every native harness argument.
1245
+ * A bare `--` ends Penny's option parsing, so a later `--stats` still reaches the harness.
1246
+ */
1247
+ export function extractStatsArgs(args) {
1248
+ const rest = [];
1249
+ let stats = false;
1250
+ for (let i = 0; i < args.length; i += 1) {
1251
+ const arg = args[i];
1252
+ if (arg === "--") {
1253
+ rest.push(...args.slice(i));
1254
+ break;
1255
+ }
1256
+ if (arg === "--stats") stats = true;
1257
+ else rest.push(arg);
1258
+ }
1259
+ return { stats, args: rest };
1260
+ }
1261
+
1243
1262
  function assertNoConflictingArgs(harness, args) {
1244
1263
  if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
1245
1264
  throw new Error("penny claude manages --settings; remove that flag or run native claude.");
@@ -1416,23 +1435,63 @@ async function waitForChild(child) {
1416
1435
  });
1417
1436
  }
1418
1437
 
1438
+ /** Launch Penny Claude inside Penny Compare's single-session token readout. */
1439
+ export async function runPennyClaudeStats(args, profile) {
1440
+ if (!(process.platform === "darwin" || process.platform === "linux")) {
1441
+ throw new Error("penny claude --stats is supported on macOS and Linux.");
1442
+ }
1443
+ const panePath = join(pennyCompareDir(), "pane.py");
1444
+ try {
1445
+ await stat(panePath);
1446
+ } catch {
1447
+ throw new Error(
1448
+ "Penny's stats display is not installed. Run `npx pennyrouter compare` and try again.",
1449
+ );
1450
+ }
1451
+ const child = spawn("python3", [
1452
+ panePath,
1453
+ "penny-claude",
1454
+ "--side",
1455
+ "single",
1456
+ "--cwd",
1457
+ process.cwd(),
1458
+ "--harness-args-json",
1459
+ JSON.stringify(["--penny-profile", profile, ...args]),
1460
+ ], {
1461
+ cwd: process.cwd(),
1462
+ env: process.env,
1463
+ stdio: "inherit",
1464
+ });
1465
+ const result = await waitForChild(child);
1466
+ if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
1467
+ return result.code ?? 1;
1468
+ }
1469
+
1419
1470
  export async function runPenny(argv = process.argv.slice(2)) {
1420
1471
  const harness = argv[0];
1421
1472
  if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
1422
1473
  console.log("Usage: penny <claude|codex|opencode> [--penny-profile <name>] [native arguments...]");
1423
1474
  console.log(" --private / --cloud select the like-named profile.");
1475
+ console.log(" penny claude --stats adds the live single-session token readout.");
1424
1476
  return 0;
1425
1477
  }
1426
1478
  if (!["claude", "codex", "opencode"].includes(harness)) {
1427
1479
  throw new Error(`Unknown harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
1428
1480
  }
1429
- const selection = extractProfileArgs(argv.slice(1));
1481
+ const statsSelection = extractStatsArgs(argv.slice(1));
1482
+ if (statsSelection.stats && harness !== "claude") {
1483
+ throw new Error("--stats is currently supported only by penny claude.");
1484
+ }
1485
+ const selection = extractProfileArgs(statsSelection.args);
1430
1486
  argv = [harness, ...selection.args];
1431
1487
  const config = await loadPennyConfig({
1432
1488
  profile: selection.profile,
1433
1489
  requireExisting: Boolean(selection.profile),
1434
1490
  });
1435
1491
  if (harness === "claude" && argv[1]?.toLowerCase() === "desktop") {
1492
+ if (statsSelection.stats) {
1493
+ throw new Error("--stats applies to Claude Code sessions, not Claude Desktop.");
1494
+ }
1436
1495
  const option = argv[2]?.toLowerCase();
1437
1496
  if (option === "--restore" || option === "restore") {
1438
1497
  if (argv.length > 3) {
@@ -1469,6 +1528,9 @@ export async function runPenny(argv = process.argv.slice(2)) {
1469
1528
  }
1470
1529
  return runClaudeDesktop(config, { logs, mode });
1471
1530
  }
1531
+ if (statsSelection.stats) {
1532
+ return runPennyClaudeStats(argv.slice(1), config.profile);
1533
+ }
1472
1534
  if (harness === "codex" && argv[1]?.toLowerCase() === "desktop") {
1473
1535
  const option = argv[2]?.toLowerCase();
1474
1536
  const mode = option === "native" || option === "--native" ? "native" : "gateway";
@@ -51,7 +51,7 @@ NATIVE_PROXY_TOKEN_ENV = "PENNY_COMPARE_NATIVE_AUTH_TOKEN"
51
51
  class Readout:
52
52
  """Paints the fixed block at the top of the pane."""
53
53
 
54
- def __init__(self, harness, title, delta_path=None, provider=None, label=None,
54
+ def __init__(self, harness, title, delta_path=None, label=None,
55
55
  session_id=None, side=None, variant=None, pricing_profile="standard"):
56
56
  self.harness = harness
57
57
  # The pane's fixed role ("NATIVE"/"PENNY"). It keys the files the two panes use to
@@ -60,8 +60,6 @@ class Readout:
60
60
  # What the readout actually shows, which the user may rename.
61
61
  self.label = label or title
62
62
  self.delta_path = delta_path
63
- # For Codex, whether this pane's session should be the PennyRouter-routed one.
64
- self.provider = provider
65
63
  # Claude accepts a caller-supplied session id, so its transcript can be selected exactly
66
64
  # instead of inferred from timing, model names, or Penny's global title handoff files.
67
65
  self.session_id = session_id
@@ -142,13 +140,14 @@ class Readout:
142
140
  def resolve(self):
143
141
  """Pin this pane to the transcript its own harness created.
144
142
 
145
- Both panes run in the same working directory, so the newest session there is ambiguous
146
- between them, and resolving by timing alone can label the two backwards.
143
+ Claude is launched with an explicit session id, making its transcript path deterministic.
144
+ Codex and OpenCode are selected from files created after this pane launched, narrowed by
145
+ cwd, with pane claims preventing two local panes from adopting the same transcript.
147
146
 
148
- Claude is launched with an explicit session id, making both its native and Penny
149
- transcript paths deterministic. Codex has no equivalent launch flag, but records the
150
- provider in its session header, so each Codex pane takes the session matching how it was
151
- started.
147
+ Do not filter Codex by provider here. Subscription-backed Penny sessions deliberately
148
+ record ``model_provider = openai``, and a hosted gateway cannot create a claim file on
149
+ this machine. That filter rejects the correct local JSONL for both penny-session and the
150
+ normal compare layout, where cwd/time/claiming already identify it.
152
151
  """
153
152
  if self.path and os.path.exists(self.path):
154
153
  return self.path
@@ -163,8 +162,7 @@ class Readout:
163
162
  # session, so require one created afterwards rather than adopting the newest on disk.
164
163
  taken = self._peers() | self._preexisting
165
164
  candidate = newest_session(self.harness, os.getcwd(),
166
- since=self.started, exclude=taken,
167
- provider=self.provider)
165
+ since=self.started, exclude=taken)
168
166
  if candidate and not self._claim(candidate):
169
167
  return None
170
168
  self.path = candidate
@@ -346,6 +344,8 @@ def main():
346
344
  help="start both panes on one model so the comparison is like for like")
347
345
  parser.add_argument("--label", default=None,
348
346
  help="name shown on this pane's readout (default: NATIVE or PENNY)")
347
+ parser.add_argument("--cwd", default=None,
348
+ help="working directory selected by the compare launcher")
349
349
  parser.add_argument("--side", choices=("left", "right", "single"),
350
350
  help="stable pane position used by the final report")
351
351
  parser.add_argument("--pricing-profile", choices=("standard", "govcloud"),
@@ -353,8 +353,26 @@ def main():
353
353
  parser.add_argument("--native-proxy", action="store_true",
354
354
  help=f"route the NATIVE pane through {NATIVE_PROXY_URL_ENV} / "
355
355
  f"{NATIVE_PROXY_TOKEN_ENV} instead of going direct")
356
+ parser.add_argument("--harness-args-json", default="[]",
357
+ help=argparse.SUPPRESS)
356
358
  args = parser.parse_args()
357
359
 
360
+ try:
361
+ harness_args = json.loads(args.harness_args_json)
362
+ if not isinstance(harness_args, list) or not all(
363
+ isinstance(value, str) for value in harness_args):
364
+ raise ValueError("expected a JSON array of strings")
365
+ except (json.JSONDecodeError, ValueError) as error:
366
+ sys.stderr.write(f"penny-compare: invalid harness arguments: {error}\n")
367
+ return 2
368
+
369
+ if args.cwd:
370
+ try:
371
+ os.chdir(os.path.expanduser(args.cwd))
372
+ except OSError as error:
373
+ sys.stderr.write(f"penny-compare: cannot enter {args.cwd}: {error}\n")
374
+ return 1
375
+
358
376
  harness, command, title = VARIANTS[args.variant]
359
377
  session_id = None
360
378
  if harness == "claude-code":
@@ -364,20 +382,14 @@ def main():
364
382
  # Passed to the harness rather than set in the environment, so Penny's own model
365
383
  # configuration is not overridden on the penny side.
366
384
  command = command + ["--model", args.model]
385
+ command = command + harness_args
367
386
 
368
387
  rows = shutil.get_terminal_size().lines
369
388
  # Reserve the top rows for the readout and confine scrolling to what is left.
370
389
  sys.stdout.write(f"\033[2J\033[{hud.ROWS + 1};{rows}r\033[{hud.ROWS + 1};1H")
371
390
  sys.stdout.flush()
372
391
 
373
- # Codex and OpenCode have no caller-assigned session id, so they identify their transcript by the provider
374
- # recorded in the session header instead.
375
- is_penny = command[0] == "penny"
376
392
  readout = Readout(harness, title, args.delta_file,
377
- # OpenCode owns provider/model selection inside its own TUI. The
378
- # penny-opencode spelling is only a launch convenience, not a reliable
379
- # provider identity like Codex's session header is.
380
- provider=is_penny if harness == "codex" else None,
381
393
  label=args.label,
382
394
  session_id=session_id,
383
395
  side=args.side,
@@ -236,6 +236,8 @@ workdir_left="${dir_left:-$PWD/native}"
236
236
  workdir_left="${workdir_left/#\~/$HOME}"
237
237
  workdir_right="${dir_right:-$PWD/penny}"
238
238
  workdir_right="${workdir_right/#\~/$HOME}"
239
+ workdir_left_arg=" --cwd $(printf '%q' "$workdir_left")"
240
+ workdir_right_arg=" --cwd $(printf '%q' "$workdir_right")"
239
241
 
240
242
  # --native-proxy only means anything on a NATIVE Claude or Codex pane;
241
243
  # a penny-* pane already owns its own proxy routing and ignores the flag either way.
@@ -265,10 +267,10 @@ wire_args="${wire:+ $wire}${wire_both:+ $wire_both}${wire_https:+ $wire_https}"
265
267
 
266
268
  tmux new-session -d -s "$session" -c "$workdir_left" -x "$(tput cols)" -y "$(tput lines)" \
267
269
  -e PENNY_COMPARE_STATE="$state" \
268
- "$here/pane.py $left --side left --pricing-profile $pricing_profile${model:+ --model $model}${native_proxy_left:+ $native_proxy_left}${label_left_arg}${wire_args}"
270
+ "$here/pane.py $left --side left --pricing-profile $pricing_profile${workdir_left_arg}${model:+ --model $model}${native_proxy_left:+ $native_proxy_left}${label_left_arg}${wire_args}"
269
271
  tmux split-window -h -t "$session" -c "$workdir_right" \
270
272
  -e PENNY_COMPARE_STATE="$state" \
271
- "$here/pane.py $right --side right --pricing-profile $pricing_profile --delta-file $delta${model:+ --model $model}${native_proxy_right:+ $native_proxy_right}${label_right_arg}${wire_args}"
273
+ "$here/pane.py $right --side right --pricing-profile $pricing_profile --delta-file $delta${workdir_right_arg}${model:+ --model $model}${native_proxy_right:+ $native_proxy_right}${label_right_arg}${wire_args}"
272
274
 
273
275
  # The comparison belongs to neither pane, so a small watcher derives it from both.
274
276
  tmux new-window -d -t "$session" -n delta "$here/delta.py '$state' '$delta'"