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.
- package/package.json +1 -1
- package/src/launch.js +20 -0
- package/src/launch.test.js +16 -1
- package/standalone/govcloud-rates.json +106 -0
- package/standalone/pricing.py +9 -31
- package/standalone/usage.py +7 -0
- package/standalone/web-demo/README.md +4 -4
- package/standalone/web-demo/claude-meter.mjs +222 -54
- package/standalone/web-demo/compare.html +1402 -0
- package/standalone/web-demo/gallery-submit.mjs +70 -0
- package/standalone/web-demo/pane-options.mjs +58 -0
- package/standalone/web-demo/server.mjs +164 -81
- package/standalone/web-demo/telemetry.mjs +71 -0
- package/standalone/web-demo/terminal-preview.html +0 -384
|
@@ -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 {
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
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]
|
|
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
|
-
['/', ['
|
|
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('
|
|
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
|
|
183
|
+
const expanded = /^~[\\/]/.test(config.cwd) ? join(homedir(), config.cwd.slice(2)) : config.cwd;
|
|
105
184
|
const cwd = resolve(expanded);
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 (
|
|
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',
|
|
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',
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
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 (
|
|
277
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
|
|
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
|
+
}
|