pennyrouter 0.3.33 → 0.3.36
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 +11 -4
- package/package.json +7 -1
- package/src/cli.js +4 -2
- package/src/launch.js +82 -2
- package/standalone/pane.py +30 -18
- package/standalone/penny-compare +4 -2
- package/standalone/report.py +8 -1
- package/standalone/web-demo/README.md +30 -0
- package/standalone/web-demo/claude-meter.mjs +174 -0
- package/standalone/web-demo/fix-pty-permissions.mjs +17 -0
- package/standalone/web-demo/package.json +15 -0
- package/standalone/web-demo/server.mjs +227 -0
- package/standalone/web-demo/terminal-preview.html +160 -0
package/README.md
CHANGED
|
@@ -80,16 +80,19 @@ OpenRouter key. PennyRouter-funded provider keys are never an installed-harness
|
|
|
80
80
|
|
|
81
81
|
## Explicit Penny Commands
|
|
82
82
|
|
|
83
|
-
Penny Compare is bundled with the CLI on macOS and
|
|
83
|
+
Penny Compare is bundled with the CLI on macOS, Linux, and Windows. It is installed automatically as part
|
|
84
84
|
of `npx pennyrouter install`; to install or refresh it separately, run:
|
|
85
85
|
|
|
86
86
|
```sh
|
|
87
87
|
npx pennyrouter compare
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
On macOS/Linux, the installer puts `penny-compare` and `penny-session` on PATH alongside `penny`.
|
|
91
|
+
These commands require `tmux`, Python 3.10+, and the harnesses being compared. On Windows,
|
|
92
|
+
`penny-compare` starts a local browser terminal with native Claude and `penny claude` side by
|
|
93
|
+
side. Its live token and cost readouts use Claude transcripts, with no tmux or Python install.
|
|
94
|
+
Open the local URL printed by the command. Sync is Ctrl+Shift+S; Exit and save report is
|
|
95
|
+
Ctrl+Shift+X. The Windows path currently supports Claude Code comparisons.
|
|
93
96
|
|
|
94
97
|
On macOS and Linux, a normal install adds a small `penny` launcher under `~/.local/bin`.
|
|
95
98
|
On Windows it installs `penny.cmd` under `%LOCALAPPDATA%\\PennyRouter\\bin` and adds that
|
|
@@ -97,6 +100,7 @@ directory to the user PATH:
|
|
|
97
100
|
|
|
98
101
|
```bash
|
|
99
102
|
penny claude
|
|
103
|
+
penny claude --stats
|
|
100
104
|
penny claude desktop
|
|
101
105
|
penny claude desktop gateway
|
|
102
106
|
penny claude desktop native
|
|
@@ -108,6 +112,9 @@ penny codex desktop native
|
|
|
108
112
|
penny opencode
|
|
109
113
|
```
|
|
110
114
|
|
|
115
|
+
`penny claude --stats` launches Claude Code with Penny Compare's live single-session token and
|
|
116
|
+
cost readout. Other arguments still pass through, so `penny claude --stats --resume` works.
|
|
117
|
+
|
|
111
118
|
Arguments are passed through to the native harness, so commands such as `penny claude --resume`,
|
|
112
119
|
`penny codex resume --last`, and `penny opencode --continue` work normally. By default, bare
|
|
113
120
|
`claude`, `codex`, and `opencode` remain
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.36",
|
|
4
4
|
"description": "Install and manage PennyRouter local coding-agent integrations.",
|
|
5
5
|
"homepage": "https://pennyrouter.com",
|
|
6
6
|
"bugs": {
|
|
@@ -27,5 +27,11 @@
|
|
|
27
27
|
"start": "node ./bin/pennyrouter.js",
|
|
28
28
|
"check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node --test src/cli.test.js && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/harnesses/opencode.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
|
|
29
29
|
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@xterm/addon-fit": "^0.10.0",
|
|
32
|
+
"@xterm/xterm": "^5.5.0",
|
|
33
|
+
"node-pty": "^1.0.0",
|
|
34
|
+
"ws": "^8.18.0"
|
|
35
|
+
},
|
|
30
36
|
"license": "MIT"
|
|
31
37
|
}
|
package/src/cli.js
CHANGED
|
@@ -135,10 +135,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
135
135
|
const result = await installPennyCompare();
|
|
136
136
|
if (!result.installed) {
|
|
137
137
|
if (result.supported) throw new Error("Penny Compare is not included in this CLI package.");
|
|
138
|
-
throw new Error("Penny Compare requires macOS
|
|
138
|
+
throw new Error("Penny Compare requires macOS, Linux, or Windows.");
|
|
139
139
|
}
|
|
140
140
|
console.log(`Penny Compare installed at ${result.path}`);
|
|
141
|
-
console.log(
|
|
141
|
+
console.log(process.platform === "win32"
|
|
142
|
+
? "Run `penny-compare`, then open the local URL it prints."
|
|
143
|
+
: "Run `penny-compare` or `penny-session` to use it.");
|
|
142
144
|
return;
|
|
143
145
|
}
|
|
144
146
|
|
package/src/launch.js
CHANGED
|
@@ -276,7 +276,7 @@ async function copyRuntime() {
|
|
|
276
276
|
|
|
277
277
|
/** Install the bundled, repository-independent Penny Compare tools. */
|
|
278
278
|
export async function installPennyCompare() {
|
|
279
|
-
if (!(process.platform === "darwin" || process.platform === "linux")) {
|
|
279
|
+
if (!(process.platform === "darwin" || process.platform === "linux" || process.platform === "win32")) {
|
|
280
280
|
return { supported: false, installed: false, path: pennyCompareDir() };
|
|
281
281
|
}
|
|
282
282
|
const source = join(packageRoot(), "standalone");
|
|
@@ -298,6 +298,24 @@ export async function installPennyCompare() {
|
|
|
298
298
|
}
|
|
299
299
|
await mkdir(join(destination, "reports"), { recursive: true, mode: 0o700 });
|
|
300
300
|
await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
|
|
301
|
+
if (process.platform === "win32") {
|
|
302
|
+
const modules = join(destination, "web-demo", "node_modules");
|
|
303
|
+
for (const name of ["@xterm/addon-fit", "@xterm/xterm", "node-pty", "ws"]) {
|
|
304
|
+
const target = join(modules, name);
|
|
305
|
+
await mkdir(dirname(target), { recursive: true });
|
|
306
|
+
await cp(join(packageRoot(), "node_modules", name), target, { recursive: true });
|
|
307
|
+
}
|
|
308
|
+
const link = join(pennyBinDir(), "penny-compare.cmd");
|
|
309
|
+
const marker = "@REM PennyRouter-managed compare launcher";
|
|
310
|
+
let previous = "";
|
|
311
|
+
try { previous = await readFile(link, "utf8"); } catch (error) {
|
|
312
|
+
if (error.code !== "ENOENT") throw error;
|
|
313
|
+
}
|
|
314
|
+
if (!previous || previous.startsWith(marker)) {
|
|
315
|
+
await writeFile(link, `${marker}\r\n@echo off\r\nnode "${join(destination, "web-demo", "server.mjs")}" --compare %*\r\n`);
|
|
316
|
+
}
|
|
317
|
+
return { supported: true, installed: true, path: destination };
|
|
318
|
+
}
|
|
301
319
|
for (const entry of ["penny-compare", "penny-session", "penny-compare-viewer"]) {
|
|
302
320
|
const link = join(pennyBinDir(), entry);
|
|
303
321
|
const target = join(destination, entry);
|
|
@@ -1240,6 +1258,25 @@ export function extractProfileArgs(args) {
|
|
|
1240
1258
|
return { profile, args: rest };
|
|
1241
1259
|
}
|
|
1242
1260
|
|
|
1261
|
+
/**
|
|
1262
|
+
* Remove the launcher-only stats flag while preserving every native harness argument.
|
|
1263
|
+
* A bare `--` ends Penny's option parsing, so a later `--stats` still reaches the harness.
|
|
1264
|
+
*/
|
|
1265
|
+
export function extractStatsArgs(args) {
|
|
1266
|
+
const rest = [];
|
|
1267
|
+
let stats = false;
|
|
1268
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1269
|
+
const arg = args[i];
|
|
1270
|
+
if (arg === "--") {
|
|
1271
|
+
rest.push(...args.slice(i));
|
|
1272
|
+
break;
|
|
1273
|
+
}
|
|
1274
|
+
if (arg === "--stats") stats = true;
|
|
1275
|
+
else rest.push(arg);
|
|
1276
|
+
}
|
|
1277
|
+
return { stats, args: rest };
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1243
1280
|
function assertNoConflictingArgs(harness, args) {
|
|
1244
1281
|
if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
|
|
1245
1282
|
throw new Error("penny claude manages --settings; remove that flag or run native claude.");
|
|
@@ -1416,23 +1453,63 @@ async function waitForChild(child) {
|
|
|
1416
1453
|
});
|
|
1417
1454
|
}
|
|
1418
1455
|
|
|
1456
|
+
/** Launch Penny Claude inside Penny Compare's single-session token readout. */
|
|
1457
|
+
export async function runPennyClaudeStats(args, profile) {
|
|
1458
|
+
if (!(process.platform === "darwin" || process.platform === "linux")) {
|
|
1459
|
+
throw new Error("penny claude --stats is supported on macOS and Linux.");
|
|
1460
|
+
}
|
|
1461
|
+
const panePath = join(pennyCompareDir(), "pane.py");
|
|
1462
|
+
try {
|
|
1463
|
+
await stat(panePath);
|
|
1464
|
+
} catch {
|
|
1465
|
+
throw new Error(
|
|
1466
|
+
"Penny's stats display is not installed. Run `npx pennyrouter compare` and try again.",
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
const child = spawn("python3", [
|
|
1470
|
+
panePath,
|
|
1471
|
+
"penny-claude",
|
|
1472
|
+
"--side",
|
|
1473
|
+
"single",
|
|
1474
|
+
"--cwd",
|
|
1475
|
+
process.cwd(),
|
|
1476
|
+
"--harness-args-json",
|
|
1477
|
+
JSON.stringify(["--penny-profile", profile, ...args]),
|
|
1478
|
+
], {
|
|
1479
|
+
cwd: process.cwd(),
|
|
1480
|
+
env: process.env,
|
|
1481
|
+
stdio: "inherit",
|
|
1482
|
+
});
|
|
1483
|
+
const result = await waitForChild(child);
|
|
1484
|
+
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
1485
|
+
return result.code ?? 1;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1419
1488
|
export async function runPenny(argv = process.argv.slice(2)) {
|
|
1420
1489
|
const harness = argv[0];
|
|
1421
1490
|
if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
|
|
1422
1491
|
console.log("Usage: penny <claude|codex|opencode> [--penny-profile <name>] [native arguments...]");
|
|
1423
1492
|
console.log(" --private / --cloud select the like-named profile.");
|
|
1493
|
+
console.log(" penny claude --stats adds the live single-session token readout.");
|
|
1424
1494
|
return 0;
|
|
1425
1495
|
}
|
|
1426
1496
|
if (!["claude", "codex", "opencode"].includes(harness)) {
|
|
1427
1497
|
throw new Error(`Unknown harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
|
|
1428
1498
|
}
|
|
1429
|
-
const
|
|
1499
|
+
const statsSelection = extractStatsArgs(argv.slice(1));
|
|
1500
|
+
if (statsSelection.stats && harness !== "claude") {
|
|
1501
|
+
throw new Error("--stats is currently supported only by penny claude.");
|
|
1502
|
+
}
|
|
1503
|
+
const selection = extractProfileArgs(statsSelection.args);
|
|
1430
1504
|
argv = [harness, ...selection.args];
|
|
1431
1505
|
const config = await loadPennyConfig({
|
|
1432
1506
|
profile: selection.profile,
|
|
1433
1507
|
requireExisting: Boolean(selection.profile),
|
|
1434
1508
|
});
|
|
1435
1509
|
if (harness === "claude" && argv[1]?.toLowerCase() === "desktop") {
|
|
1510
|
+
if (statsSelection.stats) {
|
|
1511
|
+
throw new Error("--stats applies to Claude Code sessions, not Claude Desktop.");
|
|
1512
|
+
}
|
|
1436
1513
|
const option = argv[2]?.toLowerCase();
|
|
1437
1514
|
if (option === "--restore" || option === "restore") {
|
|
1438
1515
|
if (argv.length > 3) {
|
|
@@ -1469,6 +1546,9 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
1469
1546
|
}
|
|
1470
1547
|
return runClaudeDesktop(config, { logs, mode });
|
|
1471
1548
|
}
|
|
1549
|
+
if (statsSelection.stats) {
|
|
1550
|
+
return runPennyClaudeStats(argv.slice(1), config.profile);
|
|
1551
|
+
}
|
|
1472
1552
|
if (harness === "codex" && argv[1]?.toLowerCase() === "desktop") {
|
|
1473
1553
|
const option = argv[2]?.toLowerCase();
|
|
1474
1554
|
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'"
|
package/standalone/report.py
CHANGED
|
@@ -75,7 +75,8 @@ def centered(lines: list[str]) -> str:
|
|
|
75
75
|
def main() -> int:
|
|
76
76
|
state = Path(sys.argv[1])
|
|
77
77
|
reports = Path(sys.argv[2])
|
|
78
|
-
|
|
78
|
+
json_mode = len(sys.argv) > 3 and sys.argv[3] == "--json"
|
|
79
|
+
session = None if json_mode else sys.argv[3]
|
|
79
80
|
try:
|
|
80
81
|
started = (state / "started").stat().st_mtime
|
|
81
82
|
except OSError:
|
|
@@ -111,6 +112,12 @@ def main() -> int:
|
|
|
111
112
|
destination = reports / f"compare-{finished.strftime('%Y%m%d-%H%M%S-%f')}.json"
|
|
112
113
|
destination.write_text(json.dumps(report, indent=2) + "\n")
|
|
113
114
|
|
|
115
|
+
if json_mode:
|
|
116
|
+
print(json.dumps({"path": str(destination), "savings": savings,
|
|
117
|
+
"native": {"calls": native["call_count"], "totals": native_totals},
|
|
118
|
+
"penny": {"calls": penny["call_count"], "totals": penny_totals}}))
|
|
119
|
+
return 0
|
|
120
|
+
|
|
114
121
|
lines = [
|
|
115
122
|
"PENNY COMPARISON",
|
|
116
123
|
"",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Live terminal preview
|
|
2
|
+
|
|
3
|
+
From this directory:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install
|
|
7
|
+
npm start
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Open `http://127.0.0.1:8789`. The page starts two real shells in the current directory. Type `claude` in either pane to run Claude Code. With sync on, the same keys go to both panes. Click a pane to focus it; use **Sync** or Ctrl+Shift+S to toggle broadcast input. **Exit** or Ctrl+Shift+X ends both sessions.
|
|
11
|
+
|
|
12
|
+
To launch Claude Code in both panes immediately:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm start -- --claude
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
To run the current Penny Compare measurement code in the panes:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm start -- --compare
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This starts native Claude on the left and `penny claude` on the right. On macOS/Linux, it uses the existing Python session reader, cost model, HUD, and delta calculation. On Windows, it reads Claude transcripts and prices calls in Node, with no Python or tmux requirement. Exit saves a JSON report under `apps/compare/reports` and prints the summary in the left pane. Both platforms need Claude Code and the `penny` command on `PATH`.
|
|
25
|
+
|
|
26
|
+
The Windows path currently supports Claude Code comparisons. Codex and OpenCode transcript readers are still in the Python path.
|
|
27
|
+
|
|
28
|
+
To exercise the Windows measurement path on macOS/Linux, use `npm start -- --compare-node`.
|
|
29
|
+
|
|
30
|
+
The demo requires Claude Code on `PATH` for that option. Closing or reloading the page ends both sessions. The local server listens only on `127.0.0.1`; it does not start a PennyRouter gateway.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { mkdir, open, readdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Mirrors the standard Claude rates in ../pricing.py (USD per token).
|
|
6
|
+
const rates = {
|
|
7
|
+
'claude-opus-5': [5e-6, .5e-6, 6.25e-6, 10e-6, 25e-6],
|
|
8
|
+
'claude-sonnet-5': [3e-6, .3e-6, 3.75e-6, 6e-6, 15e-6],
|
|
9
|
+
'claude-haiku-4-5': [1e-6, .1e-6, 1.25e-6, 2e-6, 5e-6],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function rateFor(model) {
|
|
13
|
+
const normalized = String(model || '').replace(/^.*\//, '').replace('[1m]', '');
|
|
14
|
+
const key = Object.keys(rates).find((name) => normalized.startsWith(name));
|
|
15
|
+
return rates[key] || rates['claude-sonnet-5'];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function emptyTotals() {
|
|
19
|
+
return { cache_read: 0, cache_write: 0, write_5m: 0, write_1h: 0,
|
|
20
|
+
fresh_input: 0, output: 0, reasoning_output: 0, total: 0, cost: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ClaudeMeter {
|
|
24
|
+
constructor({ sessionId, cwd, side, role }) {
|
|
25
|
+
this.sessionId = sessionId;
|
|
26
|
+
this.cwd = cwd;
|
|
27
|
+
this.side = side;
|
|
28
|
+
this.role = role;
|
|
29
|
+
this.root = join(homedir(), '.claude', 'projects');
|
|
30
|
+
this.files = new Map();
|
|
31
|
+
this.ids = new Set();
|
|
32
|
+
this.calls = [];
|
|
33
|
+
this.totals = emptyTotals();
|
|
34
|
+
this.model = '';
|
|
35
|
+
this.transcriptPath = null;
|
|
36
|
+
this.started = Date.now() / 1000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async findTranscript() {
|
|
40
|
+
if (this.transcriptPath) return this.transcriptPath;
|
|
41
|
+
let projects;
|
|
42
|
+
try { projects = await readdir(this.root, { withFileTypes: true }); } catch { return null; }
|
|
43
|
+
for (const project of projects) {
|
|
44
|
+
if (!project.isDirectory()) continue;
|
|
45
|
+
const candidate = join(this.root, project.name, `${this.sessionId}.jsonl`);
|
|
46
|
+
try { await stat(candidate); this.transcriptPath = candidate; return candidate; } catch { /* next */ }
|
|
47
|
+
}
|
|
48
|
+
// Claude can occasionally place a session under a different transcript filename.
|
|
49
|
+
// Its first JSONL record still carries the session ID.
|
|
50
|
+
for (const project of projects) {
|
|
51
|
+
if (!project.isDirectory()) continue;
|
|
52
|
+
let entries;
|
|
53
|
+
try { entries = await readdir(join(this.root, project.name)); } catch { continue; }
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (!entry.endsWith('.jsonl')) continue;
|
|
56
|
+
const path = join(this.root, project.name, entry);
|
|
57
|
+
let handle;
|
|
58
|
+
try {
|
|
59
|
+
handle = await open(path, 'r');
|
|
60
|
+
const buffer = Buffer.alloc(4096);
|
|
61
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
62
|
+
const first = buffer.subarray(0, bytesRead).toString('utf8').split('\n', 1)[0];
|
|
63
|
+
if (JSON.parse(first).sessionId === this.sessionId) {
|
|
64
|
+
this.transcriptPath = path;
|
|
65
|
+
return path;
|
|
66
|
+
}
|
|
67
|
+
} catch { /* next */ } finally { await handle?.close(); }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async poll() {
|
|
74
|
+
const transcript = await this.findTranscript();
|
|
75
|
+
if (!transcript) return this.snapshot();
|
|
76
|
+
const paths = [transcript];
|
|
77
|
+
const collect = async (dir) => {
|
|
78
|
+
let children;
|
|
79
|
+
try { children = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
80
|
+
for (const child of children) {
|
|
81
|
+
const path = join(dir, child.name);
|
|
82
|
+
if (child.isDirectory()) await collect(path);
|
|
83
|
+
else if (child.name.endsWith('.jsonl')) paths.push(path);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
await collect(join(transcript.slice(0, -6), 'subagents'));
|
|
87
|
+
for (const path of paths) await this.readAppended(path);
|
|
88
|
+
return this.snapshot();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async readAppended(path) {
|
|
92
|
+
let size;
|
|
93
|
+
try { ({ size } = await stat(path)); } catch { return; }
|
|
94
|
+
let offset = this.files.get(path) || 0;
|
|
95
|
+
if (size < offset) offset = 0;
|
|
96
|
+
if (size === offset) return;
|
|
97
|
+
const handle = await open(path, 'r');
|
|
98
|
+
try {
|
|
99
|
+
const buffer = Buffer.alloc(size - offset);
|
|
100
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
101
|
+
const text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
102
|
+
const end = text.lastIndexOf('\n');
|
|
103
|
+
if (end < 0) return;
|
|
104
|
+
this.files.set(path, offset + Buffer.byteLength(text.slice(0, end + 1)));
|
|
105
|
+
for (const line of text.slice(0, end).split('\n')) {
|
|
106
|
+
let obj;
|
|
107
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
108
|
+
const message = obj.message;
|
|
109
|
+
const usage = message?.usage;
|
|
110
|
+
if (!usage || typeof usage !== 'object') continue;
|
|
111
|
+
if (message.id && this.ids.has(message.id)) continue;
|
|
112
|
+
if (message.id) this.ids.add(message.id);
|
|
113
|
+
const read = Number(usage.cache_read_input_tokens) || 0;
|
|
114
|
+
const write = Number(usage.cache_creation_input_tokens) || 0;
|
|
115
|
+
let write5 = Number(usage.cache_creation?.ephemeral_5m_input_tokens) || 0;
|
|
116
|
+
const write1 = Number(usage.cache_creation?.ephemeral_1h_input_tokens) || 0;
|
|
117
|
+
if (!write5 && !write1) write5 = write;
|
|
118
|
+
const input = Number(usage.input_tokens) || 0;
|
|
119
|
+
const output = Number(usage.output_tokens) || 0;
|
|
120
|
+
const model = message.model || this.model;
|
|
121
|
+
const prices = rateFor(model);
|
|
122
|
+
const cost = input * prices[0] + read * prices[1] + write5 * prices[2] +
|
|
123
|
+
write1 * prices[3] + output * prices[4];
|
|
124
|
+
const call = { timestamp: obj.timestamp || null, recorded_at: Date.now() / 1000,
|
|
125
|
+
model, cache_read: read, cache_write: write, write_5m: write5, write_1h: write1,
|
|
126
|
+
fresh_input: input, output, reasoning_output: 0,
|
|
127
|
+
total: read + write + input + output, cost,
|
|
128
|
+
context_tokens: read + write + input,
|
|
129
|
+
pricing_profile: 'standard',
|
|
130
|
+
rates_per_million: Object.fromEntries(
|
|
131
|
+
['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output']
|
|
132
|
+
.map((name, index) => [name, prices[index] * 1_000_000])),
|
|
133
|
+
};
|
|
134
|
+
this.calls.push(call);
|
|
135
|
+
this.model = model;
|
|
136
|
+
this.totals.cache_read += read;
|
|
137
|
+
this.totals.cache_write += write;
|
|
138
|
+
this.totals.write_5m += write5;
|
|
139
|
+
this.totals.write_1h += write1;
|
|
140
|
+
this.totals.fresh_input += input;
|
|
141
|
+
this.totals.output += output;
|
|
142
|
+
this.totals.total += call.total;
|
|
143
|
+
this.totals.cost += cost;
|
|
144
|
+
}
|
|
145
|
+
} finally { await handle.close(); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
snapshot() {
|
|
149
|
+
return { side: this.side, role: this.role, label: this.role, variant: this.side === 'left' ? 'claude' : 'penny-claude',
|
|
150
|
+
harness: 'claude-code', cwd: this.cwd, pricing_profile: 'standard', session_id: this.sessionId,
|
|
151
|
+
transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
|
|
152
|
+
totals: { ...this.totals } };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function saveReport(left, right, reportsDir) {
|
|
157
|
+
const native = left.snapshot();
|
|
158
|
+
const penny = right.snapshot();
|
|
159
|
+
const savings = {
|
|
160
|
+
cost: native.totals.cost - penny.totals.cost,
|
|
161
|
+
tokens: native.totals.total - penny.totals.total,
|
|
162
|
+
cost_percent: native.totals.cost ? (native.totals.cost - penny.totals.cost) / native.totals.cost * 100 : 0,
|
|
163
|
+
token_percent: native.totals.total ? (native.totals.total - penny.totals.total) / native.totals.total * 100 : 0,
|
|
164
|
+
};
|
|
165
|
+
const report = { created_at: new Date().toISOString(),
|
|
166
|
+
measured_from_epoch: Math.min(left.started, right.started), pricing_profile: 'standard',
|
|
167
|
+
left: { ...native, calls: left.calls }, right: { ...penny, calls: right.calls },
|
|
168
|
+
savings_native_minus_penny: savings };
|
|
169
|
+
await mkdir(reportsDir, { recursive: true });
|
|
170
|
+
const path = join(reportsDir, `compare-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
|
|
171
|
+
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`);
|
|
172
|
+
return { path, savings, native: { calls: native.call_count, totals: native.totals },
|
|
173
|
+
penny: { calls: penny.call_count, totals: penny.totals } };
|
|
174
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { chmod, stat } from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// node-pty 1.1.0's macOS prebuild is packaged with a non-executable helper.
|
|
6
|
+
// Its native binding can load, but every PTY launch then fails at posix_spawnp.
|
|
7
|
+
if (process.platform === 'darwin') {
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const packageDir = dirname(require.resolve('node-pty/package.json'));
|
|
10
|
+
const helper = join(packageDir, 'prebuilds', `darwin-${process.arch}`, 'spawn-helper');
|
|
11
|
+
try {
|
|
12
|
+
const { mode } = await stat(helper);
|
|
13
|
+
await chmod(helper, mode | 0o111);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error.code !== 'ENOENT') throw error; // Source builds use build/Release instead.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "penny-compare-terminal-demo",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"start": "node server.mjs",
|
|
7
|
+
"postinstall": "node fix-pty-permissions.mjs"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@xterm/addon-fit": "^0.10.0",
|
|
11
|
+
"@xterm/xterm": "^5.5.0",
|
|
12
|
+
"node-pty": "^1.0.0",
|
|
13
|
+
"ws": "^8.18.0"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { spawn, execFile } from 'node:child_process';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { WebSocketServer } from 'ws';
|
|
10
|
+
import pty from 'node-pty';
|
|
11
|
+
import { ClaudeMeter, saveReport } from './claude-meter.mjs';
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const compareDir = dirname(here);
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const claude = args.includes('--claude');
|
|
18
|
+
const nodeMeter = args.includes('--compare-node') ||
|
|
19
|
+
(args.includes('--compare') && process.platform === 'win32');
|
|
20
|
+
const compare = args.includes('--compare') || nodeMeter;
|
|
21
|
+
const pythonCompare = compare && !nodeMeter && process.platform !== 'win32';
|
|
22
|
+
const portArg = args.find((arg) => /^--port=\d+$/.test(arg));
|
|
23
|
+
const port = Number(portArg?.slice(7) ?? 8789);
|
|
24
|
+
if (args.some((arg) => !['--claude', '--compare', '--compare-node', portArg].includes(arg)) ||
|
|
25
|
+
(claude && compare) || port < 1 || port > 65535) {
|
|
26
|
+
console.error('Usage: npm start -- [--claude | --compare | --compare-node] [--port=8789]');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const files = new Map([
|
|
31
|
+
['/', ['terminal-preview.html', 'text/html; charset=utf-8']],
|
|
32
|
+
['/xterm.css', ['node_modules/@xterm/xterm/css/xterm.css', 'text/css; charset=utf-8']],
|
|
33
|
+
['/xterm.js', ['node_modules/@xterm/xterm/lib/xterm.js', 'text/javascript; charset=utf-8']],
|
|
34
|
+
['/addon-fit.js', ['node_modules/@xterm/addon-fit/lib/addon-fit.js', 'text/javascript; charset=utf-8']],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const server = createServer(async (request, response) => {
|
|
38
|
+
const file = files.get(request.url);
|
|
39
|
+
if (!file) { response.writeHead(404).end(); return; }
|
|
40
|
+
try {
|
|
41
|
+
const body = await readFile(join(here, file[0]));
|
|
42
|
+
response.writeHead(200, { 'Content-Type': file[1], 'Cache-Control': 'no-store' }).end(body);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(error);
|
|
45
|
+
response.writeHead(500).end('Could not load demo asset');
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
|
|
50
|
+
server.on('upgrade', (request, socket, head) => {
|
|
51
|
+
const allowedOrigins = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|
|
52
|
+
if (request.url !== '/terminal' || !allowedOrigins.has(request.headers.origin)) {
|
|
53
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
54
|
+
socket.destroy();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function launchCommand(side, state, sessionId) {
|
|
61
|
+
if (pythonCompare) {
|
|
62
|
+
const variant = side === 'left' ? 'claude' : 'penny-claude';
|
|
63
|
+
const args = [join(compareDir, 'pane.py'), variant, '--side', side,
|
|
64
|
+
'--cwd', process.cwd(), '--pricing-profile', 'standard'];
|
|
65
|
+
if (side === 'right') args.push('--delta-file', join(state, 'delta.txt'));
|
|
66
|
+
return ['python3', args];
|
|
67
|
+
}
|
|
68
|
+
if (nodeMeter) {
|
|
69
|
+
if (process.platform === 'win32') {
|
|
70
|
+
const shell = process.env.ComSpec || 'cmd.exe';
|
|
71
|
+
return [shell, ['/d', '/s', '/c', side === 'left'
|
|
72
|
+
? `claude --session-id ${sessionId}` : `penny claude --session-id ${sessionId}`]];
|
|
73
|
+
}
|
|
74
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
75
|
+
return [shell, ['-lc', `exec ${side === 'left' ? 'claude' : 'penny claude'} --session-id ${sessionId}`]];
|
|
76
|
+
}
|
|
77
|
+
if (process.platform === 'win32') {
|
|
78
|
+
const shell = process.env.ComSpec || 'cmd.exe';
|
|
79
|
+
return claude ? [shell, ['/d', '/s', '/c', 'claude']] : [shell, []];
|
|
80
|
+
}
|
|
81
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
82
|
+
return claude ? [shell, ['-lc', 'exec claude']] : [shell, []];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
wss.on('connection', (ws) => {
|
|
86
|
+
const sessions = new Map();
|
|
87
|
+
const meters = new Map();
|
|
88
|
+
const snapshots = new Map();
|
|
89
|
+
let state;
|
|
90
|
+
let delta;
|
|
91
|
+
let meterTimer;
|
|
92
|
+
let polling = false;
|
|
93
|
+
let closed = false;
|
|
94
|
+
let exiting = false;
|
|
95
|
+
const ready = pythonCompare ? mkdtemp(join(tmpdir(), 'penny-compare-web-')).then(async (dir) => {
|
|
96
|
+
state = dir;
|
|
97
|
+
await writeFile(join(dir, 'started'), '');
|
|
98
|
+
await writeFile(join(dir, 'delta.txt'), '');
|
|
99
|
+
}) : Promise.resolve();
|
|
100
|
+
const send = (message) => {
|
|
101
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message));
|
|
102
|
+
};
|
|
103
|
+
const pollMeters = async () => {
|
|
104
|
+
if (polling || closed) return;
|
|
105
|
+
polling = true;
|
|
106
|
+
try {
|
|
107
|
+
for (const meter of meters.values()) {
|
|
108
|
+
const snapshot = await meter.poll();
|
|
109
|
+
snapshots.set(meter.side, snapshot);
|
|
110
|
+
send({ type: 'meter', data: snapshot });
|
|
111
|
+
}
|
|
112
|
+
const left = snapshots.get('left');
|
|
113
|
+
const right = snapshots.get('right');
|
|
114
|
+
if (left && right) {
|
|
115
|
+
const base = left.totals;
|
|
116
|
+
const current = right.totals;
|
|
117
|
+
send({ type: 'delta', cost: base.cost ? (current.cost - base.cost) / base.cost * 100 : 0,
|
|
118
|
+
tokens: base.total ? (current.total - base.total) / base.total * 100 : 0 });
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
send({ type: 'error', side: 'left', error: `Meter: ${error.message}` });
|
|
122
|
+
} finally { polling = false; }
|
|
123
|
+
};
|
|
124
|
+
const dispose = () => {
|
|
125
|
+
if (closed) return;
|
|
126
|
+
closed = true;
|
|
127
|
+
for (const session of sessions.values()) {
|
|
128
|
+
try { session.kill(); } catch { /* already exited */ }
|
|
129
|
+
}
|
|
130
|
+
sessions.clear();
|
|
131
|
+
if (meterTimer) clearInterval(meterTimer);
|
|
132
|
+
if (delta) delta.kill();
|
|
133
|
+
if (state) void rm(state, { recursive: true, force: true });
|
|
134
|
+
};
|
|
135
|
+
const handle = async (raw) => {
|
|
136
|
+
let message;
|
|
137
|
+
try { message = JSON.parse(raw); } catch { return; }
|
|
138
|
+
if (message.type === 'exit') {
|
|
139
|
+
if (exiting) return;
|
|
140
|
+
exiting = true;
|
|
141
|
+
if (pythonCompare && state) {
|
|
142
|
+
try {
|
|
143
|
+
const { stdout } = await execFileAsync('python3', [join(compareDir, 'report.py'),
|
|
144
|
+
state, join(compareDir, 'reports'), '--json']);
|
|
145
|
+
send({ type: 'report', ...JSON.parse(stdout) });
|
|
146
|
+
} catch (error) {
|
|
147
|
+
send({ type: 'report-error', error: error.message });
|
|
148
|
+
}
|
|
149
|
+
} else if (nodeMeter) {
|
|
150
|
+
try {
|
|
151
|
+
await pollMeters();
|
|
152
|
+
const left = meters.get('left');
|
|
153
|
+
const right = meters.get('right');
|
|
154
|
+
if (!left || !right) throw new Error('Both compare panes must start before a report can be saved.');
|
|
155
|
+
send({ type: 'report', ...await saveReport(left, right, join(compareDir, 'reports')) });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
send({ type: 'report-error', error: error.message });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
dispose();
|
|
161
|
+
ws.close();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!['left', 'right'].includes(message.side)) return;
|
|
165
|
+
const size = (value, max) => Number.isInteger(value) && value >= 2 && value <= max;
|
|
166
|
+
if (message.type === 'start' && !sessions.has(message.side) &&
|
|
167
|
+
size(message.cols, 500) && size(message.rows, 200)) {
|
|
168
|
+
try {
|
|
169
|
+
await ready;
|
|
170
|
+
if (closed) return;
|
|
171
|
+
if (pythonCompare && !delta) {
|
|
172
|
+
delta = spawn('python3', [join(compareDir, 'delta.py'), state, join(state, 'delta.txt')],
|
|
173
|
+
{ stdio: 'ignore' });
|
|
174
|
+
delta.on('error', (error) => send({ type: 'error', side: 'right', error: error.message }));
|
|
175
|
+
}
|
|
176
|
+
const sessionId = nodeMeter ? randomUUID() : undefined;
|
|
177
|
+
const [command, commandArgs] = launchCommand(message.side, state, sessionId);
|
|
178
|
+
const env = { ...process.env, TERM: 'xterm-256color',
|
|
179
|
+
...(pythonCompare ? { PENNY_COMPARE_STATE: state } : {}) };
|
|
180
|
+
if (compare && message.side === 'left') {
|
|
181
|
+
for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']) {
|
|
182
|
+
delete env[key];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const session = pty.spawn(command, commandArgs, {
|
|
186
|
+
name: 'xterm-256color', cols: message.cols, rows: message.rows,
|
|
187
|
+
cwd: process.cwd(), env,
|
|
188
|
+
});
|
|
189
|
+
sessions.set(message.side, session);
|
|
190
|
+
if (nodeMeter) {
|
|
191
|
+
meters.set(message.side, new ClaudeMeter({ sessionId, cwd: process.cwd(),
|
|
192
|
+
side: message.side, role: message.side === 'left' ? 'NATIVE' : 'PENNY' }));
|
|
193
|
+
send({ type: 'meter-mode' });
|
|
194
|
+
if (!meterTimer) meterTimer = setInterval(() => { void pollMeters(); }, 1000);
|
|
195
|
+
}
|
|
196
|
+
session.onData((data) => send({ type: 'output', side: message.side, data }));
|
|
197
|
+
session.onExit(({ exitCode }) => {
|
|
198
|
+
sessions.delete(message.side);
|
|
199
|
+
send({ type: 'exit', side: message.side, code: exitCode });
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
send({ type: 'error', side: message.side, error: error.message });
|
|
203
|
+
}
|
|
204
|
+
} else if (message.type === 'input' && typeof message.data === 'string' &&
|
|
205
|
+
message.data.length <= 16384) {
|
|
206
|
+
sessions.get(message.side)?.write(message.data);
|
|
207
|
+
} else if (message.type === 'resize' && size(message.cols, 500) && size(message.rows, 200)) {
|
|
208
|
+
sessions.get(message.side)?.resize(message.cols, message.rows);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
ws.on('message', (raw) => { void handle(raw); });
|
|
212
|
+
ws.on('close', dispose);
|
|
213
|
+
ws.on('error', dispose);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
server.listen(port, '127.0.0.1', () => {
|
|
217
|
+
const url = `http://127.0.0.1:${port}`;
|
|
218
|
+
console.log(`Penny Compare live terminal demo: ${url}`);
|
|
219
|
+
console.log(compare ? 'Native Claude and Penny Claude panes with transcript HUDs.' :
|
|
220
|
+
claude ? 'Each pane starts Claude Code.' : 'Each pane starts a shell. Type claude to run Claude Code.');
|
|
221
|
+
if (process.platform === 'win32') {
|
|
222
|
+
execFile(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`],
|
|
223
|
+
{ windowsHide: true }, (error) => {
|
|
224
|
+
if (error) console.error(`Could not open the browser automatically: ${error.message}`);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Penny Compare — terminal preview</title>
|
|
7
|
+
<link rel="stylesheet" href="/xterm.css">
|
|
8
|
+
<style>
|
|
9
|
+
:root { color-scheme: dark; font: 14px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
html, body { height: 100%; margin: 0; background: #111116; color: #d7d7dd; }
|
|
12
|
+
body { display: flex; flex-direction: column; overflow: hidden; }
|
|
13
|
+
.status { height: 25px; flex: none; display: flex; align-items: center; justify-content: center; gap: 1.2rem; background: #262629; color: #9ded83; font-size: 12px; white-space: nowrap; }
|
|
14
|
+
.status button { appearance: none; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; padding: 0; }
|
|
15
|
+
.status button:hover { color: #fff; }
|
|
16
|
+
.panes { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); flex: 1; min-height: 0; }
|
|
17
|
+
.pane { min-width: 0; min-height: 0; overflow: hidden; background: #16161b; }
|
|
18
|
+
.pane + .pane { border-left: 1px solid #4d4d52; }
|
|
19
|
+
.pane.focused { box-shadow: inset 0 0 0 1px #6a6a72; }
|
|
20
|
+
.terminal { width: 100%; height: 100%; padding: 4px 7px; }
|
|
21
|
+
.pane { position: relative; }
|
|
22
|
+
.meter { display: none; position: absolute; top: 4px; right: 13px; z-index: 2;
|
|
23
|
+
width: 190px; white-space: pre; color: #9ded83; line-height: 1.15; font-size: 13px;
|
|
24
|
+
pointer-events: none; }
|
|
25
|
+
.pane.metered .meter { display: block; }
|
|
26
|
+
.delta { display: none; position: absolute; top: 4px; right: 220px; z-index: 2;
|
|
27
|
+
color: #9ded83; white-space: nowrap; pointer-events: none; }
|
|
28
|
+
.pane.metered .delta { display: block; }
|
|
29
|
+
.xterm { height: 100%; }
|
|
30
|
+
.xterm-viewport { scrollbar-width: thin; }
|
|
31
|
+
@media (max-width: 720px) { .status { justify-content: flex-start; overflow-x: auto; padding: 0 8px; } }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body>
|
|
35
|
+
<div class="status">
|
|
36
|
+
<button id="sync">SYNC ON ^⇧S</button>
|
|
37
|
+
<button id="exit">EXIT ^⇧X</button>
|
|
38
|
+
<span id="connection" style="color:#77777e">connecting…</span>
|
|
39
|
+
</div>
|
|
40
|
+
<main class="panes">
|
|
41
|
+
<section class="pane focused"><div class="meter" id="left-meter"></div><div class="terminal" id="native"></div></section>
|
|
42
|
+
<section class="pane"><div class="delta" id="delta"></div><div class="meter" id="right-meter"></div><div class="terminal" id="penny"></div></section>
|
|
43
|
+
</main>
|
|
44
|
+
<script src="/xterm.js"></script>
|
|
45
|
+
<script src="/addon-fit.js"></script>
|
|
46
|
+
<script>
|
|
47
|
+
const options = {
|
|
48
|
+
fontFamily: 'SFMono-Regular, Menlo, Consolas, monospace', fontSize: 13,
|
|
49
|
+
lineHeight: 1.15, cursorBlink: true, cursorStyle: 'block',
|
|
50
|
+
allowTransparency: false, scrollback: 1000,
|
|
51
|
+
theme: { background: '#16161b', foreground: '#d7d7dd', cursor: '#d7d7dd',
|
|
52
|
+
selectionBackground: '#4b5369', black: '#24242a', red: '#e88b8b',
|
|
53
|
+
green: '#9ded83', yellow: '#e6c583', blue: '#8eaeeb', magenta: '#c8a7e8',
|
|
54
|
+
cyan: '#8ed1cf', white: '#d7d7dd' }
|
|
55
|
+
};
|
|
56
|
+
const native = new Terminal(options);
|
|
57
|
+
const penny = new Terminal(options);
|
|
58
|
+
const fit = { left: new FitAddon.FitAddon(), right: new FitAddon.FitAddon() };
|
|
59
|
+
native.loadAddon(fit.left);
|
|
60
|
+
penny.loadAddon(fit.right);
|
|
61
|
+
native.open(document.getElementById('native'));
|
|
62
|
+
penny.open(document.getElementById('penny'));
|
|
63
|
+
let sync = true;
|
|
64
|
+
const panes = document.querySelectorAll('.pane');
|
|
65
|
+
const send = message => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); };
|
|
66
|
+
const socket = new WebSocket(`ws://${location.host}/terminal`);
|
|
67
|
+
const setFocus = index => { panes.forEach((p, i) => p.classList.toggle('focused', i === index)); (index ? penny : native).focus(); };
|
|
68
|
+
const setSync = value => {
|
|
69
|
+
sync = value;
|
|
70
|
+
document.getElementById('sync').textContent = sync ? 'SYNC ON ^⇧S' : 'sync off ^⇧S';
|
|
71
|
+
};
|
|
72
|
+
const exit = () => {
|
|
73
|
+
send({ type: 'exit' });
|
|
74
|
+
document.getElementById('connection').textContent = 'saving report…';
|
|
75
|
+
document.getElementById('exit').disabled = true;
|
|
76
|
+
};
|
|
77
|
+
function resize() {
|
|
78
|
+
for (const [side, terminal] of [['left', native], ['right', penny]]) {
|
|
79
|
+
fit[side].fit();
|
|
80
|
+
send({ type: 'resize', side, cols: terminal.cols, rows: terminal.rows });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
socket.addEventListener('open', () => {
|
|
84
|
+
document.getElementById('connection').textContent = 'live PTYs';
|
|
85
|
+
resize();
|
|
86
|
+
for (const [side, terminal] of [['left', native], ['right', penny]]) {
|
|
87
|
+
send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
|
|
88
|
+
}
|
|
89
|
+
setFocus(0);
|
|
90
|
+
});
|
|
91
|
+
socket.addEventListener('message', event => {
|
|
92
|
+
const message = JSON.parse(event.data);
|
|
93
|
+
const terminal = message.side === 'left' ? native : penny;
|
|
94
|
+
if (message.type === 'output') terminal.write(message.data);
|
|
95
|
+
if (message.type === 'exit') terminal.write(`\r\n[process exited: ${message.code}]\r\n`);
|
|
96
|
+
if (message.type === 'error') terminal.write(`\r\n[launch failed: ${message.error}]\r\n`);
|
|
97
|
+
if (message.type === 'meter-mode') {
|
|
98
|
+
panes.forEach(pane => pane.classList.add('metered'));
|
|
99
|
+
resize();
|
|
100
|
+
}
|
|
101
|
+
if (message.type === 'meter') {
|
|
102
|
+
const data = message.data;
|
|
103
|
+
const total = data.totals;
|
|
104
|
+
document.getElementById(`${data.side}-meter`).textContent =
|
|
105
|
+
`${data.role} ${(data.model || 'waiting').split('/').pop()}\n` +
|
|
106
|
+
`read ${total.cache_read.toLocaleString().padStart(11)}\n` +
|
|
107
|
+
`write ${total.cache_write.toLocaleString().padStart(11)}\n` +
|
|
108
|
+
`in ${total.fresh_input.toLocaleString().padStart(11)}\n` +
|
|
109
|
+
`out ${total.output.toLocaleString().padStart(11)}\n` +
|
|
110
|
+
`total ${total.total.toLocaleString().padStart(11)}\n` +
|
|
111
|
+
`cost $${total.cost.toFixed(2).padStart(10)}`;
|
|
112
|
+
}
|
|
113
|
+
if (message.type === 'delta') {
|
|
114
|
+
document.getElementById('delta').textContent =
|
|
115
|
+
`${message.cost.toFixed(0)}% cost ${message.tokens.toFixed(0)}% tok`;
|
|
116
|
+
}
|
|
117
|
+
if (message.type === 'report') {
|
|
118
|
+
const savings = message.savings;
|
|
119
|
+
const metric = (amount, percent, unit) =>
|
|
120
|
+
`${unit}${unit ? Math.abs(amount).toFixed(2) : Math.abs(amount).toLocaleString()}` +
|
|
121
|
+
` ${amount >= 0 ? 'saved' : 'extra'} (${Math.abs(percent).toFixed(1)}%)`;
|
|
122
|
+
native.write(`\r\n\x1b[1;32mPENNY COMPARISON\x1b[0m\r\n` +
|
|
123
|
+
`${metric(savings.tokens, savings.token_percent, '')} tokens\r\n` +
|
|
124
|
+
`${metric(savings.cost, savings.cost_percent, '$')}\r\n` +
|
|
125
|
+
`Report: ${message.path}\r\n`);
|
|
126
|
+
document.getElementById('connection').textContent = 'report saved';
|
|
127
|
+
}
|
|
128
|
+
if (message.type === 'report-error') {
|
|
129
|
+
native.write(`\r\n[report failed: ${message.error}]\r\n`);
|
|
130
|
+
document.getElementById('connection').textContent = 'report failed';
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
socket.addEventListener('close', () => {
|
|
134
|
+
if (!document.getElementById('exit').disabled) {
|
|
135
|
+
document.getElementById('connection').textContent = 'disconnected — reload to reconnect';
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
const resizeObserver = new ResizeObserver(resize);
|
|
139
|
+
panes.forEach(pane => resizeObserver.observe(pane));
|
|
140
|
+
document.getElementById('sync').onclick = () => setSync(!sync);
|
|
141
|
+
document.getElementById('exit').onclick = exit;
|
|
142
|
+
document.addEventListener('keydown', event => {
|
|
143
|
+
if (!event.ctrlKey || !event.shiftKey || event.altKey || event.metaKey) return;
|
|
144
|
+
if (event.code === 'KeyS') { event.preventDefault(); event.stopPropagation(); setSync(!sync); }
|
|
145
|
+
if (event.code === 'KeyX') { event.preventDefault(); event.stopPropagation(); exit(); }
|
|
146
|
+
}, true);
|
|
147
|
+
panes[0].addEventListener('pointerdown', () => setFocus(0));
|
|
148
|
+
panes[1].addEventListener('pointerdown', () => setFocus(1));
|
|
149
|
+
native.onData(data => {
|
|
150
|
+
send({ type: 'input', side: 'left', data });
|
|
151
|
+
if (sync) send({ type: 'input', side: 'right', data });
|
|
152
|
+
});
|
|
153
|
+
penny.onData(data => {
|
|
154
|
+
send({ type: 'input', side: 'right', data });
|
|
155
|
+
if (sync) send({ type: 'input', side: 'left', data });
|
|
156
|
+
});
|
|
157
|
+
setFocus(0);
|
|
158
|
+
</script>
|
|
159
|
+
</body>
|
|
160
|
+
</html>
|