pennyrouter 0.3.44 → 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.test.js +1 -1
- package/standalone/usage.py +7 -0
- package/standalone/web-demo/README.md +2 -0
- package/standalone/web-demo/claude-meter.mjs +23 -3
- package/standalone/web-demo/compare.html +117 -143
- package/standalone/web-demo/pane-options.mjs +58 -0
- package/standalone/web-demo/server.mjs +6 -3
package/package.json
CHANGED
package/src/launch.test.js
CHANGED
|
@@ -107,7 +107,7 @@ async function main() {
|
|
|
107
107
|
assert.equal(second.changed, false);
|
|
108
108
|
assert.equal(await fileMode(pennyShimPath()), 0o755);
|
|
109
109
|
assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
|
|
110
|
-
for (const asset of ["terminal-unicode.js", "artifacts.mjs", "artifact-preview.html", "telemetry.mjs", "gallery-submit.mjs", "compare.html",
|
|
110
|
+
for (const asset of ["terminal-unicode.js", "artifacts.mjs", "artifact-preview.html", "telemetry.mjs", "gallery-submit.mjs", "pane-options.mjs", "compare.html",
|
|
111
111
|
"node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js"]) {
|
|
112
112
|
assert.ok(fs.existsSync(path.join(pennyCompareDir(), "web-demo", asset)), `compare installs ${asset}`);
|
|
113
113
|
}
|
package/standalone/usage.py
CHANGED
|
@@ -214,6 +214,13 @@ class UsageReader:
|
|
|
214
214
|
return
|
|
215
215
|
state.counted.add(mid)
|
|
216
216
|
|
|
217
|
+
# A reply the gateway answered itself carries an estimate of the whole context as fresh
|
|
218
|
+
# input, and nothing was billed for it. The gateway marks these replies with a
|
|
219
|
+
# `penny_local_` request id and a `msg_swallow_` message id.
|
|
220
|
+
if (str(obj.get("requestId") or "").startswith("penny_local_")
|
|
221
|
+
or str(mid or "").startswith("msg_swallow_")):
|
|
222
|
+
return
|
|
223
|
+
|
|
217
224
|
read = int(usage.get("cache_read_input_tokens") or 0)
|
|
218
225
|
write = int(usage.get("cache_creation_input_tokens") or 0)
|
|
219
226
|
creation = usage.get("cache_creation") or {}
|
|
@@ -23,6 +23,8 @@ npm start -- --compare
|
|
|
23
23
|
|
|
24
24
|
This opens a setup screen with the Claude/Codex pane choices, folders, labels, shared model, and environment (provider, proxy, pricing). The default is native Claude on the left and Penny Claude on the right, using `native` and `penny` folders under the directory `npm start` was run from; missing folders are created. Each pane's meter reads Claude or Codex transcripts and prices calls in Node on every platform, with no Python or tmux requirement. Claude panes also report background model calls (such as the auto-mode reviewer) to this server over local OpenTelemetry, which the meter adds as a "side" line. 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
|
+
A Penny Claude pane has an **Options** section under its picker, set separately for each side. **Cache TTL** chooses 5 minutes (the default) or 1 hour for the conversation cache by setting Claude Code's `FORCE_PROMPT_CACHING_5M` or `ENABLE_PROMPT_CACHING_1H` for that pane; the shared prefix is always 1 hour. The choice is shown in the pane header and saved in the report. Options are defined in `pane-options.mjs`.
|
|
27
|
+
|
|
26
28
|
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. `--compare-node` is accepted as an older name for `--compare`.
|
|
27
29
|
|
|
28
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.
|
|
@@ -61,6 +61,13 @@ async function readFirstLine(path, limit = 1024 * 1024) {
|
|
|
61
61
|
// as a background call only once its request id has stayed out of the transcript this long.
|
|
62
62
|
const sideCallGraceMs = 5000;
|
|
63
63
|
|
|
64
|
+
// A reply the gateway answered itself, with no model call, reports an estimate of the whole
|
|
65
|
+
// context as fresh input so Claude Code's context gauge stays honest. Nothing was billed for
|
|
66
|
+
// it. The gateway marks these replies with a `penny_local_` request id (which Claude Code keeps
|
|
67
|
+
// in its transcript and telemetry) and a `msg_swallow_` message id.
|
|
68
|
+
const isLocalReply = (requestId, messageId) =>
|
|
69
|
+
String(requestId || '').startsWith('penny_local_') || String(messageId || '').startsWith('msg_swallow_');
|
|
70
|
+
|
|
64
71
|
// Without a request id (a gateway may not relay Anthropic's), a telemetry event and its
|
|
65
72
|
// transcript line still carry the same usage, so that usage identifies the call.
|
|
66
73
|
const usagePrint = (model, input, read, write, output) =>
|
|
@@ -78,13 +85,14 @@ function emptyTotals() {
|
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
export class ClaudeMeter {
|
|
81
|
-
constructor({ sessionId, cwd, side, role, label, variant, pricingProfile = 'standard' }) {
|
|
88
|
+
constructor({ sessionId, cwd, side, role, label, variant, pricingProfile = 'standard', options = {} }) {
|
|
82
89
|
this.sessionId = sessionId;
|
|
83
90
|
this.cwd = cwd;
|
|
84
91
|
this.side = side;
|
|
85
92
|
this.role = role;
|
|
86
93
|
this.label = label || role;
|
|
87
94
|
this.variant = variant;
|
|
95
|
+
this.options = options;
|
|
88
96
|
this.pricingProfile = pricingProfiles.includes(pricingProfile) ? pricingProfile : 'standard';
|
|
89
97
|
this.root = join(homedir(), '.claude', 'projects');
|
|
90
98
|
this.files = new Map();
|
|
@@ -99,6 +107,7 @@ export class ClaudeMeter {
|
|
|
99
107
|
this.chargedPrints = new Map();
|
|
100
108
|
this.telemetry = new Map();
|
|
101
109
|
this.sideCalls = { count: 0, cost: 0, models: {} };
|
|
110
|
+
this.localReplyIds = new Set();
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
// api_request events from this pane's telemetry (see telemetry.mjs).
|
|
@@ -116,6 +125,11 @@ export class ClaudeMeter {
|
|
|
116
125
|
const now = Date.now();
|
|
117
126
|
for (const request of this.telemetry.values()) {
|
|
118
127
|
if (request.resolved) continue;
|
|
128
|
+
if (isLocalReply(request.requestId)) {
|
|
129
|
+
request.resolved = true;
|
|
130
|
+
this.localReplyIds.add(request.requestId);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
119
133
|
const { model, cache_read: read, cache_write: write, fresh_input: input, output } = request;
|
|
120
134
|
const print = usagePrint(model, input, read, write, output);
|
|
121
135
|
if (request.requestId ? this.requestIds.has(request.requestId) : takePrint(this.transcriptPrints, print)) {
|
|
@@ -234,6 +248,12 @@ export class ClaudeMeter {
|
|
|
234
248
|
const input = Number(usage.input_tokens) || 0;
|
|
235
249
|
const output = Number(usage.output_tokens) || 0;
|
|
236
250
|
const model = message.model || this.model;
|
|
251
|
+
if (isLocalReply(obj.requestId, message.id)) {
|
|
252
|
+
this.localReplyIds.add(obj.requestId || message.id);
|
|
253
|
+
// Without a request id the matching telemetry event is recognized by its usage.
|
|
254
|
+
if (!obj.requestId) addPrint(this.transcriptPrints, usagePrint(model, input, read, write, output));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
237
257
|
if (!obj.requestId) {
|
|
238
258
|
const print = usagePrint(model, input, read, write, output);
|
|
239
259
|
// Already charged from telemetry when its transcript line arrived late.
|
|
@@ -268,10 +288,10 @@ export class ClaudeMeter {
|
|
|
268
288
|
}
|
|
269
289
|
|
|
270
290
|
snapshot() {
|
|
271
|
-
return { side: this.side, role: this.role, label: this.label, variant: this.variant,
|
|
291
|
+
return { side: this.side, role: this.role, label: this.label, variant: this.variant, options: this.options,
|
|
272
292
|
harness: 'claude-code', cwd: this.cwd, pricing_profile: this.pricingProfile, session_id: this.sessionId,
|
|
273
293
|
transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
|
|
274
|
-
totals: { ...this.totals },
|
|
294
|
+
totals: { ...this.totals }, local_replies: this.localReplyIds.size,
|
|
275
295
|
side_calls: { ...this.sideCalls, models: structuredClone(this.sideCalls.models) } };
|
|
276
296
|
}
|
|
277
297
|
}
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
background: var(--pane-head); border-bottom: 1px solid var(--line); border-top: 2px solid transparent; }
|
|
43
43
|
.pane.focused header { border-top-color: #5d7d55; }
|
|
44
44
|
.pane header .label { color: #b8b8c3; letter-spacing: .06em; }
|
|
45
|
+
.pane header .chip { padding: 1px 8px; border: 1px solid #4d4d57; border-radius: 999px; color: var(--muted);
|
|
46
|
+
font-size: 11px; white-space: nowrap; }
|
|
47
|
+
.pane-options details { margin-top: 10px; }
|
|
48
|
+
.pane-options .option + .option { margin-top: 4px; }
|
|
45
49
|
.pane.focused header .label { color: var(--text); }
|
|
46
50
|
.terminal { flex: 1; min-height: 0; padding: 5px 7px; }
|
|
47
51
|
.xterm { height: 100%; }
|
|
@@ -98,9 +102,7 @@
|
|
|
98
102
|
letter-spacing: 1.8px; text-transform: uppercase; }
|
|
99
103
|
.card h2 { margin: 0 0 8px; font-size: 20px; font-weight: 650; letter-spacing: -.4px; }
|
|
100
104
|
.card .lede { margin: 0 0 20px; color: var(--muted); }
|
|
101
|
-
.card .notice { margin: 0 0 20px; padding: 12px 14px; border-radius: 8px; background: #1d2219;
|
|
102
105
|
color: #b4b4be; font-size: 12px; }
|
|
103
|
-
.card .notice strong { color: var(--text); }
|
|
104
106
|
.card label { display: block; margin: 14px 0 0; color: #b8b8c1; font-size: 12px; }
|
|
105
107
|
.card input:not([type=checkbox]), .card select { display: block; width: 100%; min-width: 0; margin-top: 8px;
|
|
106
108
|
padding: 10px 12px; border: 1px solid #3a3a44; border-radius: 8px; background: var(--field); color: #eee;
|
|
@@ -193,17 +195,6 @@
|
|
|
193
195
|
.card .error { margin: 14px 0 0; color: var(--error); font-size: 13px; }
|
|
194
196
|
.card .error:empty { display: none; }
|
|
195
197
|
.card .footnote { margin: 16px 0 0; color: var(--muted); font-size: 12px; }
|
|
196
|
-
.progress { margin: 16px 0 0; font-size: 12px; }
|
|
197
|
-
.progress:empty { display: none; }
|
|
198
|
-
.progress p { margin: 0 0 8px; color: var(--accent); }
|
|
199
|
-
.progress ol { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; font-family: var(--mono); }
|
|
200
|
-
.progress li { display: flex; gap: 8px; color: var(--dim); }
|
|
201
|
-
.progress li::before { content: '○'; width: 1em; text-align: center; }
|
|
202
|
-
.progress li.done { color: #92929c; }
|
|
203
|
-
.progress li.done::before { content: '✓'; color: var(--accent); }
|
|
204
|
-
.progress li.current { color: var(--text); }
|
|
205
|
-
.progress li.current::before { content: '›'; color: var(--accent); }
|
|
206
|
-
.progress .elapsed { margin-left: auto; color: #92929c; font-variant-numeric: tabular-nums; }
|
|
207
198
|
.metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 18px 0; }
|
|
208
199
|
.metric { padding: 14px; border-radius: 10px; background: var(--field); }
|
|
209
200
|
.metric span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; }
|
|
@@ -222,18 +213,18 @@
|
|
|
222
213
|
title="Type into both panes at once (Ctrl/⌘+Shift+S)">Sync off</button>
|
|
223
214
|
<button id="finish" disabled>Finish comparison</button>
|
|
224
215
|
<button id="download-logs" hidden>Download logs</button>
|
|
225
|
-
<button id="add-to-gallery" disabled title="
|
|
216
|
+
<button id="add-to-gallery" disabled title="Finish this comparison and share it in the PennyRouter gallery">Add to gallery</button>
|
|
226
217
|
<span id="delta" aria-live="polite"></span>
|
|
227
218
|
<span id="versions"></span>
|
|
228
219
|
<span id="status">Connecting…</span>
|
|
229
220
|
</div>
|
|
230
221
|
<main class="panes">
|
|
231
|
-
<section class="pane focused" id="left-pane"><header><strong class="label" id="left-label">NATIVE</strong>
|
|
222
|
+
<section class="pane focused" id="left-pane"><header><strong class="label" id="left-label">NATIVE</strong><span class="chip" id="left-chip" hidden></span>
|
|
232
223
|
<div class="files" id="left-files"><button class="newest" id="left-newest" hidden></button>
|
|
233
224
|
<button class="count" id="left-files-count" aria-expanded="false" aria-controls="left-file-list">No files</button>
|
|
234
225
|
<div class="file-list" id="left-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
|
|
235
226
|
</header><div class="meter" id="left-meter"></div><div class="terminal" id="left"></div></section>
|
|
236
|
-
<section class="pane" id="right-pane"><header><strong class="label" id="right-label">PENNY</strong>
|
|
227
|
+
<section class="pane" id="right-pane"><header><strong class="label" id="right-label">PENNY</strong><span class="chip" id="right-chip" hidden></span>
|
|
237
228
|
<div class="files" id="right-files"><button class="newest" id="right-newest" hidden></button>
|
|
238
229
|
<button class="count" id="right-files-count" aria-expanded="false" aria-controls="right-file-list">No files</button>
|
|
239
230
|
<div class="file-list" id="right-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
|
|
@@ -241,17 +232,22 @@
|
|
|
241
232
|
</main>
|
|
242
233
|
|
|
243
234
|
<section class="overlay" id="setup" hidden>
|
|
244
|
-
<form class="card" id="
|
|
245
|
-
<span class="eyebrow">
|
|
246
|
-
<h2>
|
|
247
|
-
<p class="lede">
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
<
|
|
253
|
-
|
|
254
|
-
<input
|
|
235
|
+
<form class="card wide" id="local-setup" hidden>
|
|
236
|
+
<span class="eyebrow">On this machine</span>
|
|
237
|
+
<h2>Penny Compare</h2>
|
|
238
|
+
<p class="lede">Run a native harness and a PennyRouter harness side by side and measure both.</p>
|
|
239
|
+
<div class="grid">
|
|
240
|
+
<label>Left pane <select id="left-variant"></select></label>
|
|
241
|
+
<label>Right pane <select id="right-variant"></select></label>
|
|
242
|
+
<div class="pane-options" id="left-options"></div>
|
|
243
|
+
<div class="pane-options" id="right-options"></div>
|
|
244
|
+
<label>Left directory <input id="left-cwd" autocomplete="off" spellcheck="false" required></label>
|
|
245
|
+
<label>Right directory <input id="right-cwd" autocomplete="off" spellcheck="false" required></label>
|
|
246
|
+
<label>Left label <input id="left-custom-label" placeholder="NATIVE"></label>
|
|
247
|
+
<label>Right label <input id="right-custom-label" placeholder="PENNY"></label>
|
|
248
|
+
</div>
|
|
249
|
+
<label>Model (both panes)
|
|
250
|
+
<input id="model" placeholder="Harness default" autocomplete="off" spellcheck="false"></label>
|
|
255
251
|
<details id="environment">
|
|
256
252
|
<summary>Environment <span class="choice" id="environment-choice"></span></summary>
|
|
257
253
|
<label>Provider
|
|
@@ -276,32 +272,6 @@
|
|
|
276
272
|
<option value="govcloud" data-short="GovCloud pricing">GovCloud</option>
|
|
277
273
|
</select></label>
|
|
278
274
|
</details>
|
|
279
|
-
<details>
|
|
280
|
-
<summary>Optional: second account for cleaner cache metrics</summary>
|
|
281
|
-
<label><span id="penny-key-label">Anthropic API key (second account)</span>
|
|
282
|
-
<input type="password" id="penny-key" autocomplete="off" spellcheck="false"></label>
|
|
283
|
-
<p class="hint">Both terminals otherwise share one key, so they can share prompt-cache entries and blur the cache numbers. A key from a separate account keeps the Penny side's cache isolated. Leave blank to use the key above for both.</p>
|
|
284
|
-
</details>
|
|
285
|
-
<p class="error" id="hosted-error" role="alert"></p>
|
|
286
|
-
<button class="primary" id="hosted-start" disabled>Start terminals</button>
|
|
287
|
-
<div class="progress" id="progress" role="status" aria-live="polite"></div>
|
|
288
|
-
</form>
|
|
289
|
-
|
|
290
|
-
<form class="card wide" id="local-setup" hidden>
|
|
291
|
-
<span class="eyebrow">On this machine</span>
|
|
292
|
-
<h2>Penny Compare</h2>
|
|
293
|
-
<p class="lede">Run a native harness and a PennyRouter harness side by side and measure both.</p>
|
|
294
|
-
<div class="grid">
|
|
295
|
-
<label>Left pane <select id="left-variant"></select></label>
|
|
296
|
-
<label>Right pane <select id="right-variant"></select></label>
|
|
297
|
-
<label>Left directory <input id="left-cwd" autocomplete="off" spellcheck="false" required></label>
|
|
298
|
-
<label>Right directory <input id="right-cwd" autocomplete="off" spellcheck="false" required></label>
|
|
299
|
-
<label>Left label <input id="left-custom-label" placeholder="NATIVE"></label>
|
|
300
|
-
<label>Right label <input id="right-custom-label" placeholder="PENNY"></label>
|
|
301
|
-
</div>
|
|
302
|
-
<label>Model (both panes)
|
|
303
|
-
<input id="model" placeholder="Harness default" autocomplete="off" spellcheck="false"></label>
|
|
304
|
-
<div id="local-environment"></div>
|
|
305
275
|
<p class="error" id="local-error" role="alert"></p>
|
|
306
276
|
<button class="primary" id="local-start" type="submit" disabled>Start comparison</button>
|
|
307
277
|
<p class="footnote">Exit saves a JSON report under <code>apps/compare/reports</code>.</p>
|
|
@@ -638,13 +608,19 @@
|
|
|
638
608
|
$('setup').hidden = true;
|
|
639
609
|
$('sync').disabled = false;
|
|
640
610
|
$('finish').disabled = false;
|
|
611
|
+
$('add-to-gallery').disabled = false;
|
|
641
612
|
setStatus('Live');
|
|
642
613
|
resize();
|
|
643
614
|
terminals.left.focus();
|
|
644
615
|
startFilePolling();
|
|
645
616
|
};
|
|
646
|
-
|
|
617
|
+
let finishing = false;
|
|
618
|
+
let publishAfterFinish = false;
|
|
619
|
+
const finishComparison = async () => {
|
|
620
|
+
if (finishing || lastReport || !started) return;
|
|
621
|
+
finishing = true;
|
|
647
622
|
$('finish').disabled = true;
|
|
623
|
+
$('add-to-gallery').disabled = true;
|
|
648
624
|
setStatus('Saving files…');
|
|
649
625
|
clearInterval(fileTimer);
|
|
650
626
|
await pollFiles().catch(() => {});
|
|
@@ -652,6 +628,7 @@
|
|
|
652
628
|
send({ type: 'exit' });
|
|
653
629
|
setStatus(hosted ? 'Finishing…' : 'Saving report…');
|
|
654
630
|
};
|
|
631
|
+
$('finish').onclick = finishComparison;
|
|
655
632
|
|
|
656
633
|
// Files the agents write, previewed in a separate tab
|
|
657
634
|
const previewTabs = new Map();
|
|
@@ -931,8 +908,7 @@
|
|
|
931
908
|
};
|
|
932
909
|
|
|
933
910
|
// Environment: the pricing schedule both meters use, plus the provider and proxy the
|
|
934
|
-
// comparison represents. The
|
|
935
|
-
if (!hosted) $('local-environment').replaceWith($('environment'));
|
|
911
|
+
// comparison represents. (The website's demo collects these on its own setup card.)
|
|
936
912
|
const environment = () => ({ pricing: $('pricing').value, provider: $('provider').value,
|
|
937
913
|
proxy: $('proxy').value });
|
|
938
914
|
const describeEnvironment = () => {
|
|
@@ -1003,6 +979,8 @@
|
|
|
1003
979
|
}
|
|
1004
980
|
$('results-publish').hidden = false;
|
|
1005
981
|
if (blocker) $('results-publish').textContent = blocker;
|
|
982
|
+
if (publishAfterFinish && !blocker) openPublish();
|
|
983
|
+
publishAfterFinish = false;
|
|
1006
984
|
if (hosted) window.parent.postMessage({ type: 'compare-demo-finished' }, config.parentOrigin);
|
|
1007
985
|
};
|
|
1008
986
|
const remembered = (key, value) => {
|
|
@@ -1079,7 +1057,11 @@
|
|
|
1079
1057
|
$('publish').hidden = false;
|
|
1080
1058
|
$('publish-title').focus();
|
|
1081
1059
|
};
|
|
1082
|
-
$('add-to-gallery').onclick =
|
|
1060
|
+
$('add-to-gallery').onclick = () => {
|
|
1061
|
+
if (lastReport) return openPublish();
|
|
1062
|
+
publishAfterFinish = true;
|
|
1063
|
+
return finishComparison();
|
|
1064
|
+
};
|
|
1083
1065
|
$('results-publish').onclick = openPublish;
|
|
1084
1066
|
$('publish-cancel').onclick = () => { $('publish').hidden = true; };
|
|
1085
1067
|
$('publish-form').addEventListener('input', updatePreview);
|
|
@@ -1173,89 +1155,30 @@
|
|
|
1173
1155
|
showReport(message);
|
|
1174
1156
|
prepareGallery(message);
|
|
1175
1157
|
}
|
|
1176
|
-
else if (message.type === 'report-error')
|
|
1158
|
+
else if (message.type === 'report-error') {
|
|
1159
|
+
publishAfterFinish = false;
|
|
1160
|
+
showReportError(message.error);
|
|
1161
|
+
}
|
|
1177
1162
|
else return false;
|
|
1178
1163
|
return true;
|
|
1179
1164
|
};
|
|
1180
1165
|
|
|
1181
1166
|
if (hosted) {
|
|
1182
|
-
// Hosted sandbox: the
|
|
1183
|
-
|
|
1184
|
-
$('hosted-setup').hidden = false;
|
|
1167
|
+
// Hosted sandbox: the website shows the setup card. It supplies the session token, then the
|
|
1168
|
+
// visitor's choices; this page reports progress back and shows the terminals once started.
|
|
1185
1169
|
let tokenAccepted = false;
|
|
1170
|
+
let authenticated = false;
|
|
1186
1171
|
let launchError = '';
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
const intro = document.createElement('p');
|
|
1195
|
-
intro.textContent = lead;
|
|
1196
|
-
const list = document.createElement('ol');
|
|
1197
|
-
let elapsed;
|
|
1198
|
-
steps.forEach((label, index) => {
|
|
1199
|
-
const item = document.createElement('li');
|
|
1200
|
-
item.className = index < current ? 'done' : index === current ? 'current' : '';
|
|
1201
|
-
const text = document.createElement('span');
|
|
1202
|
-
text.textContent = label;
|
|
1203
|
-
item.append(text);
|
|
1204
|
-
if (index === current) {
|
|
1205
|
-
elapsed = document.createElement('span');
|
|
1206
|
-
elapsed.className = 'elapsed';
|
|
1207
|
-
elapsed.setAttribute('aria-hidden', 'true');
|
|
1208
|
-
item.append(elapsed);
|
|
1209
|
-
}
|
|
1210
|
-
list.append(item);
|
|
1211
|
-
});
|
|
1212
|
-
$('progress').replaceChildren(intro, list);
|
|
1213
|
-
if (!elapsed) return;
|
|
1214
|
-
const since = Date.now();
|
|
1215
|
-
// Only show a counter once a step is slow enough for the wait to be noticeable.
|
|
1216
|
-
progressTimer = setInterval(() => {
|
|
1217
|
-
const seconds = Math.floor((Date.now() - since) / 1000);
|
|
1218
|
-
elapsed.textContent = seconds >= 2 ? `${seconds}s` : '';
|
|
1219
|
-
}, 500);
|
|
1220
|
-
};
|
|
1221
|
-
// Mirrors the server's key check; the server closes the sandbox on a rejected start.
|
|
1222
|
-
const plausibleKey = (key) => key.length > 3 && key.length <= 1024 && !/\s/.test(key) && key.startsWith('sk-');
|
|
1223
|
-
$('hosted-setup').onsubmit = (event) => {
|
|
1224
|
-
event.preventDefault();
|
|
1225
|
-
const harness = $('harness');
|
|
1226
|
-
const input = $('key');
|
|
1227
|
-
const second = $('penny-key');
|
|
1228
|
-
const start = $('hosted-start');
|
|
1229
|
-
const nativeKey = input.value.trim();
|
|
1230
|
-
const pennyKey = second.value.trim() || nativeKey;
|
|
1231
|
-
const provider = harness.value === 'claude' ? 'Anthropic' : 'OpenAI';
|
|
1232
|
-
if (!plausibleKey(nativeKey) || !plausibleKey(pennyKey)) {
|
|
1233
|
-
$('hosted-error').textContent = `That doesn't look like an ${provider} API key. Keys start with "sk-".`;
|
|
1172
|
+
const toParent = (message) => window.parent.postMessage(message, config.parentOrigin);
|
|
1173
|
+
window.addEventListener('message', (event) => {
|
|
1174
|
+
if (event.origin !== config.parentOrigin || event.source !== window.parent) return;
|
|
1175
|
+
if (event.data?.type === 'compare-demo-start' && authenticated) {
|
|
1176
|
+
const { harness, nativeKey, pennyKey, environment: chosen } = event.data;
|
|
1177
|
+
send({ type: 'start', harness, nativeKey, pennyKey, environment: chosen });
|
|
1178
|
+
setStatus('Starting terminals…');
|
|
1234
1179
|
return;
|
|
1235
1180
|
}
|
|
1236
|
-
|
|
1237
|
-
send({ type: 'start', harness: harness.value, nativeKey, pennyKey, environment: environment() });
|
|
1238
|
-
input.value = '';
|
|
1239
|
-
second.value = '';
|
|
1240
|
-
for (const field of [harness, input, second, $('pricing'), $('provider'), $('proxy')]) field.disabled = true;
|
|
1241
|
-
refreshPickers();
|
|
1242
|
-
input.placeholder = 'Key received';
|
|
1243
|
-
start.disabled = true;
|
|
1244
|
-
start.classList.add('busy');
|
|
1245
|
-
start.textContent = 'Starting terminals…';
|
|
1246
|
-
showProgress('Key received. Preparing your terminals…');
|
|
1247
|
-
setStatus('Starting terminals…');
|
|
1248
|
-
};
|
|
1249
|
-
$('harness').onchange = (event) => {
|
|
1250
|
-
limitProviders(event.target.value === 'codex');
|
|
1251
|
-
const name = event.target.value === 'claude' ? 'Anthropic' : 'OpenAI';
|
|
1252
|
-
const suffix = event.target.value === 'claude' ? '' : ' (for Codex)';
|
|
1253
|
-
$('key-label').textContent = `${name} API key${suffix}`;
|
|
1254
|
-
$('penny-key-label').textContent = `${name} API key${suffix} (second account)`;
|
|
1255
|
-
};
|
|
1256
|
-
window.addEventListener('message', (event) => {
|
|
1257
|
-
if (event.origin !== config.parentOrigin || event.source !== window.parent ||
|
|
1258
|
-
event.data?.type !== 'compare-demo-session' || tokenAccepted) return;
|
|
1181
|
+
if (event.data?.type !== 'compare-demo-session' || tokenAccepted) return;
|
|
1259
1182
|
tokenAccepted = true;
|
|
1260
1183
|
sessionToken = event.data.token;
|
|
1261
1184
|
socket = new WebSocket(`wss://${location.host}/terminal`);
|
|
@@ -1267,33 +1190,32 @@
|
|
|
1267
1190
|
if (message.type === 'authenticated') {
|
|
1268
1191
|
$('download-logs').hidden = !message.captureLogs;
|
|
1269
1192
|
if (message.subscription) {
|
|
1270
|
-
$('setup').hidden = true;
|
|
1271
1193
|
setStatus('Starting subscription test · costs are API-equivalent estimates');
|
|
1272
1194
|
send({ type: 'start', harness: message.subscriptionHarness || 'claude' });
|
|
1273
1195
|
return;
|
|
1274
1196
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1197
|
+
authenticated = true;
|
|
1198
|
+
setStatus('Ready');
|
|
1199
|
+
toParent({ type: 'compare-demo-authenticated' });
|
|
1277
1200
|
} else if (message.type === 'progress') {
|
|
1278
|
-
|
|
1201
|
+
setStatus(message.steps?.[message.current] || 'Starting terminals…');
|
|
1202
|
+
toParent({ type: 'compare-demo-progress', steps: message.steps, current: message.current });
|
|
1279
1203
|
} else if (message.type === 'started') {
|
|
1280
|
-
clearProgress();
|
|
1281
1204
|
markStarted();
|
|
1205
|
+
toParent({ type: 'compare-demo-started' });
|
|
1282
1206
|
} else if (message.type === 'error') {
|
|
1283
1207
|
launchError = message.error;
|
|
1284
|
-
$('hosted-start').classList.remove('busy');
|
|
1285
|
-
clearProgress();
|
|
1286
|
-
$('hosted-error').textContent = message.error;
|
|
1287
1208
|
setStatus('Could not start');
|
|
1209
|
+
toParent({ type: 'compare-demo-error', error: message.error });
|
|
1288
1210
|
}
|
|
1289
1211
|
});
|
|
1290
1212
|
socket.addEventListener('close', () => {
|
|
1291
1213
|
clearInterval(fileTimer);
|
|
1292
|
-
$('hosted-start').disabled = true;
|
|
1293
1214
|
$('finish').disabled = true;
|
|
1215
|
+
if (!lastReport) $('add-to-gallery').disabled = true;
|
|
1294
1216
|
if (!$('results').hidden) return;
|
|
1295
1217
|
setStatus('Session ended');
|
|
1296
|
-
|
|
1218
|
+
toParent({ type: 'compare-demo-ended', error: launchError });
|
|
1297
1219
|
});
|
|
1298
1220
|
});
|
|
1299
1221
|
window.parent.postMessage({ type: 'compare-demo-ready' }, config.parentOrigin);
|
|
@@ -1329,6 +1251,50 @@
|
|
|
1329
1251
|
$('right-variant').value = `penny-${config.defaultHarness}`;
|
|
1330
1252
|
$('left-cwd').value = config.folders.left;
|
|
1331
1253
|
$('right-cwd').value = config.folders.right;
|
|
1254
|
+
// Options a pane supports beyond its harness, under its picker (see pane-options.mjs).
|
|
1255
|
+
const optionControls = { left: [], right: [] };
|
|
1256
|
+
for (const side of sides) {
|
|
1257
|
+
const details = document.createElement('details');
|
|
1258
|
+
details.hidden = true;
|
|
1259
|
+
details.append(document.createElement('summary'));
|
|
1260
|
+
for (const option of config.paneOptions || []) {
|
|
1261
|
+
const group = document.createElement('div');
|
|
1262
|
+
group.className = 'option';
|
|
1263
|
+
const label = document.createElement('label');
|
|
1264
|
+
label.append(document.createTextNode(option.label));
|
|
1265
|
+
const select = document.createElement('select');
|
|
1266
|
+
for (const choice of option.choices) select.add(new Option(choice.label, choice.value));
|
|
1267
|
+
select.value = option.default;
|
|
1268
|
+
label.append(select);
|
|
1269
|
+
const hint = document.createElement('p');
|
|
1270
|
+
hint.className = 'hint';
|
|
1271
|
+
hint.textContent = option.hint;
|
|
1272
|
+
group.append(label, hint);
|
|
1273
|
+
details.append(group);
|
|
1274
|
+
optionControls[side].push({ option, select, group });
|
|
1275
|
+
}
|
|
1276
|
+
$(`${side}-options`).append(details);
|
|
1277
|
+
}
|
|
1278
|
+
const supportedControls = (side, variant) =>
|
|
1279
|
+
optionControls[side].filter(({ option }) => option.variants.includes(variant));
|
|
1280
|
+
const optionSummary = (side, variant) => supportedControls(side, variant).map(({ option, select }) =>
|
|
1281
|
+
option.choices.find((choice) => choice.value === select.value)?.summary).filter(Boolean).join(' · ');
|
|
1282
|
+
const refreshOptions = () => {
|
|
1283
|
+
for (const side of sides) {
|
|
1284
|
+
const variant = $(`${side}-variant`).value;
|
|
1285
|
+
const details = $(`${side}-options`).firstElementChild;
|
|
1286
|
+
const supported = supportedControls(side, variant);
|
|
1287
|
+
details.hidden = !supported.length;
|
|
1288
|
+
for (const control of optionControls[side]) control.group.hidden = !supported.includes(control);
|
|
1289
|
+
const summary = details.firstElementChild;
|
|
1290
|
+
summary.replaceChildren(`${side === 'left' ? 'Left' : 'Right'} options `);
|
|
1291
|
+
const choice = document.createElement('span');
|
|
1292
|
+
choice.className = 'choice';
|
|
1293
|
+
choice.textContent = `· ${optionSummary(side, variant)}`;
|
|
1294
|
+
summary.append(choice);
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
for (const side of sides) for (const { select } of optionControls[side]) select.addEventListener('change', refreshOptions);
|
|
1332
1298
|
let modelFamily = 'claude';
|
|
1333
1299
|
const refreshVariants = () => {
|
|
1334
1300
|
const left = $('left-variant').value;
|
|
@@ -1346,6 +1312,7 @@
|
|
|
1346
1312
|
modelSuggestions = family ? [{ value: '', label: 'Harness default' },
|
|
1347
1313
|
...modelChoices[family].map(([value, label]) => ({ value, label }))] : [];
|
|
1348
1314
|
model.placeholder = family ? 'Harness default' : 'Pick panes from the same harness to choose a model';
|
|
1315
|
+
refreshOptions();
|
|
1349
1316
|
refreshPickers();
|
|
1350
1317
|
};
|
|
1351
1318
|
$('left-variant').onchange = refreshVariants;
|
|
@@ -1371,8 +1338,14 @@
|
|
|
1371
1338
|
model: comboValue($('model'), modelSuggestions),
|
|
1372
1339
|
};
|
|
1373
1340
|
for (const side of sides) {
|
|
1374
|
-
|
|
1375
|
-
|
|
1341
|
+
const { variant } = settings[side];
|
|
1342
|
+
settings[side].options = Object.fromEntries(supportedControls(side, variant)
|
|
1343
|
+
.map(({ option, select }) => [option.id, select.value]));
|
|
1344
|
+
$(`${side}-label`).textContent = settings[side].label || roleLabel(variant);
|
|
1345
|
+
paneRoles[side] = roleLabel(variant);
|
|
1346
|
+
const chip = $(`${side}-chip`);
|
|
1347
|
+
chip.textContent = optionSummary(side, variant);
|
|
1348
|
+
chip.hidden = !chip.textContent;
|
|
1376
1349
|
}
|
|
1377
1350
|
pending = true;
|
|
1378
1351
|
$('local-start').disabled = true;
|
|
@@ -1415,6 +1388,7 @@
|
|
|
1415
1388
|
clearInterval(fileTimer);
|
|
1416
1389
|
$('sync').disabled = true;
|
|
1417
1390
|
$('local-start').disabled = true;
|
|
1391
|
+
if (!lastReport) $('add-to-gallery').disabled = true;
|
|
1418
1392
|
if (!$('finish').disabled) {
|
|
1419
1393
|
$('finish').disabled = true;
|
|
1420
1394
|
setStatus('Disconnected · reload to reconnect');
|
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,7 @@ import { WebSocketServer } from 'ws';
|
|
|
10
10
|
import pty from 'node-pty';
|
|
11
11
|
import { ClaudeMeter, CodexMeter, galleryReport, pricingProfiles, saveReport } from './claude-meter.mjs';
|
|
12
12
|
import { publishToGallery } from './gallery-submit.mjs';
|
|
13
|
+
import { applyPaneOptions, describePaneOptions, normalizePaneOptions } from './pane-options.mjs';
|
|
13
14
|
import { listArtifacts, readArtifact } from './artifacts.mjs';
|
|
14
15
|
import { readTelemetry, telemetryEnv, telemetryRoute } from './telemetry.mjs';
|
|
15
16
|
import { Readable } from 'node:stream';
|
|
@@ -96,7 +97,7 @@ const server = createServer(async (request, response) => {
|
|
|
96
97
|
try {
|
|
97
98
|
let body = await readFile(join(here, file[0]));
|
|
98
99
|
if (request.url === '/') {
|
|
99
|
-
const config = { mode: 'local', compare, defaultHarness: startupHarness,
|
|
100
|
+
const config = { mode: 'local', compare, defaultHarness: startupHarness, paneOptions: describePaneOptions(),
|
|
100
101
|
folders: { left: join(launchDirectory, 'native'), right: join(launchDirectory, 'penny') },
|
|
101
102
|
versions: await harnessVersions };
|
|
102
103
|
body = body.toString('utf8')
|
|
@@ -186,7 +187,7 @@ async function validateConfig(config) {
|
|
|
186
187
|
if (!existing && !(await stat(dirname(cwd)).catch(() => null))?.isDirectory()) {
|
|
187
188
|
throw new Error(`Folder does not exist: ${dirname(cwd)}`);
|
|
188
189
|
}
|
|
189
|
-
return { ...config, cwd, create: !existing };
|
|
190
|
+
return { ...config, cwd, create: !existing, options: normalizePaneOptions(config.variant, config.options) };
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
// Each pane needs its own folder: agents running side by side in one folder would edit the
|
|
@@ -337,7 +338,8 @@ wss.on('connection', (ws) => {
|
|
|
337
338
|
const Meter = config.variant.includes('codex') ? CodexMeter : ClaudeMeter;
|
|
338
339
|
const role = config.variant.startsWith('penny-') ? 'PENNY' : 'NATIVE';
|
|
339
340
|
meter = new Meter({ sessionId, cwd: config.cwd, side: message.side, role,
|
|
340
|
-
label: config.label, variant: config.variant, pricingProfile: environment.pricing
|
|
341
|
+
label: config.label, variant: config.variant, pricingProfile: environment.pricing,
|
|
342
|
+
options: config.options });
|
|
341
343
|
}
|
|
342
344
|
// Claude panes report background model calls to this server; see telemetry.mjs.
|
|
343
345
|
const route = meter instanceof CodexMeter || !meter ? null : telemetryRoute();
|
|
@@ -352,6 +354,7 @@ wss.on('connection', (ws) => {
|
|
|
352
354
|
delete env[key];
|
|
353
355
|
}
|
|
354
356
|
}
|
|
357
|
+
applyPaneOptions(env, config.variant, config.options);
|
|
355
358
|
const session = pty.spawn(command, commandArgs, {
|
|
356
359
|
name: 'xterm-256color', cols: message.cols, rows: message.rows,
|
|
357
360
|
cwd: config.cwd, env,
|