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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.44",
3
+ "version": "0.3.45",
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>
@@ -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
- $('finish').onclick = async () => {
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 section lives in the hosted form and moves into the local one.
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 = openPublish;
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') showReportError(message.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 parent page supplies the session token, then the key form starts both panes.
1183
- $('setup').hidden = false;
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
- 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-".`;
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
- $('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;
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
- setStatus('Ready for your key');
1276
- $('hosted-start').disabled = false;
1197
+ authenticated = true;
1198
+ setStatus('Ready');
1199
+ toParent({ type: 'compare-demo-authenticated' });
1277
1200
  } else if (message.type === 'progress') {
1278
- showProgress('Key received. Preparing your terminals…', message.steps, message.current);
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
- window.parent.postMessage({ type: 'compare-demo-ended', error: launchError }, config.parentOrigin);
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
- $(`${side}-label`).textContent = settings[side].label || roleLabel(settings[side].variant);
1375
- paneRoles[side] = roleLabel(settings[side].variant);
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,