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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.44",
3
+ "version": "0.3.46",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -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
  }
@@ -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="Available after you finish the comparison">Add to gallery</button>
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="hosted-setup" hidden>
245
- <span class="eyebrow">Live demo</span>
246
- <h2>How well does PennyRouter work?</h2>
247
- <p class="lede">Two real terminals, one native and one through PennyRouter, with the actual spend side by side.</p>
248
- <p class="notice"><strong>Use a temporary, dedicated API key with a small spend limit.</strong>
249
- Your key is used only to run this demo, is not saved to our database, and is discarded
250
- when this session ends.</p>
251
- <label>Compare
252
- <select id="harness"><option value="claude">Claude Code</option><option value="codex">Codex</option></select></label>
253
- <label><span id="key-label">Anthropic API key</span>
254
- <input type="password" id="key" autocomplete="off" spellcheck="false" required></label>
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
- $('finish').onclick = async () => {
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 section lives in the hosted form and moves into the local one.
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
- const publishBlocker = () => {
990
- if (!gallery) return 'Publishing needs a native pane and a Penny pane on the same harness.';
991
- if (!snapshot[sideOf('NATIVE')].length || !snapshot[sideOf('PENNY')].length) {
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
- const chosen = (role) => snapshot[sideOf(role)][Number($(role === 'NATIVE' ? 'publish-native' : 'publish-penny').value) || 0];
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
- const cover = chosen(checkedValue('publish-cover') === 'native' ? 'NATIVE' : 'PENNY');
1063
- const key = `${checkedValue('publish-cover')}:${cover?.path}`;
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 = openPublish;
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.path, penny_file: penny.path, cover: checkedValue('publish-cover'),
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.content, penny: penny.content, video,
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') showReportError(message.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 parent page supplies the session token, then the key form starts both panes.
1183
- $('setup').hidden = false;
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
- let progressTimer;
1188
- const clearProgress = () => {
1189
- clearInterval(progressTimer);
1190
- $('progress').replaceChildren();
1191
- };
1192
- const showProgress = (lead, steps = [], current = -1) => {
1193
- clearInterval(progressTimer);
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
- $('hosted-error').textContent = '';
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
- setStatus('Ready for your key');
1276
- $('hosted-start').disabled = false;
1216
+ authenticated = true;
1217
+ setStatus('Ready');
1218
+ toParent({ type: 'compare-demo-authenticated' });
1277
1219
  } else if (message.type === 'progress') {
1278
- showProgress('Key received. Preparing your terminals…', message.steps, message.current);
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
- window.parent.postMessage({ type: 'compare-demo-ended', error: launchError }, config.parentOrigin);
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
- $(`${side}-label`).textContent = settings[side].label || roleLabel(settings[side].variant);
1375
- paneRoles[side] = roleLabel(settings[side].variant);
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 (!(page instanceof Blob)) throw new Error('Choose a native page and a Penny page.');
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,