pennyrouter 0.3.37 → 0.3.38
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 +6 -6
- package/package.json +1 -1
- package/src/cli.js +7 -7
- package/src/launch.js +28 -8
- package/standalone/penny-compare +13 -2
- package/standalone/pricing.py +3 -0
- package/standalone/web-demo/README.md +3 -3
- package/standalone/web-demo/claude-meter.mjs +105 -4
- package/standalone/web-demo/server.mjs +84 -22
- package/standalone/web-demo/terminal-preview.html +166 -15
package/README.md
CHANGED
|
@@ -87,12 +87,12 @@ of `npx pennyrouter install`; to install or refresh it separately, run:
|
|
|
87
87
|
npx pennyrouter compare
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
Run `penny compare` on macOS, Linux, or Windows to open the browser-based setup. It mirrors the
|
|
91
|
+
classic launcher's Claude/Codex pane, directory, label, and model choices. The browser version
|
|
92
|
+
uses Python session metering on macOS/Linux and Node transcript metering on Windows, so it does
|
|
93
|
+
not need tmux. The classic terminal interface remains available as `penny-compare` on macOS/Linux
|
|
94
|
+
and requires tmux and Python 3.10+. Sync is Ctrl+Shift+S; Exit saves the report and shows the
|
|
95
|
+
final summary.
|
|
96
96
|
|
|
97
97
|
On macOS and Linux, a normal install adds a small `penny` launcher under `~/.local/bin`.
|
|
98
98
|
On Windows it installs `penny.cmd` under `%LOCALAPPDATA%\\PennyRouter\\bin` and adds that
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -138,9 +138,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
138
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(
|
|
142
|
-
|
|
143
|
-
: "Run `penny-compare` or `penny-session` to use it.");
|
|
141
|
+
console.log("Run `penny compare` to open the browser-based comparison setup.");
|
|
142
|
+
if (process.platform !== "win32") console.log("Run `penny-compare` for the classic tmux interface.");
|
|
144
143
|
return;
|
|
145
144
|
}
|
|
146
145
|
|
|
@@ -1010,9 +1009,9 @@ export async function install(flags = {}) {
|
|
|
1010
1009
|
}
|
|
1011
1010
|
}
|
|
1012
1011
|
if (runtimeResult && !runtimeResult.available) {
|
|
1013
|
-
console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
|
|
1012
|
+
console.log(color(color("Add the PennyRouter launcher directory to PATH before using the `penny` command.", "bold"), "yellow"));
|
|
1014
1013
|
} else if (runtimeResult?.changed) {
|
|
1015
|
-
console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
|
|
1014
|
+
console.log(color(color("Open a new terminal (or reload your shell) before using the `penny` command.", "bold"), "yellow"));
|
|
1016
1015
|
}
|
|
1017
1016
|
|
|
1018
1017
|
// Registered after routing, which is the primary job: a naming conflict here reports itself
|
|
@@ -1460,9 +1459,9 @@ async function update(flags) {
|
|
|
1460
1459
|
|
|
1461
1460
|
console.log("");
|
|
1462
1461
|
if (runtimeResult && !runtimeResult.available) {
|
|
1463
|
-
console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
|
|
1462
|
+
console.log(color(color("Add the PennyRouter launcher directory to PATH before using the `penny` command.", "bold"), "yellow"));
|
|
1464
1463
|
} else if (runtimeResult?.changed) {
|
|
1465
|
-
console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
|
|
1464
|
+
console.log(color(color("Open a new terminal (or reload your shell) before using the `penny` command.", "bold"), "yellow"));
|
|
1466
1465
|
}
|
|
1467
1466
|
console.log("Restart any running Claude Code or Codex sessions to pick up the update.");
|
|
1468
1467
|
reportFailures(failures);
|
|
@@ -2391,6 +2390,7 @@ function color(text, style) {
|
|
|
2391
2390
|
bold: ["\x1b[1m", "\x1b[22m"],
|
|
2392
2391
|
cyan: ["\x1b[36m", "\x1b[39m"],
|
|
2393
2392
|
green: ["\x1b[32m", "\x1b[39m"],
|
|
2393
|
+
yellow: ["\x1b[33m", "\x1b[39m"],
|
|
2394
2394
|
};
|
|
2395
2395
|
const pair = codes[style];
|
|
2396
2396
|
return pair ? `${pair[0]}${text}${pair[1]}` : text;
|
package/src/launch.js
CHANGED
|
@@ -314,13 +314,13 @@ export async function installPennyCompare() {
|
|
|
314
314
|
}
|
|
315
315
|
await mkdir(join(destination, "reports"), { recursive: true, mode: 0o700 });
|
|
316
316
|
await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
|
|
317
|
+
const modules = join(destination, "web-demo", "node_modules");
|
|
318
|
+
for (const name of ["@xterm/addon-fit", "@xterm/xterm", "node-pty", "ws"]) {
|
|
319
|
+
const target = join(modules, name);
|
|
320
|
+
await mkdir(dirname(target), { recursive: true });
|
|
321
|
+
await cp(await findInstalledPackage(name), target, { recursive: true });
|
|
322
|
+
}
|
|
317
323
|
if (process.platform === "win32") {
|
|
318
|
-
const modules = join(destination, "web-demo", "node_modules");
|
|
319
|
-
for (const name of ["@xterm/addon-fit", "@xterm/xterm", "node-pty", "ws"]) {
|
|
320
|
-
const target = join(modules, name);
|
|
321
|
-
await mkdir(dirname(target), { recursive: true });
|
|
322
|
-
await cp(await findInstalledPackage(name), target, { recursive: true });
|
|
323
|
-
}
|
|
324
324
|
const link = join(pennyBinDir(), "penny-compare.cmd");
|
|
325
325
|
const marker = "@REM PennyRouter-managed compare launcher";
|
|
326
326
|
let previous = "";
|
|
@@ -1501,16 +1501,36 @@ export async function runPennyClaudeStats(args, profile) {
|
|
|
1501
1501
|
return result.code ?? 1;
|
|
1502
1502
|
}
|
|
1503
1503
|
|
|
1504
|
+
/** Launch the separately installed Penny Compare app from the `penny` command. */
|
|
1505
|
+
async function runPennyCompare(args) {
|
|
1506
|
+
const compareDir = pennyCompareDir();
|
|
1507
|
+
const executable = join(compareDir, "web-demo", "server.mjs");
|
|
1508
|
+
try {
|
|
1509
|
+
await stat(executable);
|
|
1510
|
+
} catch {
|
|
1511
|
+
throw new Error("Penny Compare is not installed. Run `npx pennyrouter compare` first.");
|
|
1512
|
+
}
|
|
1513
|
+
const child = spawn(process.execPath, [executable, "--compare", ...args], {
|
|
1514
|
+
cwd: process.cwd(), env: process.env, stdio: "inherit",
|
|
1515
|
+
});
|
|
1516
|
+
const result = await waitForChild(child);
|
|
1517
|
+
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
1518
|
+
return result.code ?? 1;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1504
1521
|
export async function runPenny(argv = process.argv.slice(2)) {
|
|
1505
1522
|
const harness = argv[0];
|
|
1506
1523
|
if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
|
|
1507
|
-
console.log("Usage: penny <claude|codex|opencode> [
|
|
1524
|
+
console.log("Usage: penny <claude|codex|opencode|compare> [arguments...]");
|
|
1525
|
+
console.log(" penny compare launches Penny Compare; install it with `npx pennyrouter compare`.");
|
|
1526
|
+
console.log(" penny <claude|codex|opencode> [--penny-profile <name>] [native arguments...]");
|
|
1508
1527
|
console.log(" --private / --cloud select the like-named profile.");
|
|
1509
1528
|
console.log(" penny claude --stats adds the live single-session token readout.");
|
|
1510
1529
|
return 0;
|
|
1511
1530
|
}
|
|
1531
|
+
if (harness === "compare") return runPennyCompare(argv.slice(1));
|
|
1512
1532
|
if (!["claude", "codex", "opencode"].includes(harness)) {
|
|
1513
|
-
throw new Error(`Unknown
|
|
1533
|
+
throw new Error(`Unknown Penny command "${harness}". Try penny claude, penny codex, penny opencode, or penny compare.`);
|
|
1514
1534
|
}
|
|
1515
1535
|
const statsSelection = extractStatsArgs(argv.slice(1));
|
|
1516
1536
|
if (statsSelection.stats && harness !== "claude") {
|
package/standalone/penny-compare
CHANGED
|
@@ -62,9 +62,11 @@ label_right=""
|
|
|
62
62
|
wire=""
|
|
63
63
|
wire_both=""
|
|
64
64
|
wire_https=""
|
|
65
|
+
pricing_profile=""
|
|
65
66
|
while [ $# -gt 0 ]; do
|
|
66
67
|
case "$1" in
|
|
67
68
|
--model) model="$2"; shift 2 ;;
|
|
69
|
+
--pricing-profile) pricing_profile="$2"; shift 2 ;;
|
|
68
70
|
--dir-left) dir_left="$2"; shift 2 ;;
|
|
69
71
|
--dir-right) dir_right="$2"; shift 2 ;;
|
|
70
72
|
--native-proxy) native_proxy="--native-proxy"; shift ;;
|
|
@@ -175,6 +177,17 @@ else
|
|
|
175
177
|
fi
|
|
176
178
|
fi
|
|
177
179
|
|
|
180
|
+
# Pricing is an estimate-only display setting, so choose it explicitly instead of silently
|
|
181
|
+
# coupling it to the native transport proxy. This also keeps direct (two-argument) launches
|
|
182
|
+
# consistent with menu launches.
|
|
183
|
+
if [ -z "$pricing_profile" ]; then
|
|
184
|
+
pricing_profile="$(choose 'pricing profile' standard standard govcloud)"
|
|
185
|
+
fi
|
|
186
|
+
case "$pricing_profile" in
|
|
187
|
+
standard|govcloud) ;;
|
|
188
|
+
*) echo "unknown pricing profile: $pricing_profile (try standard or govcloud)" >&2; exit 1 ;;
|
|
189
|
+
esac
|
|
190
|
+
|
|
178
191
|
# A directory that does not exist would otherwise surface as a confusing tmux failure.
|
|
179
192
|
for candidate in "$dir_left" "$dir_right"; do
|
|
180
193
|
if [ -n "$candidate" ] && [ ! -d "${candidate/#\~/$HOME}" ]; then
|
|
@@ -243,9 +256,7 @@ workdir_right_arg=" --cwd $(printf '%q' "$workdir_right")"
|
|
|
243
256
|
# a penny-* pane already owns its own proxy routing and ignores the flag either way.
|
|
244
257
|
native_proxy_left=""
|
|
245
258
|
native_proxy_right=""
|
|
246
|
-
pricing_profile="standard"
|
|
247
259
|
if [ -n "$native_proxy" ]; then
|
|
248
|
-
pricing_profile="govcloud"
|
|
249
260
|
case "$left" in claude|codex) native_proxy_left="$native_proxy" ;; esac
|
|
250
261
|
case "$right" in claude|codex) native_proxy_right="$native_proxy" ;; esac
|
|
251
262
|
fi
|
package/standalone/pricing.py
CHANGED
|
@@ -15,6 +15,9 @@ RATES = {
|
|
|
15
15
|
# OpenAI does not offer a 1-hour cache tier; its write rate applies uniformly.
|
|
16
16
|
"gpt-5-codex": (1.25e-06, 1.25e-07, 1.25e-06, 1.25e-06, 1.00e-05),
|
|
17
17
|
"gpt-5.1-codex": (1.25e-06, 1.25e-07, 1.25e-06, 1.25e-06, 1.00e-05),
|
|
18
|
+
# GPT-5.6 standard pricing. Keep Luna explicit so penny-session does not silently
|
|
19
|
+
# price it as Claude Sonnet when no GovCloud profile was selected.
|
|
20
|
+
"gpt-5.6-luna": (0.20e-06, 0.020e-06, 0.250e-06, 0.250e-06, 1.20e-06),
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
# --native-proxy means the comparison is measuring the GovCloud route. Pricing remains the
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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.
|
|
@@ -21,11 +21,13 @@ function emptyTotals() {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export class ClaudeMeter {
|
|
24
|
-
constructor({ sessionId, cwd, side, role }) {
|
|
24
|
+
constructor({ sessionId, cwd, side, role, label, variant }) {
|
|
25
25
|
this.sessionId = sessionId;
|
|
26
26
|
this.cwd = cwd;
|
|
27
27
|
this.side = side;
|
|
28
28
|
this.role = role;
|
|
29
|
+
this.label = label || role;
|
|
30
|
+
this.variant = variant;
|
|
29
31
|
this.root = join(homedir(), '.claude', 'projects');
|
|
30
32
|
this.files = new Map();
|
|
31
33
|
this.ids = new Set();
|
|
@@ -146,16 +148,115 @@ export class ClaudeMeter {
|
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
snapshot() {
|
|
149
|
-
return { side: this.side, role: this.role, label: this.
|
|
151
|
+
return { side: this.side, role: this.role, label: this.label, variant: this.variant,
|
|
150
152
|
harness: 'claude-code', cwd: this.cwd, pricing_profile: 'standard', session_id: this.sessionId,
|
|
151
153
|
transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
|
|
152
154
|
totals: { ...this.totals } };
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
// Codex writes cumulative token_count snapshots to dated rollout JSONL files.
|
|
159
|
+
// Keep a cursor per transcript and charge each snapshot's delta once.
|
|
160
|
+
export class CodexMeter extends ClaudeMeter {
|
|
161
|
+
constructor(options) {
|
|
162
|
+
super(options);
|
|
163
|
+
this.root = join(homedir(), '.codex', 'sessions');
|
|
164
|
+
this.transcriptPath = null;
|
|
165
|
+
this.previous = null;
|
|
166
|
+
this.started = Date.now() / 1000;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async findTranscript() {
|
|
170
|
+
if (this.transcriptPath) return this.transcriptPath;
|
|
171
|
+
const visit = async (directory) => {
|
|
172
|
+
let entries;
|
|
173
|
+
try { entries = await readdir(directory, { withFileTypes: true }); } catch { return []; }
|
|
174
|
+
const found = [];
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
const path = join(directory, entry.name);
|
|
177
|
+
if (entry.isDirectory()) found.push(...await visit(path));
|
|
178
|
+
else if (entry.name.endsWith('.jsonl')) {
|
|
179
|
+
try {
|
|
180
|
+
const handle = await open(path, 'r');
|
|
181
|
+
const buffer = Buffer.alloc(4096);
|
|
182
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
183
|
+
await handle.close();
|
|
184
|
+
const meta = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8').split('\n', 1)[0]).payload;
|
|
185
|
+
const info = await stat(path);
|
|
186
|
+
const expectedProvider = this.role === 'NATIVE' ? 'openai' : 'pennyrouter';
|
|
187
|
+
if (meta?.cwd && meta.cwd.toLowerCase() === this.cwd.toLowerCase() &&
|
|
188
|
+
meta.model_provider === expectedProvider &&
|
|
189
|
+
info.mtimeMs / 1000 >= this.started - 3) found.push([path, info.mtimeMs]);
|
|
190
|
+
} catch { /* incomplete or unrelated rollout */ }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return found;
|
|
194
|
+
};
|
|
195
|
+
const candidates = await visit(this.root);
|
|
196
|
+
candidates.sort((a, b) => b[1] - a[1]);
|
|
197
|
+
this.transcriptPath = candidates[0]?.[0] || null;
|
|
198
|
+
return this.transcriptPath;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async poll() {
|
|
202
|
+
const path = await this.findTranscript();
|
|
203
|
+
if (!path) return this.snapshot();
|
|
204
|
+
let size;
|
|
205
|
+
try { ({ size } = await stat(path)); } catch { return this.snapshot(); }
|
|
206
|
+
let offset = this.files.get(path) || 0;
|
|
207
|
+
if (size < offset) { offset = 0; this.previous = null; }
|
|
208
|
+
if (size === offset) return this.snapshot();
|
|
209
|
+
const handle = await open(path, 'r');
|
|
210
|
+
try {
|
|
211
|
+
const buffer = Buffer.alloc(size - offset);
|
|
212
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
213
|
+
const text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
214
|
+
const end = text.lastIndexOf('\n');
|
|
215
|
+
if (end < 0) return this.snapshot();
|
|
216
|
+
this.files.set(path, offset + Buffer.byteLength(text.slice(0, end + 1)));
|
|
217
|
+
for (const line of text.slice(0, end).split('\n')) {
|
|
218
|
+
let obj;
|
|
219
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
220
|
+
const payload = obj.payload;
|
|
221
|
+
if (obj.type === 'turn_context' && payload?.model) this.model = payload.model;
|
|
222
|
+
if (payload?.type !== 'token_count' || !payload.info?.total_token_usage) continue;
|
|
223
|
+
const total = payload.info.total_token_usage;
|
|
224
|
+
const fingerprint = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens',
|
|
225
|
+
'output_tokens', 'reasoning_output_tokens'].map((key) => Number(total[key]) || 0);
|
|
226
|
+
if (this.previous && fingerprint.every((value, index) => value === this.previous[index])) continue;
|
|
227
|
+
const delta = fingerprint.map((value, index) => Math.max(0, value - (this.previous?.[index] || 0)));
|
|
228
|
+
this.previous = fingerprint;
|
|
229
|
+
const [input, cached, write, output, reasoning] = delta;
|
|
230
|
+
const fresh = Math.max(0, input - cached - write);
|
|
231
|
+
const prices = [1.25e-6, .125e-6, 1.25e-6, 1.25e-6, 10e-6];
|
|
232
|
+
const cost = fresh * prices[0] + cached * prices[1] + write * prices[2] +
|
|
233
|
+
(output - reasoning) * prices[4] + reasoning * prices[4];
|
|
234
|
+
const call = { timestamp: obj.timestamp || null, recorded_at: Date.now() / 1000,
|
|
235
|
+
model: this.model, cache_read: cached, cache_write: write, write_5m: write,
|
|
236
|
+
write_1h: 0, fresh_input: fresh, output, reasoning_output: reasoning,
|
|
237
|
+
total: input + output, cost, context_tokens: input, pricing_profile: 'standard',
|
|
238
|
+
rates_per_million: { input: 1.25, cache_read: .125, cache_write_5m: 1.25,
|
|
239
|
+
cache_write_1h: 1.25, output: 10 } };
|
|
240
|
+
this.calls.push(call);
|
|
241
|
+
this.totals.cache_read += cached; this.totals.cache_write += write;
|
|
242
|
+
this.totals.write_5m += write; this.totals.fresh_input += fresh;
|
|
243
|
+
this.totals.output += output; this.totals.reasoning_output += reasoning;
|
|
244
|
+
this.totals.total += call.total; this.totals.cost += cost;
|
|
245
|
+
}
|
|
246
|
+
} finally { await handle.close(); }
|
|
247
|
+
return this.snapshot();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
snapshot() {
|
|
251
|
+
return { ...super.snapshot(), variant: this.side === 'left' ? 'codex' : 'penny-codex',
|
|
252
|
+
harness: 'codex', transcript_path: this.transcriptPath };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
156
256
|
export async function saveReport(left, right, reportsDir) {
|
|
157
|
-
const
|
|
158
|
-
const
|
|
257
|
+
const sides = [left.snapshot(), right.snapshot()];
|
|
258
|
+
const native = sides.find((side) => side.role === 'NATIVE') || sides[0];
|
|
259
|
+
const penny = sides.find((side) => side.role === 'PENNY') || sides[1];
|
|
159
260
|
const savings = {
|
|
160
261
|
cost: native.totals.cost - penny.totals.cost,
|
|
161
262
|
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) ||
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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',
|
|
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 ${
|
|
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:
|
|
242
|
+
cwd: config.cwd, env,
|
|
188
243
|
});
|
|
189
244
|
sessions.set(message.side, session);
|
|
190
245
|
if (nodeMeter) {
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
220
|
-
|
|
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
|
});
|
|
@@ -21,26 +21,92 @@
|
|
|
21
21
|
.pane { position: relative; }
|
|
22
22
|
.meter { display: none; position: absolute; top: 4px; right: 13px; z-index: 2;
|
|
23
23
|
width: 190px; white-space: pre; color: #9ded83; line-height: 1.15; font-size: 13px;
|
|
24
|
-
pointer-events: none;
|
|
24
|
+
pointer-events: none; padding: 5px 8px; border-radius: 3px;
|
|
25
|
+
background: rgba(12, 12, 16, .82); }
|
|
25
26
|
.pane.metered .meter { display: block; }
|
|
26
27
|
.delta { display: none; position: absolute; top: 4px; right: 220px; z-index: 2;
|
|
27
28
|
color: #9ded83; white-space: nowrap; pointer-events: none; }
|
|
28
29
|
.pane.metered .delta { display: block; }
|
|
29
30
|
.xterm { height: 100%; }
|
|
30
31
|
.xterm-viewport { scrollbar-width: thin; }
|
|
32
|
+
.finish { position: fixed; inset: 25px 0 0; z-index: 5; display: grid; place-items: center;
|
|
33
|
+
padding: 24px; background: rgba(10, 10, 14, .94); }
|
|
34
|
+
.finish[hidden] { display: none; }
|
|
35
|
+
.card { width: min(680px, 100%); border: 1px solid #4d4d52; background: #17171d;
|
|
36
|
+
padding: 28px; box-shadow: 0 16px 60px #0009; }
|
|
37
|
+
.card h1 { margin: 0 0 24px; color: #9ded83; font-size: 18px; letter-spacing: .08em; }
|
|
38
|
+
.metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px; }
|
|
39
|
+
.metric { padding: 14px; background: #202027; }
|
|
40
|
+
.metric span { display: block; color: #92929c; margin-bottom: 8px; }
|
|
41
|
+
.metric strong { font-size: 20px; }
|
|
42
|
+
.sides { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; color: #c4c4cc; white-space: pre-line; }
|
|
43
|
+
.report-path { margin-top: 24px; color: #9ded83; overflow-wrap: anywhere; }
|
|
44
|
+
.finish-note { margin-top: 20px; color: #92929c; }
|
|
45
|
+
.setup { position: fixed; inset: 25px 0 0; z-index: 4; display: grid; place-items: center;
|
|
46
|
+
overflow: auto; padding: 24px; background: rgba(10, 10, 14, .97); }
|
|
47
|
+
.setup[hidden] { display: none; }
|
|
48
|
+
.setup-card { width: min(760px, 100%); border: 1px solid #4d4d52; background: #17171d;
|
|
49
|
+
padding: 26px; }
|
|
50
|
+
.setup-card h1 { margin: 0 0 22px; color: #9ded83; font-size: 18px; }
|
|
51
|
+
.setup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
52
|
+
.setup-field { display: grid; gap: 7px; color: #a5a5ae; }
|
|
53
|
+
.setup-field input, .setup-field select { min-width: 0; padding: 9px; border: 1px solid #4d4d52;
|
|
54
|
+
background: #101015; color: #e5e5ea; font: inherit; }
|
|
55
|
+
.setup-model { margin-top: 16px; }
|
|
56
|
+
.setup-actions { display: flex; align-items: center; gap: 14px; margin-top: 22px; }
|
|
57
|
+
.setup-actions button { padding: 10px 16px; border: 1px solid #9ded83; background: #26362a;
|
|
58
|
+
color: #c8ffd0; font: inherit; cursor: pointer; }
|
|
59
|
+
.setup-actions button:disabled { opacity: .5; cursor: wait; }
|
|
60
|
+
#setup-error { color: #ff8b98; }
|
|
61
|
+
@media (max-width: 600px) { .setup-grid { grid-template-columns: 1fr; } }
|
|
31
62
|
@media (max-width: 720px) { .status { justify-content: flex-start; overflow-x: auto; padding: 0 8px; } }
|
|
32
63
|
</style>
|
|
33
64
|
</head>
|
|
34
65
|
<body>
|
|
35
66
|
<div class="status">
|
|
36
67
|
<button id="sync">SYNC ON ^⇧S</button>
|
|
37
|
-
<button id="exit">EXIT ^⇧X</button>
|
|
68
|
+
<button id="exit" disabled>EXIT ^⇧X</button>
|
|
38
69
|
<span id="connection" style="color:#77777e">connecting…</span>
|
|
39
70
|
</div>
|
|
40
71
|
<main class="panes">
|
|
41
72
|
<section class="pane focused"><div class="meter" id="left-meter"></div><div class="terminal" id="native"></div></section>
|
|
42
73
|
<section class="pane"><div class="delta" id="delta"></div><div class="meter" id="right-meter"></div><div class="terminal" id="penny"></div></section>
|
|
43
74
|
</main>
|
|
75
|
+
<section class="setup" id="setup">
|
|
76
|
+
<form class="setup-card" id="setup-form">
|
|
77
|
+
<h1>PENNY COMPARE</h1>
|
|
78
|
+
<div class="setup-grid">
|
|
79
|
+
<label class="setup-field">Left pane
|
|
80
|
+
<select id="left-variant"></select>
|
|
81
|
+
</label>
|
|
82
|
+
<label class="setup-field">Right pane
|
|
83
|
+
<select id="right-variant"></select>
|
|
84
|
+
</label>
|
|
85
|
+
<label class="setup-field">Left directory
|
|
86
|
+
<input id="left-cwd" autocomplete="off" required>
|
|
87
|
+
</label>
|
|
88
|
+
<label class="setup-field">Right directory
|
|
89
|
+
<input id="right-cwd" autocomplete="off" required>
|
|
90
|
+
</label>
|
|
91
|
+
<label class="setup-field">Left label <input id="left-label" placeholder="NATIVE or PENNY"></label>
|
|
92
|
+
<label class="setup-field">Right label <input id="right-label" placeholder="NATIVE or PENNY"></label>
|
|
93
|
+
</div>
|
|
94
|
+
<label class="setup-field setup-model">Model (both panes)
|
|
95
|
+
<input id="model" list="model-options" placeholder="default">
|
|
96
|
+
<datalist id="model-options"></datalist>
|
|
97
|
+
</label>
|
|
98
|
+
<div class="setup-actions"><button id="launch" type="submit" disabled>Start comparison</button><span id="setup-error"></span></div>
|
|
99
|
+
</form>
|
|
100
|
+
</section>
|
|
101
|
+
<section class="finish" id="finish" hidden aria-live="polite">
|
|
102
|
+
<div class="card">
|
|
103
|
+
<h1 id="finish-title">PENNY COMPARISON</h1>
|
|
104
|
+
<div class="metrics" id="finish-metrics"></div>
|
|
105
|
+
<div class="sides" id="finish-sides"></div>
|
|
106
|
+
<div class="report-path" id="finish-path"></div>
|
|
107
|
+
<div class="finish-note" id="finish-note">Comparison complete. You can close this tab.</div>
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
44
110
|
<script src="/xterm.js"></script>
|
|
45
111
|
<script src="/addon-fit.js"></script>
|
|
46
112
|
<script>
|
|
@@ -61,36 +127,59 @@
|
|
|
61
127
|
native.open(document.getElementById('native'));
|
|
62
128
|
penny.open(document.getElementById('penny'));
|
|
63
129
|
let sync = true;
|
|
130
|
+
let pendingConfig = null;
|
|
64
131
|
const panes = document.querySelectorAll('.pane');
|
|
65
132
|
const send = message => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); };
|
|
66
|
-
|
|
133
|
+
let socket;
|
|
67
134
|
const setFocus = index => { panes.forEach((p, i) => p.classList.toggle('focused', i === index)); (index ? penny : native).focus(); };
|
|
68
135
|
const setSync = value => {
|
|
69
136
|
sync = value;
|
|
70
137
|
document.getElementById('sync').textContent = sync ? 'SYNC ON ^⇧S' : 'sync off ^⇧S';
|
|
71
138
|
};
|
|
72
139
|
const exit = () => {
|
|
140
|
+
if (document.getElementById('exit').disabled) return;
|
|
73
141
|
send({ type: 'exit' });
|
|
74
142
|
document.getElementById('connection').textContent = 'saving report…';
|
|
75
143
|
document.getElementById('exit').disabled = true;
|
|
76
144
|
};
|
|
77
145
|
function resize() {
|
|
146
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
78
147
|
for (const [side, terminal] of [['left', native], ['right', penny]]) {
|
|
79
148
|
fit[side].fit();
|
|
80
149
|
send({ type: 'resize', side, cols: terminal.cols, rows: terminal.rows });
|
|
81
150
|
}
|
|
82
151
|
}
|
|
83
|
-
|
|
152
|
+
function connect() {
|
|
153
|
+
socket = new WebSocket(`ws://${location.host}/terminal`);
|
|
154
|
+
socket.addEventListener('open', () => {
|
|
84
155
|
document.getElementById('connection').textContent = 'live PTYs';
|
|
85
156
|
resize();
|
|
86
|
-
for (const [side, terminal] of [['left', native], ['right', penny]]) {
|
|
87
|
-
send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
|
|
88
|
-
}
|
|
89
157
|
setFocus(0);
|
|
90
|
-
|
|
158
|
+
document.getElementById('launch').disabled = false;
|
|
159
|
+
if (!isCompare) {
|
|
160
|
+
document.getElementById('setup').hidden = true;
|
|
161
|
+
document.getElementById('exit').disabled = false;
|
|
162
|
+
for (const [side, terminal] of [['left', native], ['right', penny]])
|
|
163
|
+
send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
91
166
|
socket.addEventListener('message', event => {
|
|
92
167
|
const message = JSON.parse(event.data);
|
|
93
168
|
const terminal = message.side === 'left' ? native : penny;
|
|
169
|
+
if (message.type === 'configured' && pendingConfig) {
|
|
170
|
+
for (const [side, terminal] of [['left', native], ['right', penny]])
|
|
171
|
+
send({ type: 'start', side, cols: terminal.cols, rows: terminal.rows });
|
|
172
|
+
pendingConfig = null;
|
|
173
|
+
document.getElementById('launch').disabled = false;
|
|
174
|
+
document.getElementById('exit').disabled = false;
|
|
175
|
+
document.getElementById('setup').hidden = true;
|
|
176
|
+
resize();
|
|
177
|
+
}
|
|
178
|
+
if (message.type === 'configuration-error') {
|
|
179
|
+
pendingConfig = null;
|
|
180
|
+
document.getElementById('launch').disabled = false;
|
|
181
|
+
document.getElementById('setup-error').textContent = message.error;
|
|
182
|
+
}
|
|
94
183
|
if (message.type === 'output') terminal.write(message.data);
|
|
95
184
|
if (message.type === 'exit') terminal.write(`\r\n[process exited: ${message.code}]\r\n`);
|
|
96
185
|
if (message.type === 'error') terminal.write(`\r\n[launch failed: ${message.error}]\r\n`);
|
|
@@ -102,7 +191,7 @@
|
|
|
102
191
|
const data = message.data;
|
|
103
192
|
const total = data.totals;
|
|
104
193
|
document.getElementById(`${data.side}-meter`).textContent =
|
|
105
|
-
`${data.role} ${(data.model || 'waiting').split('/').pop()}\n` +
|
|
194
|
+
`${data.label || data.role} ${(data.model || 'waiting').split('/').pop()}\n` +
|
|
106
195
|
`read ${total.cache_read.toLocaleString().padStart(11)}\n` +
|
|
107
196
|
`write ${total.cache_write.toLocaleString().padStart(11)}\n` +
|
|
108
197
|
`in ${total.fresh_input.toLocaleString().padStart(11)}\n` +
|
|
@@ -119,26 +208,88 @@
|
|
|
119
208
|
const metric = (amount, percent, unit) =>
|
|
120
209
|
`${unit}${unit ? Math.abs(amount).toFixed(2) : Math.abs(amount).toLocaleString()}` +
|
|
121
210
|
` ${amount >= 0 ? 'saved' : 'extra'} (${Math.abs(percent).toFixed(1)}%)`;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
211
|
+
const addMetric = (label, value) => {
|
|
212
|
+
const item = document.createElement('div'); item.className = 'metric';
|
|
213
|
+
const caption = document.createElement('span'); caption.textContent = label;
|
|
214
|
+
const result = document.createElement('strong'); result.textContent = value;
|
|
215
|
+
item.append(caption, result); document.getElementById('finish-metrics').append(item);
|
|
216
|
+
};
|
|
217
|
+
addMetric('COST', metric(savings.cost, savings.cost_percent, '$'));
|
|
218
|
+
addMetric('TOKENS', `${metric(savings.tokens, savings.token_percent, '')} tokens`);
|
|
219
|
+
const sideSummary = side => `${side.calls} calls · ${side.totals.total.toLocaleString()} tokens · $${side.totals.cost.toFixed(2)}`;
|
|
220
|
+
document.getElementById('finish-sides').textContent =
|
|
221
|
+
`NATIVE ${sideSummary(message.native)}\nPENNY ${sideSummary(message.penny)}`;
|
|
222
|
+
document.getElementById('finish-path').textContent = `Report saved: ${message.path}`;
|
|
223
|
+
document.getElementById('finish').hidden = false;
|
|
126
224
|
document.getElementById('connection').textContent = 'report saved';
|
|
127
225
|
}
|
|
128
226
|
if (message.type === 'report-error') {
|
|
129
|
-
|
|
227
|
+
document.getElementById('finish-title').textContent = 'REPORT COULD NOT BE SAVED';
|
|
228
|
+
document.getElementById('finish-metrics').textContent = message.error;
|
|
229
|
+
document.getElementById('finish-note').textContent = 'Comparison sessions have ended. Reload the page to start again.';
|
|
230
|
+
document.getElementById('finish').hidden = false;
|
|
130
231
|
document.getElementById('connection').textContent = 'report failed';
|
|
131
232
|
}
|
|
132
|
-
|
|
233
|
+
});
|
|
133
234
|
socket.addEventListener('close', () => {
|
|
134
235
|
if (!document.getElementById('exit').disabled) {
|
|
135
236
|
document.getElementById('connection').textContent = 'disconnected — reload to reconnect';
|
|
136
237
|
}
|
|
137
238
|
});
|
|
239
|
+
}
|
|
138
240
|
const resizeObserver = new ResizeObserver(resize);
|
|
139
241
|
panes.forEach(pane => resizeObserver.observe(pane));
|
|
140
242
|
document.getElementById('sync').onclick = () => setSync(!sync);
|
|
141
243
|
document.getElementById('exit').onclick = exit;
|
|
244
|
+
const cwd = __PENNY_CWD__;
|
|
245
|
+
const isCompare = __PENNY_COMPARE__;
|
|
246
|
+
const defaultHarness = __PENNY_DEFAULT_HARNESS__;
|
|
247
|
+
const variants = ['claude', 'penny-claude', 'codex', 'penny-codex'];
|
|
248
|
+
for (const side of ['left', 'right']) {
|
|
249
|
+
const select = document.getElementById(`${side}-variant`);
|
|
250
|
+
for (const variant of variants) {
|
|
251
|
+
const option = document.createElement('option'); option.value = variant; option.textContent = variant;
|
|
252
|
+
select.append(option);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
document.getElementById('left-variant').value = defaultHarness;
|
|
256
|
+
document.getElementById('right-variant').value = `penny-${defaultHarness}`;
|
|
257
|
+
document.getElementById('left-cwd').value = `${cwd}/native`;
|
|
258
|
+
document.getElementById('right-cwd').value = `${cwd}/penny`;
|
|
259
|
+
const refreshModels = () => {
|
|
260
|
+
const left = document.getElementById('left-variant').value;
|
|
261
|
+
const right = document.getElementById('right-variant').value;
|
|
262
|
+
const leftCodex = left.includes('codex');
|
|
263
|
+
const rightCodex = right.includes('codex');
|
|
264
|
+
const model = document.getElementById('model');
|
|
265
|
+
model.disabled = leftCodex !== rightCodex;
|
|
266
|
+
if (model.disabled) model.value = '';
|
|
267
|
+
const values = leftCodex && rightCodex
|
|
268
|
+
? ['default', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini']
|
|
269
|
+
: leftCodex !== rightCodex ? []
|
|
270
|
+
: ['default', 'opus', 'sonnet', 'haiku', 'fable'];
|
|
271
|
+
const list = document.getElementById('model-options'); list.replaceChildren();
|
|
272
|
+
for (const value of values) { const option = document.createElement('option'); option.value = value; list.append(option); }
|
|
273
|
+
};
|
|
274
|
+
document.getElementById('left-variant').onchange = refreshModels;
|
|
275
|
+
document.getElementById('right-variant').onchange = refreshModels;
|
|
276
|
+
refreshModels();
|
|
277
|
+
connect();
|
|
278
|
+
document.getElementById('setup-form').addEventListener('submit', event => {
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
const config = {};
|
|
281
|
+
for (const side of ['left', 'right']) config[side] = {
|
|
282
|
+
variant: document.getElementById(`${side}-variant`).value,
|
|
283
|
+
cwd: document.getElementById(`${side}-cwd`).value.trim(),
|
|
284
|
+
label: document.getElementById(`${side}-label`).value.trim(),
|
|
285
|
+
model: document.getElementById('model').value.trim(),
|
|
286
|
+
};
|
|
287
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
288
|
+
pendingConfig = config;
|
|
289
|
+
document.getElementById('launch').disabled = true;
|
|
290
|
+
document.getElementById('setup-error').textContent = '';
|
|
291
|
+
send({ type: 'configure', config });
|
|
292
|
+
});
|
|
142
293
|
document.addEventListener('keydown', event => {
|
|
143
294
|
if (!event.ctrlKey || !event.shiftKey || event.altKey || event.metaKey) return;
|
|
144
295
|
if (event.code === 'KeyS') { event.preventDefault(); event.stopPropagation(); setSync(!sync); }
|