pennyrouter 0.3.43 → 0.3.45

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.
@@ -0,0 +1,70 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ // Publishes a finished local comparison to the PennyRouter gallery for review. The CLI's saved
6
+ // Penny key authenticates the upload server to server, so it never reaches the browser.
7
+
8
+ export const appUrl = (process.env.PENNYROUTER_APP_URL || 'https://pennyrouter.com').replace(/\/+$/, '');
9
+ export const maxVideoBytes = 100 * 1024 * 1024;
10
+
11
+ // Mirrors pennyConfigPath() and activePennyProfile() in packages/pennyrouter-cli/src/launch.js.
12
+ function configPath() {
13
+ if (process.platform === 'win32') {
14
+ return join(process.env.APPDATA || process.env.LOCALAPPDATA || homedir(), 'PennyRouter', 'config.json');
15
+ }
16
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'pennyrouter', 'config.json');
17
+ }
18
+
19
+ export async function savedPennyKey() {
20
+ try {
21
+ const config = JSON.parse(await readFile(configPath(), 'utf8'));
22
+ return config.profiles?.[config.active_profile || 'cloud']?.api_key || null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /** Sends the report, the chosen pages, and an optional video; returns the new entry's id. A
29
+ * report signed by its demo sandbox (the subscription launcher) carries that signature. */
30
+ export async function publishToGallery(report, form, { signature = '', reportNonce = '' } = {}) {
31
+ const key = await savedPennyKey();
32
+ if (!key) throw new Error('Connect PennyRouter first: run `npx pennyrouter install`, then try again.');
33
+ const outgoing = new FormData();
34
+ outgoing.set('report', report);
35
+ outgoing.set('signature', signature);
36
+ outgoing.set('report_nonce', reportNonce);
37
+ outgoing.set('meta', String(form.get('meta') ?? ''));
38
+ for (const side of ['native', 'penny']) {
39
+ const page = form.get(side);
40
+ if (!(page instanceof Blob)) throw new Error('Choose a native page and a Penny page.');
41
+ outgoing.set(side, page, `${side}.html`);
42
+ }
43
+ const cover = form.get('cover');
44
+ if (cover instanceof Blob && cover.size) outgoing.set('cover', cover, 'cover');
45
+ const response = await fetch(`${appUrl}/api/gallery/submissions`, {
46
+ method: 'POST', headers: { authorization: `Bearer ${key}` }, body: outgoing,
47
+ signal: AbortSignal.timeout(60_000),
48
+ });
49
+ const body = await response.json().catch(() => ({}));
50
+ if (!response.ok) throw new Error(body.error || `The gallery did not accept the submission (${response.status}).`);
51
+
52
+ const video = form.get('video');
53
+ if (!body.videoUpload || !(video instanceof Blob) || !video.size) return { id: body.id };
54
+ try {
55
+ if (video.size > maxVideoBytes) throw new Error('too large');
56
+ const upload = await fetch(body.videoUpload.signedUrl, {
57
+ method: 'PUT', headers: { 'content-type': body.videoType }, body: video,
58
+ signal: AbortSignal.timeout(10 * 60_000),
59
+ });
60
+ if (!upload.ok) throw new Error(`upload ${upload.status}`);
61
+ const confirm = await fetch(`${appUrl}/api/gallery/submissions/${body.id}/video`, {
62
+ method: 'POST', headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
63
+ body: JSON.stringify({ video_type: body.videoType }), signal: AbortSignal.timeout(30_000),
64
+ });
65
+ if (!confirm.ok) throw new Error(`confirm ${confirm.status}`);
66
+ return { id: body.id };
67
+ } catch {
68
+ return { id: body.id, videoError: 'Your submission was saved, but its video could not be uploaded.' };
69
+ }
70
+ }
@@ -0,0 +1,58 @@
1
+ // Options a pane can carry beyond its harness, shown under its picker in the local setup card.
2
+ // Each option names the pane variants it applies to, the choices it offers, and how a choice
3
+ // reaches the harness. The setup page renders its controls from describePaneOptions().
4
+
5
+ const cacheTtl = {
6
+ id: 'cacheTtl',
7
+ label: 'Cache TTL',
8
+ variants: ['penny-claude'],
9
+ default: '5m',
10
+ choices: [
11
+ { value: '5m', label: '5 minutes (default)', summary: '5-minute cache' },
12
+ { value: '1h', label: '1 hour', summary: '1-hour cache' },
13
+ ],
14
+ hint: 'How long the conversation cache lives. The shared prefix is always 1 hour, and a session’s ' +
15
+ 'cold first write stays at 5 minutes.',
16
+ // Claude Code chooses the lifetime of its own cache breakpoints; PennyRouter passes a client's
17
+ // 1-hour request through once the session's cache is warm.
18
+ apply(env, value) {
19
+ delete env.ENABLE_PROMPT_CACHING_1H;
20
+ delete env.FORCE_PROMPT_CACHING_5M;
21
+ env[value === '1h' ? 'ENABLE_PROMPT_CACHING_1H' : 'FORCE_PROMPT_CACHING_5M'] = '1';
22
+ },
23
+ };
24
+
25
+ export const paneOptions = [cacheTtl];
26
+
27
+ const supportedBy = (variant) => paneOptions.filter((option) => option.variants.includes(variant));
28
+
29
+ /** A pane's options validated for its variant, with defaults filled in. */
30
+ export function normalizePaneOptions(variant, raw) {
31
+ const given = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
32
+ const supported = supportedBy(variant);
33
+ for (const id of Object.keys(given)) {
34
+ if (!supported.some((option) => option.id === id)) {
35
+ throw new Error('Invalid compare settings. That option does not apply to this pane.');
36
+ }
37
+ }
38
+ const options = {};
39
+ for (const option of supported) {
40
+ const value = given[option.id] ?? option.default;
41
+ if (!option.choices.some((choice) => choice.value === value)) {
42
+ throw new Error(`Invalid compare settings. Choose a listed ${option.label}.`);
43
+ }
44
+ options[option.id] = value;
45
+ }
46
+ return options;
47
+ }
48
+
49
+ /** Sets the environment a pane's harness reads for its chosen options. */
50
+ export function applyPaneOptions(env, variant, options) {
51
+ for (const option of supportedBy(variant)) option.apply(env, options?.[option.id] ?? option.default);
52
+ }
53
+
54
+ /** The JSON-safe description the setup page renders its controls from. */
55
+ export function describePaneOptions() {
56
+ return paneOptions.map(({ id, label, variants, default: initial, choices, hint }) =>
57
+ ({ id, label, variants, default: initial, choices, hint }));
58
+ }
@@ -1,15 +1,19 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
- import { spawn, execFile } from 'node:child_process';
3
+ import { mkdir, readFile, stat } from 'node:fs/promises';
4
+ import { execFile } from 'node:child_process';
5
5
  import { join, dirname, resolve } from 'node:path';
6
- import { homedir, tmpdir } from 'node:os';
6
+ import { homedir } 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, CodexMeter, saveReport } from './claude-meter.mjs';
11
+ import { ClaudeMeter, CodexMeter, galleryReport, pricingProfiles, saveReport } from './claude-meter.mjs';
12
+ import { publishToGallery } from './gallery-submit.mjs';
13
+ import { applyPaneOptions, describePaneOptions, normalizePaneOptions } from './pane-options.mjs';
12
14
  import { listArtifacts, readArtifact } from './artifacts.mjs';
15
+ import { readTelemetry, telemetryEnv, telemetryRoute } from './telemetry.mjs';
16
+ import { Readable } from 'node:stream';
13
17
 
14
18
  const here = dirname(fileURLToPath(import.meta.url));
15
19
  const compareDir = dirname(here);
@@ -18,16 +22,14 @@ const args = process.argv.slice(2);
18
22
  const claude = args.includes('--claude');
19
23
  const harnessArg = args.find((arg) => /^--harness=(claude|codex)$/.test(arg));
20
24
  const startupHarness = harnessArg?.slice('--harness='.length) || 'claude';
21
- const nodeMeter = args.includes('--compare-node') ||
22
- (args.includes('--compare') && process.platform === 'win32');
23
- const compare = args.includes('--compare') || nodeMeter;
24
- const pythonCompare = compare && !nodeMeter && process.platform !== 'win32';
25
+ // --compare-node is the earlier name for --compare, kept so existing commands still work.
26
+ const compare = args.includes('--compare') || args.includes('--compare-node');
25
27
  const portArg = args.find((arg) => /^--port=\d+$/.test(arg));
26
28
  const port = Number(portArg?.slice(7) ?? 8789);
27
29
  if (args.some((arg) => !['--claude', '--compare', '--compare-node', '--harness=claude', '--harness=codex', portArg].includes(arg)) ||
28
30
  (claude && compare) || (harnessArg && !compare) ||
29
31
  (args.includes('--harness=claude') && args.includes('--harness=codex')) || port < 1 || port > 65535) {
30
- console.error('Usage: npm start -- [--claude | --compare [--harness=claude|codex] | --compare-node [--harness=claude|codex]] [--port=8789]');
32
+ console.error('Usage: npm start -- [--claude | --compare [--harness=claude|codex]] [--port=8789]');
31
33
  process.exit(1);
32
34
  }
33
35
 
@@ -35,15 +37,46 @@ const files = new Map([
35
37
  ['/demo-artifact-preview', ['artifact-preview.html', 'text/html; charset=utf-8']],
36
38
  ['/terminal-unicode.js', ['terminal-unicode.js', 'text/javascript; charset=utf-8']],
37
39
  ['/addon-unicode11.js', ['node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js', 'text/javascript; charset=utf-8']],
38
- ['/', ['terminal-preview.html', 'text/html; charset=utf-8']],
40
+ ['/', ['compare.html', 'text/html; charset=utf-8']],
39
41
  ['/xterm.css', ['node_modules/@xterm/xterm/css/xterm.css', 'text/css; charset=utf-8']],
40
42
  ['/xterm.js', ['node_modules/@xterm/xterm/lib/xterm.js', 'text/javascript; charset=utf-8']],
41
43
  ['/addon-fit.js', ['node_modules/@xterm/addon-fit/lib/addon-fit.js', 'text/javascript; charset=utf-8']],
42
44
  ]);
43
45
  const artifactSessions = new Map();
46
+ const telemetryMeters = new Map();
47
+ // Finished comparisons that may still be published, by one-time publish token.
48
+ const publishable = new Map();
49
+ const publishWindowMs = 60 * 60_000;
50
+ // `npm start` runs from this package's folder; INIT_CWD is where it was invoked.
51
+ const launchDirectory = process.env.INIT_CWD || process.cwd();
52
+
53
+ // Shown in the page header, as the hosted demo shows its checkpoint's versions.
54
+ async function harnessVersion(command) {
55
+ try {
56
+ const { stdout } = await execFileAsync(command, ['--version'],
57
+ { timeout: 5000, shell: process.platform === 'win32', windowsHide: true });
58
+ return stdout.match(/\d+\.\d+\.\d+/)?.[0] || null;
59
+ } catch { return null; }
60
+ }
61
+ const harnessVersions = Promise.all([harnessVersion('claude'), harnessVersion('codex')])
62
+ .then(([claudeVersion, codexVersion]) => ({ claude: claudeVersion, codex: codexVersion }));
44
63
 
45
64
  const server = createServer(async (request, response) => {
46
65
  const url = new URL(request.url, 'http://localhost');
66
+ if (url.pathname === '/gallery/submit') {
67
+ await submitToGallery(request, response);
68
+ return;
69
+ }
70
+ const telemetryMeter = telemetryMeters.get(url.pathname);
71
+ if (telemetryMeter && request.method === 'POST') {
72
+ try {
73
+ telemetryMeter.ingestTelemetry(await readTelemetry(request));
74
+ response.writeHead(200, { 'Content-Type': 'application/json' }).end('{}');
75
+ } catch {
76
+ response.writeHead(400).end();
77
+ }
78
+ return;
79
+ }
47
80
  if (['/artifacts', '/artifact'].includes(url.pathname)) {
48
81
  const roots = artifactSessions.get(request.headers.authorization?.replace(/^Bearer /, ''));
49
82
  const root = roots?.get(url.searchParams.get('side'));
@@ -64,10 +97,11 @@ const server = createServer(async (request, response) => {
64
97
  try {
65
98
  let body = await readFile(join(here, file[0]));
66
99
  if (request.url === '/') {
100
+ const config = { mode: 'local', compare, defaultHarness: startupHarness, paneOptions: describePaneOptions(),
101
+ folders: { left: join(launchDirectory, 'native'), right: join(launchDirectory, 'penny') },
102
+ versions: await harnessVersions };
67
103
  body = body.toString('utf8')
68
- .replace('__PENNY_CWD__', JSON.stringify(process.cwd()).replaceAll('<', '\\u003c'))
69
- .replace('__PENNY_COMPARE__', JSON.stringify(compare))
70
- .replace('__PENNY_DEFAULT_HARNESS__', JSON.stringify(startupHarness));
104
+ .replace('__COMPARE_CONFIG__', JSON.stringify(config).replaceAll('<', '\\u003c'));
71
105
  }
72
106
  response.writeHead(200, { 'Content-Type': file[1], 'Cache-Control': 'no-store' }).end(body);
73
107
  } catch (error) {
@@ -76,6 +110,32 @@ const server = createServer(async (request, response) => {
76
110
  }
77
111
  });
78
112
 
113
+ async function submitToGallery(request, response) {
114
+ const reply = (status, body) => response.writeHead(status, { 'Content-Type': 'application/json',
115
+ 'Cache-Control': 'no-store' }).end(JSON.stringify(body));
116
+ const allowedOrigins = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
117
+ const token = request.headers.authorization?.replace(/^Bearer /, '');
118
+ const report = publishable.get(token);
119
+ if (request.method !== 'POST' || !allowedOrigins.has(request.headers.origin) || !report) {
120
+ reply(403, { error: 'This comparison can no longer be published. Finish a new one to publish it.' });
121
+ return;
122
+ }
123
+ if (Number(request.headers['content-length'] || 0) > 120 * 1024 * 1024) {
124
+ reply(413, { error: 'The submission is too large.' });
125
+ return;
126
+ }
127
+ try {
128
+ const form = await new Request('http://localhost/gallery/submit', { method: 'POST',
129
+ headers: { 'content-type': request.headers['content-type'] || '' },
130
+ body: Readable.toWeb(request), duplex: 'half' }).formData();
131
+ const result = await publishToGallery(report, form);
132
+ publishable.delete(token);
133
+ reply(201, result);
134
+ } catch (error) {
135
+ reply(400, { error: error.message || 'Could not publish to the gallery.' });
136
+ }
137
+ }
138
+
79
139
  const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
80
140
  server.on('upgrade', (request, socket, head) => {
81
141
  const allowedOrigins = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
@@ -87,7 +147,26 @@ server.on('upgrade', (request, socket, head) => {
87
147
  wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws));
88
148
  });
89
149
 
150
+ // Launched from inside a Claude Code session (an integrated terminal or a `!` command), this
151
+ // process inherits that session's identity. A pane that keeps it attaches to the parent
152
+ // session and writes no transcript of its own, which leaves its meter empty.
153
+ const inheritedSessionKeys = /^(CLAUDECODE|CLAUDE_PID|CLAUDE_EFFORT|CLAUDE_CODE_(SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|BRIDGE_SESSION_ID|MESSAGING_SOCKET|MESSAGING_TOKEN|ENTRYPOINT|EXECPATH))$/;
154
+ function paneEnvironment() {
155
+ return Object.fromEntries(Object.entries(process.env).filter(([key]) => !inheritedSessionKeys.test(key)));
156
+ }
157
+
90
158
  const variants = new Set(['claude', 'penny-claude', 'codex', 'penny-codex']);
159
+ const providers = ['native', 'bedrock', 'foundry', 'vertex'];
160
+ const proxies = ['none', 'litellm', 'bifrost', 'kong', 'portkey', 'other'];
161
+
162
+ // Pricing changes how both panes are costed. Provider and proxy are recorded with the report.
163
+ function validateEnvironment(environment = {}) {
164
+ const { pricing = 'standard', provider = 'native', proxy = 'none' } = environment;
165
+ if (!pricingProfiles.includes(pricing) || !providers.includes(provider) || !proxies.includes(proxy)) {
166
+ throw new Error('Invalid environment. Choose a listed pricing schedule, provider, and proxy.');
167
+ }
168
+ return { pricing, provider, proxy };
169
+ }
91
170
  const safeModel = /^[A-Za-z0-9._/-]{1,160}$/;
92
171
 
93
172
  async function validateConfig(config) {
@@ -101,22 +180,32 @@ async function validateConfig(config) {
101
180
  !claudeModels.includes(config.model) && !config.model.startsWith('claude-'))) {
102
181
  throw new Error('Invalid compare settings. Choose Claude or Codex, a directory, and a valid model name.');
103
182
  }
104
- const expanded = config.cwd.startsWith('~/') ? join(homedir(), config.cwd.slice(2)) : config.cwd;
183
+ const expanded = /^~[\\/]/.test(config.cwd) ? join(homedir(), config.cwd.slice(2)) : config.cwd;
105
184
  const cwd = resolve(expanded);
106
- if (!(await stat(cwd)).isDirectory()) throw new Error(`Directory does not exist: ${cwd}`);
107
- return { ...config, cwd };
185
+ const existing = await stat(cwd).catch(() => null);
186
+ if (existing && !existing.isDirectory()) throw new Error(`Not a folder: ${cwd}`);
187
+ if (!existing && !(await stat(dirname(cwd)).catch(() => null))?.isDirectory()) {
188
+ throw new Error(`Folder does not exist: ${dirname(cwd)}`);
189
+ }
190
+ return { ...config, cwd, create: !existing, options: normalizePaneOptions(config.variant, config.options) };
108
191
  }
109
192
 
110
- function launchCommand(side, state, sessionId, config) {
111
- if (pythonCompare) {
112
- const args = [join(compareDir, 'pane.py'), config.variant, '--side', side,
113
- '--cwd', config.cwd, '--pricing-profile', 'standard'];
114
- if (config.model) args.push('--model', config.model);
115
- if (config.label) args.push('--label', config.label);
116
- if (side === 'right') args.push('--delta-file', join(state, 'delta.txt'));
117
- return ['python3', args];
193
+ // Each pane needs its own folder: agents running side by side in one folder would edit the
194
+ // same files and read each other's work. A missing last folder is created; its parent must exist.
195
+ async function preparePaneFolders(left, right) {
196
+ if (left.cwd === right.cwd) {
197
+ throw new Error('Choose a different folder for each pane, so the two agents do not edit the same files.');
118
198
  }
119
- if (nodeMeter) {
199
+ for (const config of [left, right]) if (config.create) await mkdir(config.cwd);
200
+ }
201
+
202
+ // A harness pane runs one program, never an interactive shell, so echo can be off from the
203
+ // start. Otherwise terminal replies (focus reports, device attributes) that arrive before the
204
+ // harness enters raw mode are printed as text such as ^[[I.
205
+ const quiet = 'stty -echo 2>/dev/null; ';
206
+
207
+ function launchCommand(sessionId, config) {
208
+ if (compare) {
120
209
  const codex = config.variant.includes('codex');
121
210
  const command = config.variant.startsWith('penny-')
122
211
  ? `penny ${codex ? 'codex' : 'claude'}` : (codex ? 'codex' : 'claude');
@@ -126,14 +215,14 @@ function launchCommand(side, state, sessionId, config) {
126
215
  return [shell, ['/d', '/s', '/c', `${command}${codex ? '' : ` --session-id ${sessionId}`}${modelArg}`]];
127
216
  }
128
217
  const shell = process.env.SHELL || '/bin/sh';
129
- return [shell, ['-lc', `exec ${command}${codex ? '' : ` --session-id ${sessionId}`}${modelArg}`]];
218
+ return [shell, ['-lc', `${quiet}exec ${command}${codex ? '' : ` --session-id ${sessionId}`}${modelArg}`]];
130
219
  }
131
220
  if (process.platform === 'win32') {
132
221
  const shell = process.env.ComSpec || 'cmd.exe';
133
222
  return claude ? [shell, ['/d', '/s', '/c', 'claude']] : [shell, []];
134
223
  }
135
224
  const shell = process.env.SHELL || '/bin/sh';
136
- return claude ? [shell, ['-lc', 'exec claude']] : [shell, []];
225
+ return claude ? [shell, ['-lc', `${quiet}exec claude`]] : [shell, []];
137
226
  }
138
227
 
139
228
  wss.on('connection', (ws) => {
@@ -142,19 +231,14 @@ wss.on('connection', (ws) => {
142
231
  artifactSessions.set(artifactToken, artifactRoots);
143
232
  const sessions = new Map();
144
233
  const compareConfig = new Map();
234
+ const telemetryRoutesBySide = new Map();
235
+ let environment = validateEnvironment();
145
236
  const meters = new Map();
146
237
  const snapshots = new Map();
147
- let state;
148
- let delta;
149
238
  let meterTimer;
150
239
  let polling = false;
151
240
  let closed = false;
152
241
  let exiting = false;
153
- const ready = pythonCompare ? mkdtemp(join(tmpdir(), 'penny-compare-web-')).then(async (dir) => {
154
- state = dir;
155
- await writeFile(join(dir, 'started'), '');
156
- await writeFile(join(dir, 'delta.txt'), '');
157
- }) : Promise.resolve();
158
242
  const send = (message) => {
159
243
  if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(message));
160
244
  };
@@ -167,14 +251,6 @@ wss.on('connection', (ws) => {
167
251
  snapshots.set(meter.side, snapshot);
168
252
  send({ type: 'meter', data: snapshot });
169
253
  }
170
- const left = snapshots.get('left');
171
- const right = snapshots.get('right');
172
- if (left && right) {
173
- const base = left.totals;
174
- const current = right.totals;
175
- send({ type: 'delta', cost: base.cost ? (current.cost - base.cost) / base.cost * 100 : 0,
176
- tokens: base.total ? (current.total - base.total) / base.total * 100 : 0 });
177
- }
178
254
  } catch (error) {
179
255
  send({ type: 'error', side: 'left', error: `Meter: ${error.message}` });
180
256
  } finally { polling = false; }
@@ -183,13 +259,12 @@ wss.on('connection', (ws) => {
183
259
  if (closed) return;
184
260
  closed = true;
185
261
  artifactSessions.delete(artifactToken);
262
+ for (const route of telemetryRoutesBySide.values()) telemetryMeters.delete(route);
186
263
  for (const session of sessions.values()) {
187
264
  try { session.kill(); } catch { /* already exited */ }
188
265
  }
189
266
  sessions.clear();
190
267
  if (meterTimer) clearInterval(meterTimer);
191
- if (delta) delta.kill();
192
- if (state) void rm(state, { recursive: true, force: true });
193
268
  };
194
269
  const handle = async (raw) => {
195
270
  let message;
@@ -199,9 +274,11 @@ wss.on('connection', (ws) => {
199
274
  if (!compare) throw new Error('Compare setup is unavailable in shell demo mode.');
200
275
  const left = await validateConfig(message.config?.left);
201
276
  const right = await validateConfig(message.config?.right);
277
+ environment = validateEnvironment(message.environment);
202
278
  if (left.model && left.variant.includes('codex') !== right.variant.includes('codex')) {
203
279
  throw new Error('Choose the same model family for both panes, or leave Model set to default.');
204
280
  }
281
+ await preparePaneFolders(left, right);
205
282
  compareConfig.set('left', left);
206
283
  compareConfig.set('right', right);
207
284
  artifactRoots.set('left', left.cwd);
@@ -211,6 +288,7 @@ wss.on('connection', (ws) => {
211
288
  } catch (error) {
212
289
  compareConfig.clear();
213
290
  artifactRoots.clear();
291
+ environment = validateEnvironment();
214
292
  send({ type: 'configuration-error', error: error.message });
215
293
  }
216
294
  return;
@@ -218,21 +296,23 @@ wss.on('connection', (ws) => {
218
296
  if (message.type === 'exit') {
219
297
  if (exiting) return;
220
298
  exiting = true;
221
- if (pythonCompare && state) {
222
- try {
223
- const { stdout } = await execFileAsync('python3', [join(compareDir, 'report.py'),
224
- state, join(compareDir, 'reports'), '--json']);
225
- send({ type: 'report', ...JSON.parse(stdout) });
226
- } catch (error) {
227
- send({ type: 'report-error', error: error.message });
228
- }
229
- } else if (nodeMeter) {
299
+ if (compare) {
230
300
  try {
301
+ // Telemetry is exported every second; let the last batch land before closing the books.
302
+ if (telemetryRoutesBySide.size) await new Promise((done) => setTimeout(done, 1500));
231
303
  await pollMeters();
304
+ for (const meter of meters.values()) meter.chargeSideCalls({ final: true });
232
305
  const left = meters.get('left');
233
306
  const right = meters.get('right');
234
307
  if (!left || !right) throw new Error('Both compare panes must start before a report can be saved.');
235
- send({ type: 'report', ...await saveReport(left, right, join(compareDir, 'reports')) });
308
+ const report = galleryReport(left, right, { environment, harnessVersions: await harnessVersions });
309
+ const publishToken = report ? randomUUID() : null;
310
+ if (publishToken) {
311
+ publishable.set(publishToken, report);
312
+ setTimeout(() => publishable.delete(publishToken), publishWindowMs).unref();
313
+ }
314
+ send({ type: 'report', ...await saveReport(left, right, join(compareDir, 'reports'), environment),
315
+ gallery: publishToken ? { publishToken } : null });
236
316
  } catch (error) {
237
317
  send({ type: 'report-error', error: error.message });
238
318
  }
@@ -248,24 +328,33 @@ wss.on('connection', (ws) => {
248
328
  try {
249
329
  const config = compare
250
330
  ? compareConfig.get(message.side)
251
- : { variant: 'claude', cwd: process.cwd(), label: '', model: '' };
331
+ : { variant: 'claude', cwd: launchDirectory, label: '', model: '' };
252
332
  if (!config) throw new Error('Choose and validate both compare panes before starting.');
253
- await ready;
254
333
  if (closed) return;
255
- if (pythonCompare && !delta) {
256
- delta = spawn('python3', [join(compareDir, 'delta.py'), state, join(state, 'delta.txt')],
257
- { stdio: 'ignore' });
258
- delta.on('error', (error) => send({ type: 'error', side: 'right', error: error.message }));
334
+ const sessionId = compare && !config.variant.includes('codex') ? randomUUID() : undefined;
335
+ const [command, commandArgs] = launchCommand(sessionId, config);
336
+ let meter;
337
+ if (compare) {
338
+ const Meter = config.variant.includes('codex') ? CodexMeter : ClaudeMeter;
339
+ const role = config.variant.startsWith('penny-') ? 'PENNY' : 'NATIVE';
340
+ meter = new Meter({ sessionId, cwd: config.cwd, side: message.side, role,
341
+ label: config.label, variant: config.variant, pricingProfile: environment.pricing,
342
+ options: config.options });
259
343
  }
260
- const sessionId = nodeMeter && !config.variant.includes('codex') ? randomUUID() : undefined;
261
- const [command, commandArgs] = launchCommand(message.side, state, sessionId, config);
262
- const env = { ...process.env, TERM: 'xterm-256color',
263
- ...(pythonCompare ? { PENNY_COMPARE_STATE: state } : {}) };
344
+ // Claude panes report background model calls to this server; see telemetry.mjs.
345
+ const route = meter instanceof CodexMeter || !meter ? null : telemetryRoute();
346
+ if (route) {
347
+ telemetryMeters.set(route, meter);
348
+ telemetryRoutesBySide.set(message.side, route);
349
+ }
350
+ const env = { ...paneEnvironment(), TERM: 'xterm-256color',
351
+ ...(route ? telemetryEnv(`http://127.0.0.1:${port}`, route) : {}) };
264
352
  if (compare && message.side === 'left') {
265
353
  for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']) {
266
354
  delete env[key];
267
355
  }
268
356
  }
357
+ applyPaneOptions(env, config.variant, config.options);
269
358
  const session = pty.spawn(command, commandArgs, {
270
359
  name: 'xterm-256color', cols: message.cols, rows: message.rows,
271
360
  cwd: config.cwd, env,
@@ -273,12 +362,8 @@ wss.on('connection', (ws) => {
273
362
  sessions.set(message.side, session);
274
363
  artifactRoots.set(message.side, config.cwd);
275
364
  if (!compare) send({ type: 'artifact-session', token: artifactToken });
276
- if (nodeMeter) {
277
- const Meter = config.variant.includes('codex') ? CodexMeter : ClaudeMeter;
278
- const role = config.variant.startsWith('penny-') ? 'PENNY' : 'NATIVE';
279
- meters.set(message.side, new Meter({ sessionId, cwd: config.cwd,
280
- side: message.side, role, label: config.label, variant: config.variant }));
281
- send({ type: 'meter-mode' });
365
+ if (meter) {
366
+ meters.set(message.side, meter);
282
367
  if (!meterTimer) meterTimer = setInterval(() => { void pollMeters(); }, 1000);
283
368
  }
284
369
  session.onData((data) => send({ type: 'output', side: message.side, data }));
@@ -305,16 +390,14 @@ server.listen(port, '127.0.0.1', () => {
305
390
  const url = `http://127.0.0.1:${port}`;
306
391
  console.log(`Penny Compare live terminal demo: ${url}`);
307
392
  console.log(compare
308
- ? `Compare setup ready (${pythonCompare ? 'Python session metering' : 'Node transcript metering'}). Choose both panes in the browser.`
393
+ ? 'Compare setup ready. Choose both panes in the browser.'
309
394
  : claude ? 'Each pane starts Claude Code.' : 'Each pane starts a shell. Type claude to run Claude Code.');
310
- if (process.platform === 'win32') {
311
- execFile(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`],
312
- { windowsHide: true }, (error) => {
313
- if (error) console.error(`Could not open the browser automatically: ${error.message}`);
314
- });
315
- } else {
316
- execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], (error) => {
317
- if (error) console.error(`Could not open the browser automatically: ${error.message}`);
318
- });
319
- }
395
+ // Windows hands the URL to the default browser without cmd, whose quoting rules
396
+ // mangle `start "" "<url>"` once Node quotes it as a single argument.
397
+ const [opener, openerArgs] = process.platform === 'win32'
398
+ ? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]]
399
+ : [process.platform === 'darwin' ? 'open' : 'xdg-open', [url]];
400
+ execFile(opener, openerArgs, { windowsHide: true }, (error) => {
401
+ if (error) console.error(`Could not open the browser automatically: ${error.message}`);
402
+ });
320
403
  });
@@ -0,0 +1,71 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ // Claude Code reports every model request it makes as an OpenTelemetry `api_request` log
4
+ // event, including background calls (such as the auto-mode reviewer) that never reach the
5
+ // session transcript. Each pane exports those events only to its own path on the Compare
6
+ // server; nothing is sent anywhere else, and prompts stay redacted (Claude Code's default).
7
+
8
+ export const maxTelemetryBytes = 1024 * 1024;
9
+
10
+ // One unguessable path per pane, so a request identifies its pane and outsiders cannot post.
11
+ export function telemetryRoute() {
12
+ return `/otlp/${randomBytes(24).toString('base64url')}/v1/logs`;
13
+ }
14
+
15
+ export function telemetryEnv(origin, route) {
16
+ return {
17
+ CLAUDE_CODE_ENABLE_TELEMETRY: '1',
18
+ OTEL_LOGS_EXPORTER: 'otlp',
19
+ OTEL_METRICS_EXPORTER: 'none',
20
+ OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: 'http/json',
21
+ OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: `${origin}${route}`,
22
+ OTEL_LOGS_EXPORT_INTERVAL: '1000',
23
+ };
24
+ }
25
+
26
+ const value = (attribute) => {
27
+ const raw = attribute?.value || {};
28
+ return raw.stringValue ?? raw.intValue ?? raw.doubleValue ?? raw.boolValue;
29
+ };
30
+ const count = (raw) => Math.max(0, Math.floor(Number(raw) || 0));
31
+
32
+ // The api_request events in one OTLP/HTTP JSON logs export.
33
+ export function apiRequests(body) {
34
+ const requests = [];
35
+ for (const resource of body?.resourceLogs || []) {
36
+ for (const scope of resource?.scopeLogs || []) {
37
+ for (const record of scope?.logRecords || []) {
38
+ const attributes = Object.fromEntries((record?.attributes || []).map((item) => [item?.key, value(item)]));
39
+ if (attributes['event.name'] !== 'api_request') continue;
40
+ // Responses relayed by a gateway can lack Anthropic's request id; the meter then
41
+ // matches the event to the transcript by its usage instead (see claude-meter.mjs).
42
+ const requestId = attributes.request_id ? String(attributes.request_id) : null;
43
+ const key = requestId || `sequence:${attributes['event.sequence'] ?? ''}:${attributes['event.timestamp'] ?? ''}`;
44
+ requests.push({
45
+ key,
46
+ requestId,
47
+ model: String(attributes.model || ''),
48
+ querySource: String(attributes.query_source || ''),
49
+ timestamp: attributes['event.timestamp'] || null,
50
+ fresh_input: count(attributes.input_tokens),
51
+ output: count(attributes.output_tokens),
52
+ cache_read: count(attributes.cache_read_tokens),
53
+ cache_write: count(attributes.cache_creation_tokens),
54
+ });
55
+ }
56
+ }
57
+ }
58
+ return requests;
59
+ }
60
+
61
+ // Reads an OTLP/HTTP JSON export from a request, bounded by maxTelemetryBytes.
62
+ export async function readTelemetry(request) {
63
+ const chunks = [];
64
+ let size = 0;
65
+ for await (const chunk of request) {
66
+ size += chunk.length;
67
+ if (size > maxTelemetryBytes) throw new Error('Telemetry export too large');
68
+ chunks.push(chunk);
69
+ }
70
+ return apiRequests(JSON.parse(Buffer.concat(chunks).toString('utf8')));
71
+ }