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.
- package/README.md +34 -0
- package/package.json +2 -2
- package/src/cli.js +5 -0
- package/src/launch.js +10 -1
- package/src/remote.js +40 -0
- package/src/remote.test.js +37 -0
- package/standalone/remote/rc_echo.py +298 -0
- package/standalone/remote/rc_penny.py +205 -0
- package/standalone/remote/rc_project.py +120 -0
- package/standalone/remote/rc_restart.py +144 -0
- package/standalone/remote/rc_tools.py +171 -0
- package/standalone/web-demo/claude-meter.mjs +18 -3
- package/standalone/web-demo/compare.html +58 -31
- package/standalone/web-demo/gallery-submit.mjs +2 -3
|
@@ -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 =
|
|
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 ||
|
|
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
|
-
|
|
815
|
-
|
|
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[
|
|
818
|
-
field.append(amounts[
|
|
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 =
|
|
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:
|
|
858
|
-
fresh_input:
|
|
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
|
-
|
|
862
|
-
|
|
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 =
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1041
|
-
const
|
|
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
|
|
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
|
|
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 (
|
|
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');
|