pennyrouter 0.3.34 → 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.34",
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);
@@ -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>