pennyrouter 0.3.44 → 0.3.46
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 +150 -157
- package/standalone/web-demo/gallery-submit.mjs +2 -3
- 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>
|
|
@@ -345,6 +315,7 @@
|
|
|
345
315
|
<div class="segmented" role="radiogroup" aria-label="Featured page">
|
|
346
316
|
<label><input type="radio" name="publish-cover" value="penny" checked><span>Penny page</span></label>
|
|
347
317
|
<label><input type="radio" name="publish-cover" value="native"><span>Native page</span></label>
|
|
318
|
+
<label><input type="radio" name="publish-cover" value="none"><span>No page</span></label>
|
|
348
319
|
</div>
|
|
349
320
|
<label>Your name, as shown publicly
|
|
350
321
|
<input id="publish-name" maxlength="40" required autocomplete="nickname"></label>
|
|
@@ -638,13 +609,19 @@
|
|
|
638
609
|
$('setup').hidden = true;
|
|
639
610
|
$('sync').disabled = false;
|
|
640
611
|
$('finish').disabled = false;
|
|
612
|
+
$('add-to-gallery').disabled = false;
|
|
641
613
|
setStatus('Live');
|
|
642
614
|
resize();
|
|
643
615
|
terminals.left.focus();
|
|
644
616
|
startFilePolling();
|
|
645
617
|
};
|
|
646
|
-
|
|
618
|
+
let finishing = false;
|
|
619
|
+
let publishAfterFinish = false;
|
|
620
|
+
const finishComparison = async () => {
|
|
621
|
+
if (finishing || lastReport || !started) return;
|
|
622
|
+
finishing = true;
|
|
647
623
|
$('finish').disabled = true;
|
|
624
|
+
$('add-to-gallery').disabled = true;
|
|
648
625
|
setStatus('Saving files…');
|
|
649
626
|
clearInterval(fileTimer);
|
|
650
627
|
await pollFiles().catch(() => {});
|
|
@@ -652,6 +629,7 @@
|
|
|
652
629
|
send({ type: 'exit' });
|
|
653
630
|
setStatus(hosted ? 'Finishing…' : 'Saving report…');
|
|
654
631
|
};
|
|
632
|
+
$('finish').onclick = finishComparison;
|
|
655
633
|
|
|
656
634
|
// Files the agents write, previewed in a separate tab
|
|
657
635
|
const previewTabs = new Map();
|
|
@@ -931,8 +909,7 @@
|
|
|
931
909
|
};
|
|
932
910
|
|
|
933
911
|
// Environment: the pricing schedule both meters use, plus the provider and proxy the
|
|
934
|
-
// comparison represents. The
|
|
935
|
-
if (!hosted) $('local-environment').replaceWith($('environment'));
|
|
912
|
+
// comparison represents. (The website's demo collects these on its own setup card.)
|
|
936
913
|
const environment = () => ({ pricing: $('pricing').value, provider: $('provider').value,
|
|
937
914
|
proxy: $('proxy').value });
|
|
938
915
|
const describeEnvironment = () => {
|
|
@@ -986,13 +963,9 @@
|
|
|
986
963
|
}
|
|
987
964
|
}
|
|
988
965
|
const sideOf = (role) => (paneRoles.left === role ? 'left' : 'right');
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
return 'Publishing needs an HTML page in each pane.';
|
|
993
|
-
}
|
|
994
|
-
return '';
|
|
995
|
-
};
|
|
966
|
+
// Pages are optional: a comparison can be shared with just its numbers and a screenshot.
|
|
967
|
+
const publishBlocker = () =>
|
|
968
|
+
gallery ? '' : 'Publishing needs a native pane and a Penny pane on the same harness.';
|
|
996
969
|
const prepareGallery = (message) => {
|
|
997
970
|
lastReport = message;
|
|
998
971
|
gallery = message.gallery || null;
|
|
@@ -1003,6 +976,8 @@
|
|
|
1003
976
|
}
|
|
1004
977
|
$('results-publish').hidden = false;
|
|
1005
978
|
if (blocker) $('results-publish').textContent = blocker;
|
|
979
|
+
if (publishAfterFinish && !blocker) openPublish();
|
|
980
|
+
publishAfterFinish = false;
|
|
1006
981
|
if (hosted) window.parent.postMessage({ type: 'compare-demo-finished' }, config.parentOrigin);
|
|
1007
982
|
};
|
|
1008
983
|
const remembered = (key, value) => {
|
|
@@ -1015,7 +990,22 @@
|
|
|
1015
990
|
const pagePolicy = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; " +
|
|
1016
991
|
"font-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
1017
992
|
const sandboxed = (html) => `<meta http-equiv="Content-Security-Policy" content="${pagePolicy}"><meta name="referrer" content="no-referrer">${html}`;
|
|
1018
|
-
|
|
993
|
+
// The page chosen for a side, or undefined when "No page" is selected.
|
|
994
|
+
const chosen = (role) => {
|
|
995
|
+
const value = $(role === 'NATIVE' ? 'publish-native' : 'publish-penny').value;
|
|
996
|
+
return value === '' ? undefined : snapshot[sideOf(role)][Number(value)];
|
|
997
|
+
};
|
|
998
|
+
// The featured page must be one that is shared; fall back to the other page, then to none.
|
|
999
|
+
const syncFeatured = () => {
|
|
1000
|
+
const available = { penny: Boolean(chosen('PENNY')), native: Boolean(chosen('NATIVE')) };
|
|
1001
|
+
for (const radio of document.querySelectorAll('input[name="publish-cover"]')) {
|
|
1002
|
+
radio.disabled = radio.value !== 'none' && !available[radio.value];
|
|
1003
|
+
}
|
|
1004
|
+
if (document.querySelector('input[name="publish-cover"]:checked').disabled) {
|
|
1005
|
+
const next = available.penny ? 'penny' : available.native ? 'native' : 'none';
|
|
1006
|
+
document.querySelector(`input[name="publish-cover"][value="${next}"]`).checked = true;
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1019
1009
|
const checkedValue = (name) => document.querySelector(`input[name="${name}"]:checked`).value;
|
|
1020
1010
|
const savingsText = (savings) => {
|
|
1021
1011
|
if (!savings) return '';
|
|
@@ -1059,8 +1049,11 @@
|
|
|
1059
1049
|
$('preview-savings').classList.toggle('loss', Boolean(savings && savings.cost < 0));
|
|
1060
1050
|
const model = (meterSnapshots[sideOf('PENNY')]?.model || '').split('/').pop();
|
|
1061
1051
|
$('preview-meta').textContent = [model, $('publish-name').value.trim()].filter(Boolean).join(' · ');
|
|
1062
|
-
|
|
1063
|
-
const
|
|
1052
|
+
syncFeatured();
|
|
1053
|
+
const featured = checkedValue('publish-cover');
|
|
1054
|
+
const cover = featured === 'none' ? undefined : chosen(featured === 'native' ? 'NATIVE' : 'PENNY');
|
|
1055
|
+
$('publish-preview').hidden = !cover;
|
|
1056
|
+
const key = `${featured}:${cover?.path}`;
|
|
1064
1057
|
if (cover && key !== previewedCover) {
|
|
1065
1058
|
previewedCover = key;
|
|
1066
1059
|
$('publish-preview').srcdoc = sandboxed(cover.content);
|
|
@@ -1070,7 +1063,9 @@
|
|
|
1070
1063
|
for (const [role, id] of [['NATIVE', 'publish-native'], ['PENNY', 'publish-penny']]) {
|
|
1071
1064
|
const select = $(id);
|
|
1072
1065
|
if (!select.options.length) {
|
|
1066
|
+
select.add(new Option('No page', ''));
|
|
1073
1067
|
snapshot[sideOf(role)].forEach((file, index) => select.add(new Option(file.path, String(index))));
|
|
1068
|
+
select.value = snapshot[sideOf(role)].length ? '0' : '';
|
|
1074
1069
|
}
|
|
1075
1070
|
}
|
|
1076
1071
|
if (!$('publish-name').value) $('publish-name').value = remembered('penny-compare-display-name');
|
|
@@ -1079,7 +1074,11 @@
|
|
|
1079
1074
|
$('publish').hidden = false;
|
|
1080
1075
|
$('publish-title').focus();
|
|
1081
1076
|
};
|
|
1082
|
-
$('add-to-gallery').onclick =
|
|
1077
|
+
$('add-to-gallery').onclick = () => {
|
|
1078
|
+
if (lastReport) return openPublish();
|
|
1079
|
+
publishAfterFinish = true;
|
|
1080
|
+
return finishComparison();
|
|
1081
|
+
};
|
|
1083
1082
|
$('results-publish').onclick = openPublish;
|
|
1084
1083
|
$('publish-cancel').onclick = () => { $('publish').hidden = true; };
|
|
1085
1084
|
$('publish-form').addEventListener('input', updatePreview);
|
|
@@ -1101,6 +1100,7 @@
|
|
|
1101
1100
|
if (published) return;
|
|
1102
1101
|
const native = chosen('NATIVE');
|
|
1103
1102
|
const penny = chosen('PENNY');
|
|
1103
|
+
const featured = checkedValue('publish-cover');
|
|
1104
1104
|
const video = $('publish-video').files[0] || null;
|
|
1105
1105
|
const videoType = video && (video.type || (/\.webm$/i.test(video.name) ? 'video/webm' : /\.mp4$/i.test(video.name) ? 'video/mp4' : ''));
|
|
1106
1106
|
$('publish-error').textContent = '';
|
|
@@ -1114,7 +1114,8 @@
|
|
|
1114
1114
|
}
|
|
1115
1115
|
const meta = { title: $('publish-title').value.trim(), comment: $('publish-comment').value.trim(),
|
|
1116
1116
|
tag: checkedValue('publish-tag'), display_name: $('publish-name').value.trim(),
|
|
1117
|
-
native_file: native
|
|
1117
|
+
native_file: native?.path ?? null, penny_file: penny?.path ?? null,
|
|
1118
|
+
cover: featured === 'none' ? null : featured,
|
|
1118
1119
|
video_type: video ? videoType : null };
|
|
1119
1120
|
const submit = $('publish-submit');
|
|
1120
1121
|
submit.disabled = true;
|
|
@@ -1124,13 +1125,13 @@
|
|
|
1124
1125
|
try {
|
|
1125
1126
|
if (hosted) {
|
|
1126
1127
|
result = await relayToParent({ report: gallery.report, signature: gallery.signature,
|
|
1127
|
-
reportNonce: gallery.reportNonce, meta, native: native
|
|
1128
|
+
reportNonce: gallery.reportNonce, meta, native: native?.content ?? null, penny: penny?.content ?? null, video,
|
|
1128
1129
|
cover: coverImage });
|
|
1129
1130
|
} else {
|
|
1130
1131
|
const form = new FormData();
|
|
1131
1132
|
form.set('meta', JSON.stringify(meta));
|
|
1132
|
-
form.set('native', new Blob([native.content], { type: 'text/html' }), 'native.html');
|
|
1133
|
-
form.set('penny', new Blob([penny.content], { type: 'text/html' }), 'penny.html');
|
|
1133
|
+
if (native) form.set('native', new Blob([native.content], { type: 'text/html' }), 'native.html');
|
|
1134
|
+
if (penny) form.set('penny', new Blob([penny.content], { type: 'text/html' }), 'penny.html');
|
|
1134
1135
|
if (video) form.set('video', video, video.name);
|
|
1135
1136
|
if (coverImage) form.set('cover', coverImage, 'cover');
|
|
1136
1137
|
const response = await fetch('/gallery/submit', { method: 'POST',
|
|
@@ -1173,89 +1174,30 @@
|
|
|
1173
1174
|
showReport(message);
|
|
1174
1175
|
prepareGallery(message);
|
|
1175
1176
|
}
|
|
1176
|
-
else if (message.type === 'report-error')
|
|
1177
|
+
else if (message.type === 'report-error') {
|
|
1178
|
+
publishAfterFinish = false;
|
|
1179
|
+
showReportError(message.error);
|
|
1180
|
+
}
|
|
1177
1181
|
else return false;
|
|
1178
1182
|
return true;
|
|
1179
1183
|
};
|
|
1180
1184
|
|
|
1181
1185
|
if (hosted) {
|
|
1182
|
-
// Hosted sandbox: the
|
|
1183
|
-
|
|
1184
|
-
$('hosted-setup').hidden = false;
|
|
1186
|
+
// Hosted sandbox: the website shows the setup card. It supplies the session token, then the
|
|
1187
|
+
// visitor's choices; this page reports progress back and shows the terminals once started.
|
|
1185
1188
|
let tokenAccepted = false;
|
|
1189
|
+
let authenticated = false;
|
|
1186
1190
|
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-".`;
|
|
1191
|
+
const toParent = (message) => window.parent.postMessage(message, config.parentOrigin);
|
|
1192
|
+
window.addEventListener('message', (event) => {
|
|
1193
|
+
if (event.origin !== config.parentOrigin || event.source !== window.parent) return;
|
|
1194
|
+
if (event.data?.type === 'compare-demo-start' && authenticated) {
|
|
1195
|
+
const { harness, model, nativeKey, pennyKey, environment: chosen } = event.data;
|
|
1196
|
+
send({ type: 'start', harness, model, nativeKey, pennyKey, environment: chosen });
|
|
1197
|
+
setStatus('Starting terminals…');
|
|
1234
1198
|
return;
|
|
1235
1199
|
}
|
|
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;
|
|
1200
|
+
if (event.data?.type !== 'compare-demo-session' || tokenAccepted) return;
|
|
1259
1201
|
tokenAccepted = true;
|
|
1260
1202
|
sessionToken = event.data.token;
|
|
1261
1203
|
socket = new WebSocket(`wss://${location.host}/terminal`);
|
|
@@ -1267,33 +1209,32 @@
|
|
|
1267
1209
|
if (message.type === 'authenticated') {
|
|
1268
1210
|
$('download-logs').hidden = !message.captureLogs;
|
|
1269
1211
|
if (message.subscription) {
|
|
1270
|
-
$('setup').hidden = true;
|
|
1271
1212
|
setStatus('Starting subscription test · costs are API-equivalent estimates');
|
|
1272
1213
|
send({ type: 'start', harness: message.subscriptionHarness || 'claude' });
|
|
1273
1214
|
return;
|
|
1274
1215
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1216
|
+
authenticated = true;
|
|
1217
|
+
setStatus('Ready');
|
|
1218
|
+
toParent({ type: 'compare-demo-authenticated' });
|
|
1277
1219
|
} else if (message.type === 'progress') {
|
|
1278
|
-
|
|
1220
|
+
setStatus(message.steps?.[message.current] || 'Starting terminals…');
|
|
1221
|
+
toParent({ type: 'compare-demo-progress', steps: message.steps, current: message.current });
|
|
1279
1222
|
} else if (message.type === 'started') {
|
|
1280
|
-
clearProgress();
|
|
1281
1223
|
markStarted();
|
|
1224
|
+
toParent({ type: 'compare-demo-started' });
|
|
1282
1225
|
} else if (message.type === 'error') {
|
|
1283
1226
|
launchError = message.error;
|
|
1284
|
-
$('hosted-start').classList.remove('busy');
|
|
1285
|
-
clearProgress();
|
|
1286
|
-
$('hosted-error').textContent = message.error;
|
|
1287
1227
|
setStatus('Could not start');
|
|
1228
|
+
toParent({ type: 'compare-demo-error', error: message.error });
|
|
1288
1229
|
}
|
|
1289
1230
|
});
|
|
1290
1231
|
socket.addEventListener('close', () => {
|
|
1291
1232
|
clearInterval(fileTimer);
|
|
1292
|
-
$('hosted-start').disabled = true;
|
|
1293
1233
|
$('finish').disabled = true;
|
|
1234
|
+
if (!lastReport) $('add-to-gallery').disabled = true;
|
|
1294
1235
|
if (!$('results').hidden) return;
|
|
1295
1236
|
setStatus('Session ended');
|
|
1296
|
-
|
|
1237
|
+
toParent({ type: 'compare-demo-ended', error: launchError });
|
|
1297
1238
|
});
|
|
1298
1239
|
});
|
|
1299
1240
|
window.parent.postMessage({ type: 'compare-demo-ready' }, config.parentOrigin);
|
|
@@ -1329,6 +1270,50 @@
|
|
|
1329
1270
|
$('right-variant').value = `penny-${config.defaultHarness}`;
|
|
1330
1271
|
$('left-cwd').value = config.folders.left;
|
|
1331
1272
|
$('right-cwd').value = config.folders.right;
|
|
1273
|
+
// Options a pane supports beyond its harness, under its picker (see pane-options.mjs).
|
|
1274
|
+
const optionControls = { left: [], right: [] };
|
|
1275
|
+
for (const side of sides) {
|
|
1276
|
+
const details = document.createElement('details');
|
|
1277
|
+
details.hidden = true;
|
|
1278
|
+
details.append(document.createElement('summary'));
|
|
1279
|
+
for (const option of config.paneOptions || []) {
|
|
1280
|
+
const group = document.createElement('div');
|
|
1281
|
+
group.className = 'option';
|
|
1282
|
+
const label = document.createElement('label');
|
|
1283
|
+
label.append(document.createTextNode(option.label));
|
|
1284
|
+
const select = document.createElement('select');
|
|
1285
|
+
for (const choice of option.choices) select.add(new Option(choice.label, choice.value));
|
|
1286
|
+
select.value = option.default;
|
|
1287
|
+
label.append(select);
|
|
1288
|
+
const hint = document.createElement('p');
|
|
1289
|
+
hint.className = 'hint';
|
|
1290
|
+
hint.textContent = option.hint;
|
|
1291
|
+
group.append(label, hint);
|
|
1292
|
+
details.append(group);
|
|
1293
|
+
optionControls[side].push({ option, select, group });
|
|
1294
|
+
}
|
|
1295
|
+
$(`${side}-options`).append(details);
|
|
1296
|
+
}
|
|
1297
|
+
const supportedControls = (side, variant) =>
|
|
1298
|
+
optionControls[side].filter(({ option }) => option.variants.includes(variant));
|
|
1299
|
+
const optionSummary = (side, variant) => supportedControls(side, variant).map(({ option, select }) =>
|
|
1300
|
+
option.choices.find((choice) => choice.value === select.value)?.summary).filter(Boolean).join(' · ');
|
|
1301
|
+
const refreshOptions = () => {
|
|
1302
|
+
for (const side of sides) {
|
|
1303
|
+
const variant = $(`${side}-variant`).value;
|
|
1304
|
+
const details = $(`${side}-options`).firstElementChild;
|
|
1305
|
+
const supported = supportedControls(side, variant);
|
|
1306
|
+
details.hidden = !supported.length;
|
|
1307
|
+
for (const control of optionControls[side]) control.group.hidden = !supported.includes(control);
|
|
1308
|
+
const summary = details.firstElementChild;
|
|
1309
|
+
summary.replaceChildren(`${side === 'left' ? 'Left' : 'Right'} options `);
|
|
1310
|
+
const choice = document.createElement('span');
|
|
1311
|
+
choice.className = 'choice';
|
|
1312
|
+
choice.textContent = `· ${optionSummary(side, variant)}`;
|
|
1313
|
+
summary.append(choice);
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
for (const side of sides) for (const { select } of optionControls[side]) select.addEventListener('change', refreshOptions);
|
|
1332
1317
|
let modelFamily = 'claude';
|
|
1333
1318
|
const refreshVariants = () => {
|
|
1334
1319
|
const left = $('left-variant').value;
|
|
@@ -1346,6 +1331,7 @@
|
|
|
1346
1331
|
modelSuggestions = family ? [{ value: '', label: 'Harness default' },
|
|
1347
1332
|
...modelChoices[family].map(([value, label]) => ({ value, label }))] : [];
|
|
1348
1333
|
model.placeholder = family ? 'Harness default' : 'Pick panes from the same harness to choose a model';
|
|
1334
|
+
refreshOptions();
|
|
1349
1335
|
refreshPickers();
|
|
1350
1336
|
};
|
|
1351
1337
|
$('left-variant').onchange = refreshVariants;
|
|
@@ -1371,8 +1357,14 @@
|
|
|
1371
1357
|
model: comboValue($('model'), modelSuggestions),
|
|
1372
1358
|
};
|
|
1373
1359
|
for (const side of sides) {
|
|
1374
|
-
|
|
1375
|
-
|
|
1360
|
+
const { variant } = settings[side];
|
|
1361
|
+
settings[side].options = Object.fromEntries(supportedControls(side, variant)
|
|
1362
|
+
.map(({ option, select }) => [option.id, select.value]));
|
|
1363
|
+
$(`${side}-label`).textContent = settings[side].label || roleLabel(variant);
|
|
1364
|
+
paneRoles[side] = roleLabel(variant);
|
|
1365
|
+
const chip = $(`${side}-chip`);
|
|
1366
|
+
chip.textContent = optionSummary(side, variant);
|
|
1367
|
+
chip.hidden = !chip.textContent;
|
|
1376
1368
|
}
|
|
1377
1369
|
pending = true;
|
|
1378
1370
|
$('local-start').disabled = true;
|
|
@@ -1415,6 +1407,7 @@
|
|
|
1415
1407
|
clearInterval(fileTimer);
|
|
1416
1408
|
$('sync').disabled = true;
|
|
1417
1409
|
$('local-start').disabled = true;
|
|
1410
|
+
if (!lastReport) $('add-to-gallery').disabled = true;
|
|
1418
1411
|
if (!$('finish').disabled) {
|
|
1419
1412
|
$('finish').disabled = true;
|
|
1420
1413
|
setStatus('Disconnected · reload to reconnect');
|
|
@@ -25,7 +25,7 @@ export async function savedPennyKey() {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** Sends the report, the chosen pages, and an optional video; returns the new entry's id. A
|
|
28
|
+
/** Sends the report, the chosen pages (each optional), and an optional video; returns the new entry's id. A
|
|
29
29
|
* report signed by its demo sandbox (the subscription launcher) carries that signature. */
|
|
30
30
|
export async function publishToGallery(report, form, { signature = '', reportNonce = '' } = {}) {
|
|
31
31
|
const key = await savedPennyKey();
|
|
@@ -37,8 +37,7 @@ export async function publishToGallery(report, form, { signature = '', reportNon
|
|
|
37
37
|
outgoing.set('meta', String(form.get('meta') ?? ''));
|
|
38
38
|
for (const side of ['native', 'penny']) {
|
|
39
39
|
const page = form.get(side);
|
|
40
|
-
if (
|
|
41
|
-
outgoing.set(side, page, `${side}.html`);
|
|
40
|
+
if (page instanceof Blob) outgoing.set(side, page, `${side}.html`);
|
|
42
41
|
}
|
|
43
42
|
const cover = form.get('cover');
|
|
44
43
|
if (cover instanceof Blob && cover.size) outgoing.set('cover', cover, 'cover');
|
|
@@ -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,
|