pennyrouter 0.3.45 → 0.3.47

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.
@@ -84,6 +84,10 @@ function emptyTotals() {
84
84
  fresh_input: 0, output: 0, reasoning_output: 0, total: 0, cost: 0 };
85
85
  }
86
86
 
87
+ function emptySideCalls() {
88
+ return { count: 0, cost: 0, cache_read: 0, cache_write: 0, fresh_input: 0, output: 0, total: 0, models: {} };
89
+ }
90
+
87
91
  export class ClaudeMeter {
88
92
  constructor({ sessionId, cwd, side, role, label, variant, pricingProfile = 'standard', options = {} }) {
89
93
  this.sessionId = sessionId;
@@ -106,7 +110,7 @@ export class ClaudeMeter {
106
110
  this.transcriptPrints = new Map();
107
111
  this.chargedPrints = new Map();
108
112
  this.telemetry = new Map();
109
- this.sideCalls = { count: 0, cost: 0, models: {} };
113
+ this.sideCalls = emptySideCalls();
110
114
  this.localReplyIds = new Set();
111
115
  }
112
116
 
@@ -162,6 +166,11 @@ export class ClaudeMeter {
162
166
  byModel.calls += 1; byModel.tokens += total; byModel.cost += cost;
163
167
  this.sideCalls.count += 1;
164
168
  this.sideCalls.cost += cost;
169
+ this.sideCalls.cache_read += read;
170
+ this.sideCalls.cache_write += write;
171
+ this.sideCalls.fresh_input += input;
172
+ this.sideCalls.output += output;
173
+ this.sideCalls.total += total;
165
174
  }
166
175
  }
167
176
 
@@ -287,11 +296,17 @@ export class ClaudeMeter {
287
296
  } finally { await handle.close(); }
288
297
  }
289
298
 
299
+ // `totals` is everything billed; `main` leaves out the side calls.
290
300
  snapshot() {
301
+ const side = this.sideCalls;
302
+ const main = { ...this.totals, cache_read: this.totals.cache_read - side.cache_read,
303
+ cache_write: this.totals.cache_write - side.cache_write, write_5m: this.totals.write_5m - side.cache_write,
304
+ fresh_input: this.totals.fresh_input - side.fresh_input, output: this.totals.output - side.output,
305
+ total: this.totals.total - side.total, cost: this.totals.cost - side.cost };
291
306
  return { side: this.side, role: this.role, label: this.label, variant: this.variant, options: this.options,
292
307
  harness: 'claude-code', cwd: this.cwd, pricing_profile: this.pricingProfile, session_id: this.sessionId,
293
308
  transcript_path: this.transcriptPath, model: this.model, call_count: this.calls.length,
294
- totals: { ...this.totals }, local_replies: this.localReplyIds.size,
309
+ totals: { ...this.totals }, main, local_replies: this.localReplyIds.size,
295
310
  side_calls: { ...this.sideCalls, models: structuredClone(this.sideCalls.models) } };
296
311
  }
297
312
  }
@@ -454,7 +469,7 @@ export function galleryReport(left, right, { environment, harnessVersions }) {
454
469
  const penny = sides.find((side) => side.role === 'PENNY');
455
470
  if (!native || !penny || native.harness !== penny.harness) return null;
456
471
  const side = (snapshot) => ({ model: snapshot.model, call_count: snapshot.call_count, totals: snapshot.totals,
457
- side_calls: snapshot.side_calls || { count: 0, cost: 0, models: {} } });
472
+ side_calls: snapshot.side_calls || emptySideCalls() });
458
473
  return JSON.stringify({ version: 1, harness: native.harness,
459
474
  harness_version: harnessVersions?.[native.harness === 'codex' ? 'codex' : 'claude'] || '',
460
475
  environment, native: side(native), penny: side(penny), finished_at: new Date().toISOString() });
@@ -31,6 +31,7 @@
31
31
  .bar .sync-on { color: var(--accent); border-color: #547a50; background: var(--accent-wash); }
32
32
  .bar .sync-off { color: #a0a0aa; border-color: #62626d; background: #292930; }
33
33
  #delta { color: var(--accent); }
34
+ .pane header #delta { margin-left: auto; white-space: nowrap; }
34
35
  #versions { margin-left: auto; color: #92929c; font-size: 12px; }
35
36
  #status { color: #b8b8c3; }
36
37
  @media (max-width: 760px) { .bar { overflow-x: auto; } #versions { display: none; } }
@@ -84,7 +85,7 @@
84
85
  .meter .field.read strong { color: var(--accent); }
85
86
  .meter .field.write strong { color: var(--warn); }
86
87
  .meter .field.in strong, .meter .field.out strong { color: #75ec45; }
87
- .meter .field.side { color: #77777e; }
88
+ .meter .field.main, .meter .field.side { color: #77777e; }
88
89
  .meter .wait { display: none; color: #77777e; }
89
90
  .meter.waiting .wait { display: block; }
90
91
  .meter.waiting .field { display: none; }
@@ -214,7 +215,6 @@
214
215
  <button id="finish" disabled>Finish comparison</button>
215
216
  <button id="download-logs" hidden>Download logs</button>
216
217
  <button id="add-to-gallery" disabled title="Finish this comparison and share it in the PennyRouter gallery">Add to gallery</button>
217
- <span id="delta" aria-live="polite"></span>
218
218
  <span id="versions"></span>
219
219
  <span id="status">Connecting…</span>
220
220
  </div>
@@ -225,6 +225,7 @@
225
225
  <div class="file-list" id="left-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
226
226
  </header><div class="meter" id="left-meter"></div><div class="terminal" id="left"></div></section>
227
227
  <section class="pane" id="right-pane"><header><strong class="label" id="right-label">PENNY</strong><span class="chip" id="right-chip" hidden></span>
228
+ <span id="delta" aria-live="polite"></span>
228
229
  <div class="files" id="right-files"><button class="newest" id="right-newest" hidden></button>
229
230
  <button class="count" id="right-files-count" aria-expanded="false" aria-controls="right-file-list">No files</button>
230
231
  <div class="file-list" id="right-file-list" hidden><p>HTML files the agent writes will appear here.</p></div></div>
@@ -315,6 +316,7 @@
315
316
  <div class="segmented" role="radiogroup" aria-label="Featured page">
316
317
  <label><input type="radio" name="publish-cover" value="penny" checked><span>Penny page</span></label>
317
318
  <label><input type="radio" name="publish-cover" value="native"><span>Native page</span></label>
319
+ <label><input type="radio" name="publish-cover" value="none"><span>No page</span></label>
318
320
  </div>
319
321
  <label>Your name, as shown publicly
320
322
  <input id="publish-name" maxlength="40" required autocomplete="nickname"></label>
@@ -800,6 +802,9 @@
800
802
  // Same abbreviation as hud.py: 1.2k, 3.45M.
801
803
  const abbreviate = (value) => value >= 1_000_000 ? `${(value / 1_000_000).toFixed(2)}M`
802
804
  : value >= 1_000 ? `${(value / 1_000).toFixed(1)}k` : String(value);
805
+ // Cents alone would round a cheap side call to $0.00.
806
+ const dollars = (value) => `$${value.toLocaleString('en-US',
807
+ { minimumFractionDigits: value < 1 ? 4 : 2, maximumFractionDigits: value < 1 ? 4 : 2 })}`;
803
808
  const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
804
809
  const meters = {};
805
810
  const buildMeter = (side) => {
@@ -811,15 +816,16 @@
811
816
  model.append(role, name);
812
817
  root.append(model);
813
818
  const amounts = {};
814
- for (const label of ['read', 'write', 'in', 'out', 'total', 'side', 'cost']) {
815
- const field = document.createElement('span'); field.className = `field ${label}`;
819
+ // Token rows count the main thread only. Background calls Claude Code makes outside the
820
+ // transcript (auto-mode reviewer, titles) show only as `side` cost; the last row is main + side.
821
+ for (const [key, label] of [['read', 'read'], ['write', 'write'], ['in', 'in'], ['out', 'out'],
822
+ ['total', 'total'], ['main', 'main'], ['side', 'side'], ['cost', 'total']]) {
823
+ const field = document.createElement('span'); field.className = `field ${key}`;
816
824
  field.append(document.createTextNode(`${label} `));
817
- amounts[label] = document.createElement('strong');
818
- field.append(amounts[label]);
825
+ amounts[key] = document.createElement('strong');
826
+ field.append(amounts[key]);
819
827
  root.append(field);
820
828
  }
821
- // Background calls (auto-mode reviewer, titles) that Claude Code makes outside the transcript.
822
- amounts.side.parentElement.hidden = true;
823
829
  // Zeros would read as a measurement; the pane has simply not seen a turn yet.
824
830
  const wait = document.createElement('span'); wait.className = 'wait'; wait.textContent = 'waiting for first turn';
825
831
  root.append(wait);
@@ -843,7 +849,7 @@
843
849
  amounts.out.textContent = abbreviate(rolled.output);
844
850
  amounts.total.textContent = abbreviate(rolled.cache_read + rolled.cache_write +
845
851
  rolled.fresh_input + rolled.output);
846
- amounts.cost.textContent = `$${meter.cost.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
852
+ amounts.cost.textContent = dollars(meter.cost);
847
853
  if (Object.keys(rolled).some((key) => rolled[key] !== meter.targets[key])) settled = false;
848
854
  }
849
855
  if (settled) lastFrame = 0;
@@ -852,15 +858,15 @@
852
858
  const showMeter = (snapshot) => {
853
859
  const meter = meters[snapshot.side] ||= buildMeter(snapshot.side);
854
860
  const { totals } = snapshot;
861
+ const main = snapshot.main || totals;
855
862
  meter.role.textContent = snapshot.label || snapshot.role;
856
863
  meter.name.textContent = (snapshot.model || 'waiting').split('/').pop();
857
- meter.targets = { cache_read: totals.cache_read, cache_write: totals.cache_write,
858
- fresh_input: totals.fresh_input, output: totals.output };
864
+ meter.targets = { cache_read: main.cache_read, cache_write: main.cache_write,
865
+ fresh_input: main.fresh_input, output: main.output };
859
866
  meter.cost = totals.cost;
860
867
  const side = snapshot.side_calls;
861
- const sideField = meter.amounts.side.parentElement;
862
- sideField.hidden = !side?.count;
863
- if (side?.count) meter.amounts.side.textContent = `${side.count} · $${side.cost.toFixed(2)}`;
868
+ meter.amounts.main.textContent = dollars(main.cost);
869
+ meter.amounts.side.textContent = dollars(side?.cost || 0);
864
870
  meter.root.classList.add('has-data');
865
871
  meter.root.classList.toggle('waiting', !totals.total);
866
872
  if (!frameRequest) frameRequest = requestAnimationFrame(paintMeters);
@@ -869,8 +875,10 @@
869
875
  const native = meterSnapshots.left?.totals;
870
876
  const penny = meterSnapshots.right?.totals;
871
877
  if (!native || !penny) return;
878
+ const nativeMain = meterSnapshots.left.main || native;
879
+ const pennyMain = meterSnapshots.right.main || penny;
872
880
  const cost = native.cost ? (native.cost - penny.cost) / native.cost * 100 : 0;
873
- const tokens = native.total ? (native.total - penny.total) / native.total * 100 : 0;
881
+ const tokens = nativeMain.total ? (nativeMain.total - pennyMain.total) / nativeMain.total * 100 : 0;
874
882
  $('delta').textContent =
875
883
  `${cost > 0 ? '-' : '+'}${Math.abs(cost).toFixed(0)}% cost ${tokens > 0 ? '-' : '+'}${Math.abs(tokens).toFixed(0)}% tok`;
876
884
  };
@@ -962,13 +970,9 @@
962
970
  }
963
971
  }
964
972
  const sideOf = (role) => (paneRoles.left === role ? 'left' : 'right');
965
- const publishBlocker = () => {
966
- if (!gallery) return 'Publishing needs a native pane and a Penny pane on the same harness.';
967
- if (!snapshot[sideOf('NATIVE')].length || !snapshot[sideOf('PENNY')].length) {
968
- return 'Publishing needs an HTML page in each pane.';
969
- }
970
- return '';
971
- };
973
+ // Pages are optional: a comparison can be shared with just its numbers and a screenshot.
974
+ const publishBlocker = () =>
975
+ gallery ? '' : 'Publishing needs a native pane and a Penny pane on the same harness.';
972
976
  const prepareGallery = (message) => {
973
977
  lastReport = message;
974
978
  gallery = message.gallery || null;
@@ -993,7 +997,22 @@
993
997
  const pagePolicy = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; " +
994
998
  "font-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
995
999
  const sandboxed = (html) => `<meta http-equiv="Content-Security-Policy" content="${pagePolicy}"><meta name="referrer" content="no-referrer">${html}`;
996
- const chosen = (role) => snapshot[sideOf(role)][Number($(role === 'NATIVE' ? 'publish-native' : 'publish-penny').value) || 0];
1000
+ // The page chosen for a side, or undefined when "No page" is selected.
1001
+ const chosen = (role) => {
1002
+ const value = $(role === 'NATIVE' ? 'publish-native' : 'publish-penny').value;
1003
+ return value === '' ? undefined : snapshot[sideOf(role)][Number(value)];
1004
+ };
1005
+ // The featured page must be one that is shared; fall back to the other page, then to none.
1006
+ const syncFeatured = () => {
1007
+ const available = { penny: Boolean(chosen('PENNY')), native: Boolean(chosen('NATIVE')) };
1008
+ for (const radio of document.querySelectorAll('input[name="publish-cover"]')) {
1009
+ radio.disabled = radio.value !== 'none' && !available[radio.value];
1010
+ }
1011
+ if (document.querySelector('input[name="publish-cover"]:checked').disabled) {
1012
+ const next = available.penny ? 'penny' : available.native ? 'native' : 'none';
1013
+ document.querySelector(`input[name="publish-cover"][value="${next}"]`).checked = true;
1014
+ }
1015
+ };
997
1016
  const checkedValue = (name) => document.querySelector(`input[name="${name}"]:checked`).value;
998
1017
  const savingsText = (savings) => {
999
1018
  if (!savings) return '';
@@ -1037,8 +1056,11 @@
1037
1056
  $('preview-savings').classList.toggle('loss', Boolean(savings && savings.cost < 0));
1038
1057
  const model = (meterSnapshots[sideOf('PENNY')]?.model || '').split('/').pop();
1039
1058
  $('preview-meta').textContent = [model, $('publish-name').value.trim()].filter(Boolean).join(' · ');
1040
- const cover = chosen(checkedValue('publish-cover') === 'native' ? 'NATIVE' : 'PENNY');
1041
- const key = `${checkedValue('publish-cover')}:${cover?.path}`;
1059
+ syncFeatured();
1060
+ const featured = checkedValue('publish-cover');
1061
+ const cover = featured === 'none' ? undefined : chosen(featured === 'native' ? 'NATIVE' : 'PENNY');
1062
+ $('publish-preview').hidden = !cover;
1063
+ const key = `${featured}:${cover?.path}`;
1042
1064
  if (cover && key !== previewedCover) {
1043
1065
  previewedCover = key;
1044
1066
  $('publish-preview').srcdoc = sandboxed(cover.content);
@@ -1048,7 +1070,9 @@
1048
1070
  for (const [role, id] of [['NATIVE', 'publish-native'], ['PENNY', 'publish-penny']]) {
1049
1071
  const select = $(id);
1050
1072
  if (!select.options.length) {
1073
+ select.add(new Option('No page', ''));
1051
1074
  snapshot[sideOf(role)].forEach((file, index) => select.add(new Option(file.path, String(index))));
1075
+ select.value = snapshot[sideOf(role)].length ? '0' : '';
1052
1076
  }
1053
1077
  }
1054
1078
  if (!$('publish-name').value) $('publish-name').value = remembered('penny-compare-display-name');
@@ -1083,6 +1107,7 @@
1083
1107
  if (published) return;
1084
1108
  const native = chosen('NATIVE');
1085
1109
  const penny = chosen('PENNY');
1110
+ const featured = checkedValue('publish-cover');
1086
1111
  const video = $('publish-video').files[0] || null;
1087
1112
  const videoType = video && (video.type || (/\.webm$/i.test(video.name) ? 'video/webm' : /\.mp4$/i.test(video.name) ? 'video/mp4' : ''));
1088
1113
  $('publish-error').textContent = '';
@@ -1096,7 +1121,8 @@
1096
1121
  }
1097
1122
  const meta = { title: $('publish-title').value.trim(), comment: $('publish-comment').value.trim(),
1098
1123
  tag: checkedValue('publish-tag'), display_name: $('publish-name').value.trim(),
1099
- native_file: native.path, penny_file: penny.path, cover: checkedValue('publish-cover'),
1124
+ native_file: native?.path ?? null, penny_file: penny?.path ?? null,
1125
+ cover: featured === 'none' ? null : featured,
1100
1126
  video_type: video ? videoType : null };
1101
1127
  const submit = $('publish-submit');
1102
1128
  submit.disabled = true;
@@ -1106,13 +1132,13 @@
1106
1132
  try {
1107
1133
  if (hosted) {
1108
1134
  result = await relayToParent({ report: gallery.report, signature: gallery.signature,
1109
- reportNonce: gallery.reportNonce, meta, native: native.content, penny: penny.content, video,
1135
+ reportNonce: gallery.reportNonce, meta, native: native?.content ?? null, penny: penny?.content ?? null, video,
1110
1136
  cover: coverImage });
1111
1137
  } else {
1112
1138
  const form = new FormData();
1113
1139
  form.set('meta', JSON.stringify(meta));
1114
- form.set('native', new Blob([native.content], { type: 'text/html' }), 'native.html');
1115
- form.set('penny', new Blob([penny.content], { type: 'text/html' }), 'penny.html');
1140
+ if (native) form.set('native', new Blob([native.content], { type: 'text/html' }), 'native.html');
1141
+ if (penny) form.set('penny', new Blob([penny.content], { type: 'text/html' }), 'penny.html');
1116
1142
  if (video) form.set('video', video, video.name);
1117
1143
  if (coverImage) form.set('cover', coverImage, 'cover');
1118
1144
  const response = await fetch('/gallery/submit', { method: 'POST',
@@ -1140,6 +1166,7 @@
1140
1166
  button.disabled = true;
1141
1167
  button.textContent = 'Submitted to gallery';
1142
1168
  }
1169
+ $('publish').hidden = true;
1143
1170
  };
1144
1171
 
1145
1172
  // Messages common to both servers
@@ -1173,8 +1200,8 @@
1173
1200
  window.addEventListener('message', (event) => {
1174
1201
  if (event.origin !== config.parentOrigin || event.source !== window.parent) return;
1175
1202
  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 });
1203
+ const { harness, model, nativeKey, pennyKey, environment: chosen } = event.data;
1204
+ send({ type: 'start', harness, model, nativeKey, pennyKey, environment: chosen });
1178
1205
  setStatus('Starting terminals…');
1179
1206
  return;
1180
1207
  }
@@ -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');