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.
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/cli.js +7 -7
- package/src/launch.js +32 -8
- package/src/launch.test.js +2 -0
- package/standalone/penny-compare +13 -2
- package/standalone/pricing.py +27 -17
- package/standalone/rates.json +1078 -0
- package/standalone/web-demo/README.md +3 -3
- package/standalone/web-demo/claude-meter.mjs +141 -22
- package/standalone/web-demo/server.mjs +84 -22
- package/standalone/web-demo/terminal-preview.html +166 -15
|
@@ -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.
|
|
@@ -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
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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 */ }
|
|
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.
|
|
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
|
|
158
|
-
const
|
|
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) ||
|
|
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
|
});
|