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 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 Linux. It is installed automatically as part
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
- The installer puts `penny-compare` and `penny-session` on PATH alongside the `penny` launcher.
91
- Use `penny-compare` for a side-by-side run or `penny-session` for one Penny-routed session.
92
- The bundle requires `tmux`, Python 3.10+, and the harnesses being compared.
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.33",
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 or Linux with tmux and Python 3.10+.");
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("Run `penny-compare` or `penny-session` to use it.");
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 selection = extractProfileArgs(argv.slice(1));
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";
@@ -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'"
@@ -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
- session = sys.argv[3]
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>