pennyrouter 0.3.33 → 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 +4 -0
- package/package.json +1 -1
- package/src/launch.js +63 -1
- package/standalone/pane.py +30 -18
- package/standalone/penny-compare +4 -2
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
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
|
|
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";
|
package/standalone/pane.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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,
|
package/standalone/penny-compare
CHANGED
|
@@ -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'"
|