pennyrouter 0.3.51 → 0.3.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/standalone/web-demo/claude-meter.mjs +3 -3
- package/standalone/web-demo/compare.html +214 -10
- package/standalone/web-demo/gallery-submit.mjs +24 -0
- package/standalone/web-demo/server.mjs +34 -1
- package/standalone/remote/rc_echo.py +0 -298
- package/standalone/remote/rc_local.py +0 -110
- package/standalone/remote/rc_penny.py +0 -205
- package/standalone/remote/rc_project.py +0 -167
- package/standalone/remote/rc_restart.py +0 -145
- package/standalone/remote/rc_tools.py +0 -171
package/package.json
CHANGED
|
@@ -315,9 +315,9 @@ export class ClaudeMeter {
|
|
|
315
315
|
// Subagent threads sit beside the root and name their parent in session_meta, so the meter
|
|
316
316
|
// follows that lineage (as usage.related_transcripts does) and charges each thread's deltas once.
|
|
317
317
|
export class CodexMeter extends ClaudeMeter {
|
|
318
|
-
constructor(options) {
|
|
318
|
+
constructor({ codexHome = join(homedir(), '.codex'), ...options }) {
|
|
319
319
|
super(options);
|
|
320
|
-
this.root = join(
|
|
320
|
+
this.root = join(codexHome, 'sessions');
|
|
321
321
|
this.transcriptPath = null;
|
|
322
322
|
this.threads = new Map();
|
|
323
323
|
this.metas = new Map();
|
|
@@ -458,7 +458,7 @@ export async function saveReport(left, right, reportsDir, environment) {
|
|
|
458
458
|
const path = join(reportsDir, `compare-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
|
|
459
459
|
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`);
|
|
460
460
|
return { path, savings, native: { calls: native.call_count, totals: native.totals },
|
|
461
|
-
penny: { calls: penny.call_count, totals: penny.totals } };
|
|
461
|
+
penny: { calls: penny.call_count, totals: penny.totals }, report };
|
|
462
462
|
}
|
|
463
463
|
|
|
464
464
|
// The report a gallery submission carries (apps/web/lib/gallery.ts validates it and recomputes
|
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
.bar button:disabled { opacity: .45; cursor: default; }
|
|
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
|
+
.menu { position: relative; }
|
|
34
|
+
.bar .menu-toggle::after { content: '▾'; margin-left: 7px; font-size: 10px; }
|
|
35
|
+
.menu-list { position: absolute; top: calc(100% + 6px); left: 0; z-index: 8; min-width: 200px; padding: 4px;
|
|
36
|
+
display: flex; flex-direction: column; border: 1px solid var(--line-strong); border-radius: 8px;
|
|
37
|
+
background: var(--card); box-shadow: 0 8px 24px rgba(0, 0, 0, .45); }
|
|
38
|
+
.bar .menu-list button { border: 0; border-radius: 5px; padding: 7px 10px; text-align: left; white-space: nowrap; }
|
|
39
|
+
.bar .menu-list button:hover:not(:disabled), .bar .menu-list button:focus-visible { background: var(--accent-wash); }
|
|
33
40
|
#delta { color: var(--accent); }
|
|
34
41
|
.pane header #delta { margin-left: auto; white-space: nowrap; }
|
|
35
42
|
#versions { margin-left: auto; color: #92929c; font-size: 12px; }
|
|
@@ -158,6 +165,7 @@
|
|
|
158
165
|
@media (max-width: 760px) { .publish-layout { grid-template-columns: 1fr; } }
|
|
159
166
|
.actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
|
|
160
167
|
.actions .primary { width: auto; margin: 0; }
|
|
168
|
+
.actions.split > * { flex: 1; }
|
|
161
169
|
.card .secondary { padding: 11px 16px; border: 1px solid #3a3a44; border-radius: 8px; background: transparent;
|
|
162
170
|
color: var(--text); font-size: 13px; }
|
|
163
171
|
.publish-done { margin: 14px 0 0; color: var(--accent); }
|
|
@@ -212,9 +220,21 @@
|
|
|
212
220
|
<div class="bar"><strong>PENNY COMPARE</strong>
|
|
213
221
|
<button id="sync" class="sync-off" disabled aria-pressed="false"
|
|
214
222
|
title="Type into both panes at once (Ctrl/⌘+Shift+S)">Sync off</button>
|
|
215
|
-
<
|
|
223
|
+
<div class="menu">
|
|
224
|
+
<button id="run-example" class="menu-toggle" disabled aria-haspopup="menu" aria-expanded="false"
|
|
225
|
+
aria-controls="example-menu" title="Type a starter prompt into both panes">Run example</button>
|
|
226
|
+
<div class="menu-list" id="example-menu" role="menu" hidden></div>
|
|
227
|
+
</div>
|
|
228
|
+
<div class="menu">
|
|
229
|
+
<button id="end-session" class="menu-toggle" disabled aria-haspopup="menu" aria-expanded="false"
|
|
230
|
+
aria-controls="end-menu">End session</button>
|
|
231
|
+
<div class="menu-list" id="end-menu" role="menu" hidden>
|
|
232
|
+
<button id="add-to-gallery" role="menuitem" disabled title="End this session and share the comparison in the PennyRouter gallery">Submit to Gallery</button>
|
|
233
|
+
<button id="send-comment" role="menuitem" disabled title="End this session and send a private note to the PennyRouter team">Send Comment</button>
|
|
234
|
+
<button id="finish" role="menuitem" disabled>Exit Session</button>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
216
237
|
<button id="download-logs" hidden>Download logs</button>
|
|
217
|
-
<button id="add-to-gallery" disabled title="Finish this comparison and share it in the PennyRouter gallery">Add to gallery</button>
|
|
218
238
|
<span id="versions"></span>
|
|
219
239
|
<span id="status">Connecting…</span>
|
|
220
240
|
</div>
|
|
@@ -287,7 +307,10 @@
|
|
|
287
307
|
<div class="sides" id="results-sides"></div>
|
|
288
308
|
<div class="report-path" id="results-path"></div>
|
|
289
309
|
<p class="footnote" id="results-note"></p>
|
|
290
|
-
<
|
|
310
|
+
<div class="actions split">
|
|
311
|
+
<button class="secondary" id="results-publish" type="button" hidden>Add to gallery</button>
|
|
312
|
+
<button class="primary" id="results-close" type="button">Close tab</button>
|
|
313
|
+
</div>
|
|
291
314
|
</div>
|
|
292
315
|
</section>
|
|
293
316
|
|
|
@@ -325,6 +348,11 @@
|
|
|
325
348
|
<p class="hint" id="publish-cover-note">Without one, a screenshot is added during review.</p>
|
|
326
349
|
<label>Video (optional, MP4 or WebM, up to 100 MB)
|
|
327
350
|
<input id="publish-video" type="file" accept="video/mp4,video/webm"></label>
|
|
351
|
+
<label class="check-label"><input id="publish-include-transcript" type="checkbox">
|
|
352
|
+
Include the conversation transcript</label>
|
|
353
|
+
<p class="hint">PennyRouter has a zero-retention policy. The transcript is deleted when this session ends unless you choose to include it. Included transcripts are private to Gallery reviewers and can help verify Artifact links.</p>
|
|
354
|
+
<label id="publish-transcript-label" hidden>Transcript file (JSON or JSONL, up to 10 MB)
|
|
355
|
+
<input id="publish-transcript" type="file" accept=".json,.jsonl,application/json,text/plain" disabled></label>
|
|
328
356
|
</div>
|
|
329
357
|
<div class="preview-column">
|
|
330
358
|
<span class="field-label">Preview</span>
|
|
@@ -350,6 +378,26 @@
|
|
|
350
378
|
</form>
|
|
351
379
|
</section>
|
|
352
380
|
|
|
381
|
+
<section class="overlay" id="comment" hidden>
|
|
382
|
+
<form class="card" id="comment-form">
|
|
383
|
+
<span class="eyebrow">Penny Compare</span>
|
|
384
|
+
<h2>Send a comment</h2>
|
|
385
|
+
<p class="lede">A private note to the PennyRouter team. Nothing is published, and the session's cost numbers are attached when available.</p>
|
|
386
|
+
<label>Comment
|
|
387
|
+
<textarea id="comment-text" maxlength="4000" rows="6" required placeholder="What worked, what didn't, what you'd like us to look at?"></textarea></label>
|
|
388
|
+
<label class="check-label"><input id="comment-include-transcript" type="checkbox">
|
|
389
|
+
Include the conversation transcript</label>
|
|
390
|
+
<p class="hint">PennyRouter has a zero-retention policy. The transcript is deleted when this session ends unless you choose to include it. Included transcripts are sent privately to the PennyRouter team.</p>
|
|
391
|
+
<label id="comment-transcript-label" hidden>Transcript file (JSON or JSONL, up to 10 MB)
|
|
392
|
+
<input id="comment-transcript" type="file" accept=".json,.jsonl,application/json,text/plain" disabled></label>
|
|
393
|
+
<p class="error" id="comment-error" role="alert"></p>
|
|
394
|
+
<p class="publish-done" id="comment-done" role="status"></p>
|
|
395
|
+
<div class="actions">
|
|
396
|
+
<button class="secondary" id="comment-cancel" type="button">Cancel</button>
|
|
397
|
+
<button class="primary" id="comment-submit">Send comment</button>
|
|
398
|
+
</div>
|
|
399
|
+
</form>
|
|
400
|
+
</section>
|
|
353
401
|
<script src="/xterm.js"></script><script src="/addon-fit.js"></script>
|
|
354
402
|
<script src="/addon-unicode11.js"></script>
|
|
355
403
|
<script src="/terminal-unicode.js"></script>
|
|
@@ -611,6 +659,9 @@
|
|
|
611
659
|
$('sync').disabled = false;
|
|
612
660
|
$('finish').disabled = false;
|
|
613
661
|
$('add-to-gallery').disabled = false;
|
|
662
|
+
$('send-comment').disabled = false;
|
|
663
|
+
$('end-session').disabled = false;
|
|
664
|
+
$('run-example').disabled = false;
|
|
614
665
|
setStatus('Live');
|
|
615
666
|
resize();
|
|
616
667
|
terminals.left.focus();
|
|
@@ -618,11 +669,14 @@
|
|
|
618
669
|
};
|
|
619
670
|
let finishing = false;
|
|
620
671
|
let publishAfterFinish = false;
|
|
672
|
+
let commentAfterFinish = false;
|
|
621
673
|
const finishComparison = async () => {
|
|
622
674
|
if (finishing || lastReport || !started) return;
|
|
623
675
|
finishing = true;
|
|
624
676
|
$('finish').disabled = true;
|
|
625
677
|
$('add-to-gallery').disabled = true;
|
|
678
|
+
$('send-comment').disabled = true;
|
|
679
|
+
$('run-example').disabled = true;
|
|
626
680
|
setStatus('Saving files…');
|
|
627
681
|
clearInterval(fileTimer);
|
|
628
682
|
await pollFiles().catch(() => {});
|
|
@@ -632,6 +686,50 @@
|
|
|
632
686
|
};
|
|
633
687
|
$('finish').onclick = finishComparison;
|
|
634
688
|
|
|
689
|
+
// Starter prompts typed into both panes, exactly as a visitor would type them.
|
|
690
|
+
const examples = [
|
|
691
|
+
{ label: 'Game of Life (tests)', prompt: 'Using only Node built-ins (no installs): put Game of Life rules in life.mjs on a wrapping grid, write life.test.mjs with node:test covering a blinker, a block, a glider after 4 steps and edge wrapping, run node --test and fix until everything passes, then create life.html as a single self-contained page using the same engine code, with play, pause, step, randomize, a speed slider and click-to-toggle cells.' },
|
|
692
|
+
{ label: 'Sales dashboard (data)', prompt: 'Using only Node built-ins (no installs): write gen.mjs that writes sales.csv with 5000 orders from a seeded random generator (date, store, product, qty, unit_price) across 4 coffee shops over 12 months of 2025, run it, then write analyze.mjs that prints monthly revenue per store, the top 5 products by revenue and the busiest weekday, run it and check the store totals add up to the overall total, then build dashboard.html, a single file with those results embedded and drawn as inline SVG charts.' },
|
|
693
|
+
{ label: 'Maze solver (verify)', prompt: 'Using only Node built-ins (no installs): write maze.mjs with a seeded recursive-backtracker maze generator and a BFS solver, write check.mjs that generates 25x25 mazes for seeds 1 to 50 and verifies every cell is reachable and each solution path only moves through open walls, run it and fix anything that fails, then create maze.html, a single self-contained page that animates the maze being carved and then solved, with a seed box and a new-maze button.' },
|
|
694
|
+
{ label: 'Penalty kick (quick)', prompt: 'Create football-penalty.html: a single-file page with a playable penalty-kick game. Click to aim, a goalkeeper dives, and a score is kept.' },
|
|
695
|
+
];
|
|
696
|
+
const runExample = (prompt) => {
|
|
697
|
+
for (const side of sides) {
|
|
698
|
+
send({ type: 'take-control', side });
|
|
699
|
+
send({ type: 'input', side, data: prompt });
|
|
700
|
+
}
|
|
701
|
+
// Sent after the text so the agent's prompt reads it as typing followed by Enter, not a paste.
|
|
702
|
+
setTimeout(() => { for (const side of sides) send({ type: 'input', side, data: '\r' }); }, 250);
|
|
703
|
+
terminals.left.focus();
|
|
704
|
+
};
|
|
705
|
+
for (const example of examples) {
|
|
706
|
+
const item = document.createElement('button');
|
|
707
|
+
item.type = 'button';
|
|
708
|
+
item.setAttribute('role', 'menuitem');
|
|
709
|
+
item.textContent = example.label;
|
|
710
|
+
item.title = example.prompt;
|
|
711
|
+
item.onclick = () => runExample(example.prompt);
|
|
712
|
+
$('example-menu').append(item);
|
|
713
|
+
}
|
|
714
|
+
const menus = [['run-example', 'example-menu'], ['end-session', 'end-menu']];
|
|
715
|
+
const closeMenus = (except) => {
|
|
716
|
+
for (const [toggle, list] of menus) {
|
|
717
|
+
if (toggle === except) continue;
|
|
718
|
+
$(list).hidden = true;
|
|
719
|
+
$(toggle).setAttribute('aria-expanded', 'false');
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
for (const [toggle, list] of menus) {
|
|
723
|
+
$(toggle).onclick = (event) => {
|
|
724
|
+
event.stopPropagation();
|
|
725
|
+
closeMenus(toggle);
|
|
726
|
+
$(list).hidden = !$(list).hidden;
|
|
727
|
+
$(toggle).setAttribute('aria-expanded', String(!$(list).hidden));
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
document.addEventListener('click', () => closeMenus());
|
|
731
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeMenus(); });
|
|
732
|
+
|
|
635
733
|
// Files the agents write, previewed in a separate tab
|
|
636
734
|
const previewTabs = new Map();
|
|
637
735
|
const fileState = { left: { paths: null, known: null }, right: { paths: null, known: null } };
|
|
@@ -981,9 +1079,15 @@
|
|
|
981
1079
|
}
|
|
982
1080
|
$('results-publish').hidden = false;
|
|
983
1081
|
if (blocker) $('results-publish').textContent = blocker;
|
|
1082
|
+
$('send-comment').disabled = false;
|
|
984
1083
|
if (publishAfterFinish && !blocker) openPublish();
|
|
1084
|
+
if (commentAfterFinish) openComment();
|
|
985
1085
|
publishAfterFinish = false;
|
|
986
|
-
|
|
1086
|
+
commentAfterFinish = false;
|
|
1087
|
+
if (hosted) {
|
|
1088
|
+
window.parent.postMessage({ type: 'compare-demo-report', report: message.report }, config.parentOrigin);
|
|
1089
|
+
window.parent.postMessage({ type: 'compare-demo-finished' }, config.parentOrigin);
|
|
1090
|
+
}
|
|
987
1091
|
};
|
|
988
1092
|
const remembered = (key, value) => {
|
|
989
1093
|
try {
|
|
@@ -1065,6 +1169,10 @@
|
|
|
1065
1169
|
}
|
|
1066
1170
|
};
|
|
1067
1171
|
const openPublish = () => {
|
|
1172
|
+
$('publish-include-transcript').checked = false;
|
|
1173
|
+
$('publish-transcript-label').hidden = true;
|
|
1174
|
+
$('publish-transcript').disabled = true;
|
|
1175
|
+
$('publish-transcript').value = '';
|
|
1068
1176
|
for (const [role, id] of [['NATIVE', 'publish-native'], ['PENNY', 'publish-penny']]) {
|
|
1069
1177
|
const select = $(id);
|
|
1070
1178
|
if (!select.options.length) {
|
|
@@ -1085,20 +1193,34 @@
|
|
|
1085
1193
|
return finishComparison();
|
|
1086
1194
|
};
|
|
1087
1195
|
$('results-publish').onclick = openPublish;
|
|
1196
|
+
// A tab the user opened themselves cannot be closed by a page, so say so when it stays open.
|
|
1197
|
+
$('results-close').onclick = () => {
|
|
1198
|
+
if (hosted) window.parent.postMessage({ type: 'compare-demo-close' }, config.parentOrigin);
|
|
1199
|
+
else window.close();
|
|
1200
|
+
setTimeout(() => { $('results-note').textContent = 'Your browser could not automatically close tab. Please close manually.'; }, 400);
|
|
1201
|
+
};
|
|
1088
1202
|
$('publish-cancel').onclick = () => { $('publish').hidden = true; };
|
|
1089
1203
|
$('publish-form').addEventListener('input', updatePreview);
|
|
1090
1204
|
$('publish-form').addEventListener('change', updatePreview);
|
|
1091
|
-
|
|
1205
|
+
$('publish-include-transcript').onchange = (event) => {
|
|
1206
|
+
$('publish-transcript-label').hidden = !event.target.checked;
|
|
1207
|
+
$('publish-transcript').disabled = !event.target.checked;
|
|
1208
|
+
};
|
|
1209
|
+
$('comment-include-transcript').onchange = (event) => {
|
|
1210
|
+
$('comment-transcript-label').hidden = !event.target.checked;
|
|
1211
|
+
$('comment-transcript').disabled = !event.target.checked;
|
|
1212
|
+
};
|
|
1213
|
+
const relayToParent = (payload, kind = 'gallery') => new Promise((resolve) => {
|
|
1092
1214
|
const timer = setTimeout(() => { removeEventListener('message', onResult); resolve({ error: 'The website did not respond. Please try again.' }); }, 10 * 60_000);
|
|
1093
1215
|
const onResult = (event) => {
|
|
1094
1216
|
if (event.origin !== config.parentOrigin || event.source !== window.parent ||
|
|
1095
|
-
event.data?.type !==
|
|
1217
|
+
event.data?.type !== `compare-${kind}-result`) return;
|
|
1096
1218
|
clearTimeout(timer);
|
|
1097
1219
|
removeEventListener('message', onResult);
|
|
1098
1220
|
resolve(event.data);
|
|
1099
1221
|
};
|
|
1100
1222
|
addEventListener('message', onResult);
|
|
1101
|
-
window.parent.postMessage({ type:
|
|
1223
|
+
window.parent.postMessage({ type: `compare-${kind}-submit`, ...payload }, config.parentOrigin);
|
|
1102
1224
|
});
|
|
1103
1225
|
$('publish-form').onsubmit = async (event) => {
|
|
1104
1226
|
event.preventDefault();
|
|
@@ -1107,6 +1229,8 @@
|
|
|
1107
1229
|
const penny = chosen('PENNY');
|
|
1108
1230
|
const featured = checkedValue('publish-cover');
|
|
1109
1231
|
const video = $('publish-video').files[0] || null;
|
|
1232
|
+
const includeTranscript = $('publish-include-transcript').checked;
|
|
1233
|
+
const transcript = includeTranscript ? $('publish-transcript').files[0] || null : null;
|
|
1110
1234
|
const videoType = video && (video.type || (/\.webm$/i.test(video.name) ? 'video/webm' : /\.mp4$/i.test(video.name) ? 'video/mp4' : ''));
|
|
1111
1235
|
$('publish-error').textContent = '';
|
|
1112
1236
|
if (video && !['video/mp4', 'video/webm'].includes(videoType)) {
|
|
@@ -1117,6 +1241,14 @@
|
|
|
1117
1241
|
$('publish-error').textContent = 'Videos can be at most 100 MB.';
|
|
1118
1242
|
return;
|
|
1119
1243
|
}
|
|
1244
|
+
if (transcript && transcript.size > 10 * 1024 * 1024) {
|
|
1245
|
+
$('publish-error').textContent = 'Transcripts can be at most 10 MB.';
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
if (includeTranscript && !transcript) {
|
|
1249
|
+
$('publish-error').textContent = 'Choose a transcript file or clear the include transcript selection.';
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1120
1252
|
const meta = { title: $('publish-title').value.trim(), comment: $('publish-comment').value.trim(),
|
|
1121
1253
|
tag: checkedValue('publish-tag'), display_name: $('publish-name').value.trim(),
|
|
1122
1254
|
native_file: native?.path ?? null, penny_file: penny?.path ?? null,
|
|
@@ -1131,13 +1263,14 @@
|
|
|
1131
1263
|
if (hosted) {
|
|
1132
1264
|
result = await relayToParent({ report: gallery.report, signature: gallery.signature,
|
|
1133
1265
|
reportNonce: gallery.reportNonce, meta, native: native?.content ?? null, penny: penny?.content ?? null, video,
|
|
1134
|
-
cover: coverImage });
|
|
1266
|
+
cover: coverImage, transcript });
|
|
1135
1267
|
} else {
|
|
1136
1268
|
const form = new FormData();
|
|
1137
1269
|
form.set('meta', JSON.stringify(meta));
|
|
1138
1270
|
if (native) form.set('native', new Blob([native.content], { type: 'text/html' }), 'native.html');
|
|
1139
1271
|
if (penny) form.set('penny', new Blob([penny.content], { type: 'text/html' }), 'penny.html');
|
|
1140
1272
|
if (video) form.set('video', video, video.name);
|
|
1273
|
+
if (transcript) form.set('transcript', transcript, transcript.name);
|
|
1141
1274
|
if (coverImage) form.set('cover', coverImage, 'cover');
|
|
1142
1275
|
const response = await fetch('/gallery/submit', { method: 'POST',
|
|
1143
1276
|
headers: { Authorization: `Bearer ${gallery.publishToken}` }, body: form });
|
|
@@ -1167,6 +1300,78 @@
|
|
|
1167
1300
|
$('publish').hidden = true;
|
|
1168
1301
|
};
|
|
1169
1302
|
|
|
1303
|
+
// A private note to the team: ends the session like Submit to Gallery, then asks for the comment.
|
|
1304
|
+
const openComment = () => {
|
|
1305
|
+
$('comment-include-transcript').checked = false;
|
|
1306
|
+
$('comment-transcript-label').hidden = true;
|
|
1307
|
+
$('comment-transcript').disabled = true;
|
|
1308
|
+
$('comment-transcript').value = '';
|
|
1309
|
+
$('comment-done').textContent = '';
|
|
1310
|
+
$('comment-error').textContent = '';
|
|
1311
|
+
$('comment-submit').disabled = false;
|
|
1312
|
+
$('comment-submit').textContent = 'Send comment';
|
|
1313
|
+
$('comment-cancel').textContent = 'Cancel';
|
|
1314
|
+
$('comment').hidden = false;
|
|
1315
|
+
$('comment-text').focus();
|
|
1316
|
+
};
|
|
1317
|
+
$('send-comment').onclick = () => {
|
|
1318
|
+
if (lastReport) return openComment();
|
|
1319
|
+
commentAfterFinish = true;
|
|
1320
|
+
return finishComparison();
|
|
1321
|
+
};
|
|
1322
|
+
$('comment-cancel').onclick = () => { $('comment').hidden = true; };
|
|
1323
|
+
$('comment-form').onsubmit = async (event) => {
|
|
1324
|
+
event.preventDefault();
|
|
1325
|
+
const comment = $('comment-text').value.trim();
|
|
1326
|
+
if (!comment) return;
|
|
1327
|
+
const includeTranscript = $('comment-include-transcript').checked;
|
|
1328
|
+
const transcript = includeTranscript ? $('comment-transcript').files[0] || null : null;
|
|
1329
|
+
if (includeTranscript && !transcript) {
|
|
1330
|
+
$('comment-error').textContent = 'Choose a transcript file or clear the include transcript selection.';
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
if (transcript && transcript.size > 10 * 1024 * 1024) {
|
|
1334
|
+
$('comment-error').textContent = 'Transcripts can be at most 10 MB.';
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const submit = $('comment-submit');
|
|
1338
|
+
submit.disabled = true;
|
|
1339
|
+
submit.classList.add('busy');
|
|
1340
|
+
submit.textContent = 'Sending…';
|
|
1341
|
+
$('comment-error').textContent = '';
|
|
1342
|
+
let result;
|
|
1343
|
+
try {
|
|
1344
|
+
if (hosted) {
|
|
1345
|
+
result = await relayToParent({ comment, report: gallery?.report ?? '', transcript }, 'feedback');
|
|
1346
|
+
} else {
|
|
1347
|
+
const form = new FormData();
|
|
1348
|
+
form.set('comment', comment);
|
|
1349
|
+
if (transcript) form.set('transcript', transcript, transcript.name);
|
|
1350
|
+
const response = await fetch('/feedback/submit', { method: 'POST',
|
|
1351
|
+
headers: { ...(gallery?.publishToken ? { Authorization: `Bearer ${gallery.publishToken}` } : {}) }, body: form });
|
|
1352
|
+
result = await response.json().catch(() => ({ error: 'Could not send the comment.' }));
|
|
1353
|
+
if (!response.ok && !result.error) result.error = 'Could not send the comment.';
|
|
1354
|
+
}
|
|
1355
|
+
} catch {
|
|
1356
|
+
result = { error: 'Could not send the comment.' };
|
|
1357
|
+
}
|
|
1358
|
+
submit.classList.remove('busy');
|
|
1359
|
+
if (result.error) {
|
|
1360
|
+
$('comment-error').textContent = result.error;
|
|
1361
|
+
submit.disabled = false;
|
|
1362
|
+
submit.textContent = 'Send comment';
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
$('comment-text').value = '';
|
|
1366
|
+
$('comment-include-transcript').checked = false;
|
|
1367
|
+
$('comment-transcript-label').hidden = true;
|
|
1368
|
+
$('comment-transcript').disabled = true;
|
|
1369
|
+
$('comment-transcript').value = '';
|
|
1370
|
+
submit.textContent = 'Sent';
|
|
1371
|
+
$('comment-done').textContent = 'Sent to the PennyRouter team. Thank you.';
|
|
1372
|
+
$('comment-cancel').textContent = 'Close';
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1170
1375
|
// Messages common to both servers
|
|
1171
1376
|
const handleMessage = (message) => {
|
|
1172
1377
|
if (message.type === 'output') terminals[message.side]?.write(message.data);
|
|
@@ -1216,7 +1421,7 @@
|
|
|
1216
1421
|
$('download-logs').hidden = !message.captureLogs;
|
|
1217
1422
|
if (message.subscription) {
|
|
1218
1423
|
setStatus('Starting subscription test · costs are API-equivalent estimates');
|
|
1219
|
-
send({ type: 'start', harness: message.subscriptionHarness || 'claude' });
|
|
1424
|
+
send({ type: 'start', harness: message.subscriptionHarness || 'claude', model: message.subscriptionModel || '' });
|
|
1220
1425
|
return;
|
|
1221
1426
|
}
|
|
1222
1427
|
authenticated = true;
|
|
@@ -1350,7 +1555,6 @@
|
|
|
1350
1555
|
} else {
|
|
1351
1556
|
$('left-label').textContent = 'LEFT';
|
|
1352
1557
|
$('right-label').textContent = 'RIGHT';
|
|
1353
|
-
$('finish').textContent = 'End session';
|
|
1354
1558
|
}
|
|
1355
1559
|
let pending = false;
|
|
1356
1560
|
$('local-setup').addEventListener('submit', (event) => {
|
|
@@ -39,6 +39,11 @@ export async function publishToGallery(report, form, { signature = '', reportNon
|
|
|
39
39
|
const page = form.get(side);
|
|
40
40
|
if (page instanceof Blob) outgoing.set(side, page, `${side}.html`);
|
|
41
41
|
}
|
|
42
|
+
const transcript = form.get('transcript');
|
|
43
|
+
if (transcript instanceof Blob && transcript.size) {
|
|
44
|
+
if (transcript.size > 10 * 1024 * 1024) throw new Error('Transcripts can be at most 10 MB.');
|
|
45
|
+
outgoing.set('transcript', transcript, transcript.name || 'transcript.json');
|
|
46
|
+
}
|
|
42
47
|
const cover = form.get('cover');
|
|
43
48
|
if (cover instanceof Blob && cover.size) outgoing.set('cover', cover, 'cover');
|
|
44
49
|
const response = await fetch(`${appUrl}/api/gallery/submissions`, {
|
|
@@ -67,3 +72,22 @@ export async function publishToGallery(report, form, { signature = '', reportNon
|
|
|
67
72
|
return { id: body.id, videoError: 'Your submission was saved, but its video could not be uploaded.' };
|
|
68
73
|
}
|
|
69
74
|
}
|
|
75
|
+
|
|
76
|
+
/** Sends a private comment to the PennyRouter team, with the session's report when there is one. */
|
|
77
|
+
export async function sendFeedback(comment, report = '', transcript = null) {
|
|
78
|
+
const key = await savedPennyKey();
|
|
79
|
+
if (!key) throw new Error('Connect PennyRouter first: run `npx pennyrouter install`, then try again.');
|
|
80
|
+
const form = new FormData();
|
|
81
|
+
form.set('comment', comment);
|
|
82
|
+
form.set('report', report);
|
|
83
|
+
if (transcript instanceof Blob && transcript.size) {
|
|
84
|
+
if (transcript.size > 10 * 1024 * 1024) throw new Error('Transcripts can be at most 10 MB.');
|
|
85
|
+
form.set('transcript', transcript, transcript.name || 'transcript.json');
|
|
86
|
+
}
|
|
87
|
+
const response = await fetch(`${appUrl}/api/compare-feedback`, {
|
|
88
|
+
method: 'POST', headers: { authorization: `Bearer ${key}` }, body: form, signal: AbortSignal.timeout(60_000),
|
|
89
|
+
});
|
|
90
|
+
const body = await response.json().catch(() => ({}));
|
|
91
|
+
if (!response.ok) throw new Error(body.error || `The comment was not accepted (${response.status}).`);
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
@@ -9,7 +9,7 @@ import { promisify } from 'node:util';
|
|
|
9
9
|
import { WebSocketServer } from 'ws';
|
|
10
10
|
import pty from 'node-pty';
|
|
11
11
|
import { ClaudeMeter, CodexMeter, galleryReport, pricingProfiles, saveReport } from './claude-meter.mjs';
|
|
12
|
-
import { publishToGallery } from './gallery-submit.mjs';
|
|
12
|
+
import { publishToGallery, sendFeedback } from './gallery-submit.mjs';
|
|
13
13
|
import { applyPaneOptions, describePaneOptions, normalizePaneOptions } from './pane-options.mjs';
|
|
14
14
|
import { listArtifacts, readArtifact } from './artifacts.mjs';
|
|
15
15
|
import { readTelemetry, telemetryEnv, telemetryRoute } from './telemetry.mjs';
|
|
@@ -67,6 +67,10 @@ const server = createServer(async (request, response) => {
|
|
|
67
67
|
await submitToGallery(request, response);
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
|
+
if (url.pathname === '/feedback/submit') {
|
|
71
|
+
await submitFeedback(request, response);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
70
74
|
const telemetryMeter = telemetryMeters.get(url.pathname);
|
|
71
75
|
if (telemetryMeter && request.method === 'POST') {
|
|
72
76
|
try {
|
|
@@ -136,6 +140,35 @@ async function submitToGallery(request, response) {
|
|
|
136
140
|
}
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
async function submitFeedback(request, response) {
|
|
144
|
+
const reply = (status, body) => response.writeHead(status, { 'Content-Type': 'application/json',
|
|
145
|
+
'Cache-Control': 'no-store' }).end(JSON.stringify(body));
|
|
146
|
+
const allowedOrigins = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|
|
147
|
+
if (request.method !== 'POST' || !allowedOrigins.has(request.headers.origin)) {
|
|
148
|
+
reply(403, { error: 'Could not send the comment.' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const chunks = [];
|
|
153
|
+
let size = 0;
|
|
154
|
+
for await (const chunk of request) {
|
|
155
|
+
size += chunk.length;
|
|
156
|
+
if (size > 11 * 1024 * 1024) { reply(413, { error: 'The submission is too large.' }); return; }
|
|
157
|
+
chunks.push(chunk);
|
|
158
|
+
}
|
|
159
|
+
const form = await new Request('http://localhost/feedback/submit', { method: 'POST',
|
|
160
|
+
headers: { 'content-type': request.headers['content-type'] || '' },
|
|
161
|
+
body: Buffer.concat(chunks), duplex: 'half' }).formData();
|
|
162
|
+
const comment = form.get('comment');
|
|
163
|
+
if (typeof comment !== 'string' || !comment.trim()) { reply(400, { error: 'Write a comment first.' }); return; }
|
|
164
|
+
const token = request.headers.authorization?.replace(/^Bearer /, '');
|
|
165
|
+
const report = publishable.get(token) ?? '';
|
|
166
|
+
reply(201, await sendFeedback(comment.trim(), report, form.get('transcript')));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
reply(400, { error: error.message || 'Could not send the comment.' });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
139
172
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
|
|
140
173
|
server.on('upgrade', (request, socket, head) => {
|
|
141
174
|
const allowedOrigins = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|