pennyrouter 0.3.37 → 0.3.39

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.
@@ -21,10 +21,10 @@ To run the current Penny Compare measurement code in the panes:
21
21
  npm start -- --compare
22
22
  ```
23
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`.
24
+ This opens a setup screen with the same Claude/Codex pane choices, directories, labels, and shared model selection as the classic launcher. The default is native Claude on the left and Penny Claude on the right, using `<current directory>/native` and `<current directory>/penny`. On macOS/Linux, it uses the existing Python session reader, cost model, HUD, and delta calculation. On Windows, it reads Claude or Codex transcripts and prices calls in Node, with no Python or tmux requirement. Exit saves a JSON report under `apps/compare/reports` and shows the final summary. The selected harness commands must be on `PATH`.
25
25
 
26
- The Windows path currently supports Claude Code comparisons. Codex and OpenCode transcript readers are still in the Python path.
26
+ Use `--harness=codex` to preselect Codex in the setup screen. The pane selectors can also mix Claude and Codex; the shared model control is available when both panes use the same harness family.
27
27
 
28
- To exercise the Windows measurement path on macOS/Linux, use `npm start -- --compare-node`.
28
+ To exercise the Node measurement paths on macOS/Linux, use `npm start -- --compare-node --harness=claude` for Claude or `npm start -- --compare-node --harness=codex` for Codex. Claude is the default if you omit `--harness`.
29
29
 
30
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.
@@ -1,18 +1,44 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { mkdir, open, readdir, stat, writeFile } from 'node:fs/promises';
2
3
  import { homedir } from 'node:os';
3
4
  import { join } from 'node:path';
4
5
 
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
- };
6
+ // Standard per-token rates from ../rates.json (scripts/generate_compare_rates.py), the same
7
+ // file pricing.py reads. Each rate list is [input, cache read, cache write 5m, cache write 1h,
8
+ // output]; a model may carry an `above` list that applies once one request's input context
9
+ // exceeds its threshold.
10
+ const { models } = JSON.parse(readFileSync(new URL('../rates.json', import.meta.url), 'utf8'));
11
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'];
12
+ function longestPrefix(name, table) {
13
+ const keys = Object.keys(table).filter((key) => name.startsWith(key));
14
+ return keys.sort((a, b) => b.length - a.length)[0];
15
+ }
16
+
17
+ function rateFor(model, contextTokens = 0) {
18
+ const normalized = String(model || '').replace(/^.*\//, '').replace('[1m]', '').trim();
19
+ const entry = models[longestPrefix(normalized, models)] || models['claude-sonnet-5'];
20
+ return entry.above && contextTokens > entry.above.threshold ? entry.above.rates : entry.rates;
21
+ }
22
+
23
+ // Codex's session_meta line embeds the full base instructions (~20 KB), so a fixed small
24
+ // read truncates it mid-JSON. Read until the first newline, bounded.
25
+ async function readFirstLine(path, limit = 1024 * 1024) {
26
+ const handle = await open(path, 'r');
27
+ try {
28
+ const chunks = [];
29
+ const buffer = Buffer.alloc(16 * 1024);
30
+ let position = 0;
31
+ while (position < limit) {
32
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
33
+ if (!bytesRead) break;
34
+ const chunk = buffer.subarray(0, bytesRead);
35
+ const newline = chunk.indexOf(10);
36
+ if (newline >= 0) { chunks.push(Buffer.from(chunk.subarray(0, newline))); break; }
37
+ chunks.push(Buffer.from(chunk));
38
+ position += bytesRead;
39
+ }
40
+ return Buffer.concat(chunks).toString('utf8');
41
+ } finally { await handle.close(); }
16
42
  }
17
43
 
18
44
  function emptyTotals() {
@@ -21,11 +47,13 @@ function emptyTotals() {
21
47
  }
22
48
 
23
49
  export class ClaudeMeter {
24
- constructor({ sessionId, cwd, side, role }) {
50
+ constructor({ sessionId, cwd, side, role, label, variant }) {
25
51
  this.sessionId = sessionId;
26
52
  this.cwd = cwd;
27
53
  this.side = side;
28
54
  this.role = role;
55
+ this.label = label || role;
56
+ this.variant = variant;
29
57
  this.root = join(homedir(), '.claude', 'projects');
30
58
  this.files = new Map();
31
59
  this.ids = new Set();
@@ -54,17 +82,12 @@ export class ClaudeMeter {
54
82
  for (const entry of entries) {
55
83
  if (!entry.endsWith('.jsonl')) continue;
56
84
  const path = join(this.root, project.name, entry);
57
- let handle;
58
85
  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) {
86
+ if (JSON.parse(await readFirstLine(path)).sessionId === this.sessionId) {
64
87
  this.transcriptPath = path;
65
88
  return path;
66
89
  }
67
- } catch { /* next */ } finally { await handle?.close(); }
90
+ } catch { /* next */ }
68
91
  }
69
92
  }
70
93
  return null;
@@ -118,7 +141,7 @@ export class ClaudeMeter {
118
141
  const input = Number(usage.input_tokens) || 0;
119
142
  const output = Number(usage.output_tokens) || 0;
120
143
  const model = message.model || this.model;
121
- const prices = rateFor(model);
144
+ const prices = rateFor(model, read + write + input);
122
145
  const cost = input * prices[0] + read * prices[1] + write5 * prices[2] +
123
146
  write1 * prices[3] + output * prices[4];
124
147
  const call = { timestamp: obj.timestamp || null, recorded_at: Date.now() / 1000,
@@ -146,16 +169,112 @@ export class ClaudeMeter {
146
169
  }
147
170
 
148
171
  snapshot() {
149
- return { side: this.side, role: this.role, label: this.role, variant: this.side === 'left' ? 'claude' : 'penny-claude',
172
+ return { side: this.side, role: this.role, label: this.label, variant: this.variant,
150
173
  harness: 'claude-code', cwd: this.cwd, pricing_profile: 'standard', session_id: this.sessionId,
151
174
  transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
152
175
  totals: { ...this.totals } };
153
176
  }
154
177
  }
155
178
 
179
+ // Codex writes cumulative token_count snapshots to dated rollout JSONL files.
180
+ // Keep a cursor per transcript and charge each snapshot's delta once.
181
+ export class CodexMeter extends ClaudeMeter {
182
+ constructor(options) {
183
+ super(options);
184
+ this.root = join(homedir(), '.codex', 'sessions');
185
+ this.transcriptPath = null;
186
+ this.previous = null;
187
+ this.started = Date.now() / 1000;
188
+ }
189
+
190
+ async findTranscript() {
191
+ if (this.transcriptPath) return this.transcriptPath;
192
+ const visit = async (directory) => {
193
+ let entries;
194
+ try { entries = await readdir(directory, { withFileTypes: true }); } catch { return []; }
195
+ const found = [];
196
+ for (const entry of entries) {
197
+ const path = join(directory, entry.name);
198
+ if (entry.isDirectory()) found.push(...await visit(path));
199
+ else if (entry.name.endsWith('.jsonl')) {
200
+ try {
201
+ const meta = JSON.parse(await readFirstLine(path)).payload;
202
+ const info = await stat(path);
203
+ const expectedProvider = this.role === 'NATIVE' ? 'openai' : 'pennyrouter';
204
+ if (meta?.cwd && meta.cwd.toLowerCase() === this.cwd.toLowerCase() &&
205
+ meta.model_provider === expectedProvider &&
206
+ info.mtimeMs / 1000 >= this.started - 3) found.push([path, info.mtimeMs]);
207
+ } catch { /* incomplete or unrelated rollout */ }
208
+ }
209
+ }
210
+ return found;
211
+ };
212
+ const candidates = await visit(this.root);
213
+ candidates.sort((a, b) => b[1] - a[1]);
214
+ this.transcriptPath = candidates[0]?.[0] || null;
215
+ return this.transcriptPath;
216
+ }
217
+
218
+ async poll() {
219
+ const path = await this.findTranscript();
220
+ if (!path) return this.snapshot();
221
+ let size;
222
+ try { ({ size } = await stat(path)); } catch { return this.snapshot(); }
223
+ let offset = this.files.get(path) || 0;
224
+ if (size < offset) { offset = 0; this.previous = null; }
225
+ if (size === offset) return this.snapshot();
226
+ const handle = await open(path, 'r');
227
+ try {
228
+ const buffer = Buffer.alloc(size - offset);
229
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
230
+ const text = buffer.subarray(0, bytesRead).toString('utf8');
231
+ const end = text.lastIndexOf('\n');
232
+ if (end < 0) return this.snapshot();
233
+ this.files.set(path, offset + Buffer.byteLength(text.slice(0, end + 1)));
234
+ for (const line of text.slice(0, end).split('\n')) {
235
+ let obj;
236
+ try { obj = JSON.parse(line); } catch { continue; }
237
+ const payload = obj.payload;
238
+ if (obj.type === 'turn_context' && payload?.model) this.model = payload.model;
239
+ if (payload?.type !== 'token_count' || !payload.info?.total_token_usage) continue;
240
+ const total = payload.info.total_token_usage;
241
+ const fingerprint = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens',
242
+ 'output_tokens', 'reasoning_output_tokens'].map((key) => Number(total[key]) || 0);
243
+ if (this.previous && fingerprint.every((value, index) => value === this.previous[index])) continue;
244
+ const delta = fingerprint.map((value, index) => Math.max(0, value - (this.previous?.[index] || 0)));
245
+ this.previous = fingerprint;
246
+ const [input, cached, write, output, reasoning] = delta;
247
+ const fresh = Math.max(0, input - cached - write);
248
+ const context = Number(payload.info.last_token_usage?.input_tokens) || input;
249
+ const prices = rateFor(this.model, context);
250
+ const cost = fresh * prices[0] + cached * prices[1] + write * prices[2] + output * prices[4];
251
+ const call = { timestamp: obj.timestamp || null, recorded_at: Date.now() / 1000,
252
+ model: this.model, cache_read: cached, cache_write: write, write_5m: write,
253
+ write_1h: 0, fresh_input: fresh, output, reasoning_output: reasoning,
254
+ total: input + output, cost, context_tokens: context, pricing_profile: 'standard',
255
+ rates_per_million: Object.fromEntries(
256
+ ['input', 'cache_read', 'cache_write_5m', 'cache_write_1h', 'output']
257
+ .map((name, index) => [name, prices[index] * 1_000_000])) };
258
+ this.calls.push(call);
259
+ this.totals.cache_read += cached; this.totals.cache_write += write;
260
+ this.totals.write_5m += write; this.totals.fresh_input += fresh;
261
+ this.totals.output += output; this.totals.reasoning_output += reasoning;
262
+ this.totals.total += call.total; this.totals.cost += cost;
263
+ }
264
+ } finally { await handle.close(); }
265
+ return this.snapshot();
266
+ }
267
+
268
+ snapshot() {
269
+ return { ...super.snapshot(), variant: this.side === 'left' ? 'codex' : 'penny-codex',
270
+ harness: 'codex', transcript_path: this.transcriptPath };
271
+ }
272
+ }
273
+
156
274
  export async function saveReport(left, right, reportsDir) {
157
- const native = left.snapshot();
158
- const penny = right.snapshot();
275
+ const sides = [left.snapshot(), right.snapshot()];
276
+ const native = sides.find((side) => side.role === 'NATIVE') || sides[0];
277
+ const penny = sides.find((side) => side.role === 'PENNY') || sides[1];
159
278
  const savings = {
160
279
  cost: native.totals.cost - penny.totals.cost,
161
280
  tokens: native.totals.total - penny.totals.total,
@@ -1,29 +1,32 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import { spawn, execFile } from 'node:child_process';
5
- import { join, dirname } from 'node:path';
6
- import { tmpdir } from 'node:os';
5
+ import { join, dirname, resolve } from 'node:path';
6
+ import { homedir, tmpdir } from 'node:os';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { promisify } from 'node:util';
9
9
  import { WebSocketServer } from 'ws';
10
10
  import pty from 'node-pty';
11
- import { ClaudeMeter, saveReport } from './claude-meter.mjs';
11
+ import { ClaudeMeter, CodexMeter, saveReport } from './claude-meter.mjs';
12
12
 
13
13
  const here = dirname(fileURLToPath(import.meta.url));
14
14
  const compareDir = dirname(here);
15
15
  const execFileAsync = promisify(execFile);
16
16
  const args = process.argv.slice(2);
17
17
  const claude = args.includes('--claude');
18
+ const harnessArg = args.find((arg) => /^--harness=(claude|codex)$/.test(arg));
19
+ const startupHarness = harnessArg?.slice('--harness='.length) || 'claude';
18
20
  const nodeMeter = args.includes('--compare-node') ||
19
21
  (args.includes('--compare') && process.platform === 'win32');
20
22
  const compare = args.includes('--compare') || nodeMeter;
21
23
  const pythonCompare = compare && !nodeMeter && process.platform !== 'win32';
22
24
  const portArg = args.find((arg) => /^--port=\d+$/.test(arg));
23
25
  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]');
26
+ if (args.some((arg) => !['--claude', '--compare', '--compare-node', '--harness=claude', '--harness=codex', portArg].includes(arg)) ||
27
+ (claude && compare) || (harnessArg && !compare) ||
28
+ (args.includes('--harness=claude') && args.includes('--harness=codex')) || port < 1 || port > 65535) {
29
+ console.error('Usage: npm start -- [--claude | --compare [--harness=claude|codex] | --compare-node [--harness=claude|codex]] [--port=8789]');
27
30
  process.exit(1);
28
31
  }
29
32
 
@@ -38,7 +41,13 @@ const server = createServer(async (request, response) => {
38
41
  const file = files.get(request.url);
39
42
  if (!file) { response.writeHead(404).end(); return; }
40
43
  try {
41
- const body = await readFile(join(here, file[0]));
44
+ let body = await readFile(join(here, file[0]));
45
+ if (request.url === '/') {
46
+ body = body.toString('utf8')
47
+ .replace('__PENNY_CWD__', JSON.stringify(process.cwd()).replaceAll('<', '\\u003c'))
48
+ .replace('__PENNY_COMPARE__', JSON.stringify(compare))
49
+ .replace('__PENNY_DEFAULT_HARNESS__', JSON.stringify(startupHarness));
50
+ }
42
51
  response.writeHead(200, { 'Content-Type': file[1], 'Cache-Control': 'no-store' }).end(body);
43
52
  } catch (error) {
44
53
  console.error(error);
@@ -57,22 +66,46 @@ server.on('upgrade', (request, socket, head) => {
57
66
  wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws));
58
67
  });
59
68
 
60
- function launchCommand(side, state, sessionId) {
69
+ const variants = new Set(['claude', 'penny-claude', 'codex', 'penny-codex']);
70
+ const safeModel = /^[A-Za-z0-9._/-]{1,160}$/;
71
+
72
+ async function validateConfig(config) {
73
+ const codexModels = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini'];
74
+ const claudeModels = ['opus', 'sonnet', 'haiku', 'fable'];
75
+ if (!config || !variants.has(config.variant) || typeof config.cwd !== 'string' ||
76
+ config.cwd.length > 4096 || typeof config.label !== 'string' || config.label.length > 80 ||
77
+ typeof config.model !== 'string' || (config.model && !safeModel.test(config.model)) ||
78
+ (config.model && config.variant.includes('codex') && !codexModels.includes(config.model)) ||
79
+ (config.model && !config.variant.includes('codex') &&
80
+ !claudeModels.includes(config.model) && !config.model.startsWith('claude-'))) {
81
+ throw new Error('Invalid compare settings. Choose Claude or Codex, a directory, and a valid model name.');
82
+ }
83
+ const expanded = config.cwd.startsWith('~/') ? join(homedir(), config.cwd.slice(2)) : config.cwd;
84
+ const cwd = resolve(expanded);
85
+ if (!(await stat(cwd)).isDirectory()) throw new Error(`Directory does not exist: ${cwd}`);
86
+ return { ...config, cwd };
87
+ }
88
+
89
+ function launchCommand(side, state, sessionId, config) {
61
90
  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'];
91
+ const args = [join(compareDir, 'pane.py'), config.variant, '--side', side,
92
+ '--cwd', config.cwd, '--pricing-profile', 'standard'];
93
+ if (config.model) args.push('--model', config.model);
94
+ if (config.label) args.push('--label', config.label);
65
95
  if (side === 'right') args.push('--delta-file', join(state, 'delta.txt'));
66
96
  return ['python3', args];
67
97
  }
68
98
  if (nodeMeter) {
99
+ const codex = config.variant.includes('codex');
100
+ const command = config.variant.startsWith('penny-')
101
+ ? `penny ${codex ? 'codex' : 'claude'}` : (codex ? 'codex' : 'claude');
102
+ const modelArg = config.model ? ` --model ${config.model}` : '';
69
103
  if (process.platform === 'win32') {
70
104
  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}`]];
105
+ return [shell, ['/d', '/s', '/c', `${command}${codex ? '' : ` --session-id ${sessionId}`}${modelArg}`]];
73
106
  }
74
107
  const shell = process.env.SHELL || '/bin/sh';
75
- return [shell, ['-lc', `exec ${side === 'left' ? 'claude' : 'penny claude'} --session-id ${sessionId}`]];
108
+ return [shell, ['-lc', `exec ${command}${codex ? '' : ` --session-id ${sessionId}`}${modelArg}`]];
76
109
  }
77
110
  if (process.platform === 'win32') {
78
111
  const shell = process.env.ComSpec || 'cmd.exe';
@@ -84,6 +117,7 @@ function launchCommand(side, state, sessionId) {
84
117
 
85
118
  wss.on('connection', (ws) => {
86
119
  const sessions = new Map();
120
+ const compareConfig = new Map();
87
121
  const meters = new Map();
88
122
  const snapshots = new Map();
89
123
  let state;
@@ -135,6 +169,23 @@ wss.on('connection', (ws) => {
135
169
  const handle = async (raw) => {
136
170
  let message;
137
171
  try { message = JSON.parse(raw); } catch { return; }
172
+ if (message.type === 'configure') {
173
+ try {
174
+ if (!compare) throw new Error('Compare setup is unavailable in shell demo mode.');
175
+ const left = await validateConfig(message.config?.left);
176
+ const right = await validateConfig(message.config?.right);
177
+ if (left.model && left.variant.includes('codex') !== right.variant.includes('codex')) {
178
+ throw new Error('Choose the same model family for both panes, or leave Model set to default.');
179
+ }
180
+ compareConfig.set('left', left);
181
+ compareConfig.set('right', right);
182
+ send({ type: 'configured' });
183
+ } catch (error) {
184
+ compareConfig.clear();
185
+ send({ type: 'configuration-error', error: error.message });
186
+ }
187
+ return;
188
+ }
138
189
  if (message.type === 'exit') {
139
190
  if (exiting) return;
140
191
  exiting = true;
@@ -166,6 +217,10 @@ wss.on('connection', (ws) => {
166
217
  if (message.type === 'start' && !sessions.has(message.side) &&
167
218
  size(message.cols, 500) && size(message.rows, 200)) {
168
219
  try {
220
+ const config = compare
221
+ ? compareConfig.get(message.side)
222
+ : { variant: 'claude', cwd: process.cwd(), label: '', model: '' };
223
+ if (!config) throw new Error('Choose and validate both compare panes before starting.');
169
224
  await ready;
170
225
  if (closed) return;
171
226
  if (pythonCompare && !delta) {
@@ -173,8 +228,8 @@ wss.on('connection', (ws) => {
173
228
  { stdio: 'ignore' });
174
229
  delta.on('error', (error) => send({ type: 'error', side: 'right', error: error.message }));
175
230
  }
176
- const sessionId = nodeMeter ? randomUUID() : undefined;
177
- const [command, commandArgs] = launchCommand(message.side, state, sessionId);
231
+ const sessionId = nodeMeter && !config.variant.includes('codex') ? randomUUID() : undefined;
232
+ const [command, commandArgs] = launchCommand(message.side, state, sessionId, config);
178
233
  const env = { ...process.env, TERM: 'xterm-256color',
179
234
  ...(pythonCompare ? { PENNY_COMPARE_STATE: state } : {}) };
180
235
  if (compare && message.side === 'left') {
@@ -184,12 +239,14 @@ wss.on('connection', (ws) => {
184
239
  }
185
240
  const session = pty.spawn(command, commandArgs, {
186
241
  name: 'xterm-256color', cols: message.cols, rows: message.rows,
187
- cwd: process.cwd(), env,
242
+ cwd: config.cwd, env,
188
243
  });
189
244
  sessions.set(message.side, session);
190
245
  if (nodeMeter) {
191
- meters.set(message.side, new ClaudeMeter({ sessionId, cwd: process.cwd(),
192
- side: message.side, role: message.side === 'left' ? 'NATIVE' : 'PENNY' }));
246
+ const Meter = config.variant.includes('codex') ? CodexMeter : ClaudeMeter;
247
+ const role = config.variant.startsWith('penny-') ? 'PENNY' : 'NATIVE';
248
+ meters.set(message.side, new Meter({ sessionId, cwd: config.cwd,
249
+ side: message.side, role, label: config.label, variant: config.variant }));
193
250
  send({ type: 'meter-mode' });
194
251
  if (!meterTimer) meterTimer = setInterval(() => { void pollMeters(); }, 1000);
195
252
  }
@@ -216,12 +273,17 @@ wss.on('connection', (ws) => {
216
273
  server.listen(port, '127.0.0.1', () => {
217
274
  const url = `http://127.0.0.1:${port}`;
218
275
  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.');
276
+ console.log(compare
277
+ ? `Compare setup ready (${pythonCompare ? 'Python session metering' : 'Node transcript metering'}). Choose both panes in the browser.`
278
+ : claude ? 'Each pane starts Claude Code.' : 'Each pane starts a shell. Type claude to run Claude Code.');
221
279
  if (process.platform === 'win32') {
222
280
  execFile(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`],
223
281
  { windowsHide: true }, (error) => {
224
282
  if (error) console.error(`Could not open the browser automatically: ${error.message}`);
225
283
  });
284
+ } else {
285
+ execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], (error) => {
286
+ if (error) console.error(`Could not open the browser automatically: ${error.message}`);
287
+ });
226
288
  }
227
289
  });