pennyrouter 0.3.51 → 0.3.53

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.51",
3
+ "version": "0.3.53",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -105,6 +105,7 @@ export class ClaudeMeter {
105
105
  this.totals = emptyTotals();
106
106
  this.model = '';
107
107
  this.transcriptPath = null;
108
+ this.transcriptFiles = [];
108
109
  this.started = Date.now() / 1000;
109
110
  this.requestIds = new Set();
110
111
  this.transcriptPrints = new Map();
@@ -217,6 +218,7 @@ export class ClaudeMeter {
217
218
  }
218
219
  };
219
220
  await collect(join(transcript.slice(0, -6), 'subagents'));
221
+ this.transcriptFiles = paths;
220
222
  for (const path of paths) await this.readAppended(path);
221
223
  this.chargeSideCalls({ final });
222
224
  return this.snapshot();
@@ -315,9 +317,9 @@ export class ClaudeMeter {
315
317
  // Subagent threads sit beside the root and name their parent in session_meta, so the meter
316
318
  // follows that lineage (as usage.related_transcripts does) and charges each thread's deltas once.
317
319
  export class CodexMeter extends ClaudeMeter {
318
- constructor(options) {
320
+ constructor({ codexHome = join(homedir(), '.codex'), ...options }) {
319
321
  super(options);
320
- this.root = join(homedir(), '.codex', 'sessions');
322
+ this.root = join(codexHome, 'sessions');
321
323
  this.transcriptPath = null;
322
324
  this.threads = new Map();
323
325
  this.metas = new Map();
@@ -372,6 +374,7 @@ export class CodexMeter extends ClaudeMeter {
372
374
  added = true;
373
375
  }
374
376
  }
377
+ this.transcriptFiles = paths;
375
378
  for (const path of paths) await this.readRollout(path, path === root);
376
379
  return this.snapshot();
377
380
  }
@@ -458,7 +461,7 @@ export async function saveReport(left, right, reportsDir, environment) {
458
461
  const path = join(reportsDir, `compare-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
459
462
  await writeFile(path, `${JSON.stringify(report, null, 2)}\n`);
460
463
  return { path, savings, native: { calls: native.call_count, totals: native.totals },
461
- penny: { calls: penny.call_count, totals: penny.totals } };
464
+ penny: { calls: penny.call_count, totals: penny.totals }, report };
462
465
  }
463
466
 
464
467
  // 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; }
@@ -102,8 +109,7 @@
102
109
  .eyebrow { display: block; margin-bottom: 10px; color: var(--accent); font-size: 10px; font-weight: 600;
103
110
  letter-spacing: 1.8px; text-transform: uppercase; }
104
111
  .card h2 { margin: 0 0 8px; font-size: 20px; font-weight: 650; letter-spacing: -.4px; }
105
- .card .lede { margin: 0 0 20px; color: var(--muted); }
106
- color: #b4b4be; font-size: 12px; }
112
+ .card .lede { margin: 0 0 20px; color: #b4b4be; font-size: 12px; }
107
113
  .card label { display: block; margin: 14px 0 0; color: #b8b8c1; font-size: 12px; }
108
114
  .card input:not([type=checkbox]), .card select { display: block; width: 100%; min-width: 0; margin-top: 8px;
109
115
  padding: 10px 12px; border: 1px solid #3a3a44; border-radius: 8px; background: var(--field); color: #eee;
@@ -158,6 +164,7 @@
158
164
  @media (max-width: 760px) { .publish-layout { grid-template-columns: 1fr; } }
159
165
  .actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
160
166
  .actions .primary { width: auto; margin: 0; }
167
+ .actions.split > * { flex: 1; }
161
168
  .card .secondary { padding: 11px 16px; border: 1px solid #3a3a44; border-radius: 8px; background: transparent;
162
169
  color: var(--text); font-size: 13px; }
163
170
  .publish-done { margin: 14px 0 0; color: var(--accent); }
@@ -212,9 +219,21 @@
212
219
  <div class="bar"><strong>PENNY COMPARE</strong>
213
220
  <button id="sync" class="sync-off" disabled aria-pressed="false"
214
221
  title="Type into both panes at once (Ctrl/⌘+Shift+S)">Sync off</button>
215
- <button id="finish" disabled>Finish comparison</button>
222
+ <div class="menu">
223
+ <button id="run-example" class="menu-toggle" disabled aria-haspopup="menu" aria-expanded="false"
224
+ aria-controls="example-menu" title="Type a starter prompt into both panes">Run example</button>
225
+ <div class="menu-list" id="example-menu" role="menu" hidden></div>
226
+ </div>
227
+ <div class="menu">
228
+ <button id="end-session" class="menu-toggle" disabled aria-haspopup="menu" aria-expanded="false"
229
+ aria-controls="end-menu">End session</button>
230
+ <div class="menu-list" id="end-menu" role="menu" hidden>
231
+ <button id="add-to-gallery" role="menuitem" disabled title="Share the comparison in the PennyRouter gallery. The session stays open until you exit it">Submit to Gallery</button>
232
+ <button id="send-comment" role="menuitem" disabled title="Send a private note to the PennyRouter team. The session stays open until you exit it">Send Comment</button>
233
+ <button id="finish" role="menuitem" disabled title="Freeze the comparison and see its results. The session stays open until you end it">Finish Session</button>
234
+ </div>
235
+ </div>
216
236
  <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
237
  <span id="versions"></span>
219
238
  <span id="status">Connecting…</span>
220
239
  </div>
@@ -287,7 +306,11 @@
287
306
  <div class="sides" id="results-sides"></div>
288
307
  <div class="report-path" id="results-path"></div>
289
308
  <p class="footnote" id="results-note"></p>
290
- <button class="primary" id="results-publish" type="button" hidden>Add to gallery</button>
309
+ <div class="actions split">
310
+ <button class="secondary" id="results-back" type="button" hidden>Back to session</button>
311
+ <button class="secondary" id="results-publish" type="button" hidden>Add to gallery</button>
312
+ <button class="primary" id="results-close" type="button">End session</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
+ <span id="publish-transcript-text">Include the conversation transcript</span></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
+ <span id="comment-transcript-text">Include the conversation transcript</span></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,19 +669,88 @@
618
669
  };
619
670
  let finishing = false;
620
671
  let publishAfterFinish = false;
672
+ let commentAfterFinish = false;
673
+ let resultsAfterFinish = false;
674
+ let sessionEnded = false;
675
+ // Finishing freezes the comparison and produces its report; the session itself stays open, so a
676
+ // Gallery submission or comment can still read its transcripts, until it is exited.
621
677
  const finishComparison = async () => {
622
678
  if (finishing || lastReport || !started) return;
623
679
  finishing = true;
624
- $('finish').disabled = true;
625
680
  $('add-to-gallery').disabled = true;
681
+ $('send-comment').disabled = true;
682
+ $('run-example').disabled = true;
626
683
  setStatus('Saving files…');
627
684
  clearInterval(fileTimer);
628
685
  await pollFiles().catch(() => {});
629
686
  await snapshotFiles();
630
- send({ type: 'exit' });
687
+ send({ type: 'finish' });
631
688
  setStatus(hosted ? 'Finishing…' : 'Saving report…');
632
689
  };
633
- $('finish').onclick = finishComparison;
690
+ // Shows the results while the session stays open, so Add to gallery and Send Comment can still
691
+ // read its transcripts. The panes are already stopped, so Back to session only shows them frozen.
692
+ const showResults = () => {
693
+ if (!lastReport) { resultsAfterFinish = true; return finishComparison(); }
694
+ $('publish').hidden = true;
695
+ $('comment').hidden = true;
696
+ showReport(lastReport);
697
+ };
698
+ // Ends the session for good: stops the server side (a hosted sandbox is destroyed).
699
+ const endSession = () => {
700
+ if (sessionEnded) return;
701
+ sessionEnded = true;
702
+ send({ type: 'exit' });
703
+ if (hosted) window.parent.postMessage({ type: 'compare-demo-finished' }, config.parentOrigin);
704
+ };
705
+ $('finish').onclick = showResults;
706
+ $('results-back').onclick = () => {
707
+ $('results').hidden = true;
708
+ setStatus(sessionEnded ? 'Session ended' : 'Finished · session still open');
709
+ };
710
+
711
+ // Starter prompts typed into both panes, exactly as a visitor would type them.
712
+ const examples = [
713
+ { 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.' },
714
+ { 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.' },
715
+ { 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.' },
716
+ { 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.' },
717
+ ];
718
+ const runExample = (prompt) => {
719
+ for (const side of sides) {
720
+ send({ type: 'take-control', side });
721
+ send({ type: 'input', side, data: prompt });
722
+ }
723
+ // Sent after the text so the agent's prompt reads it as typing followed by Enter, not a paste.
724
+ setTimeout(() => { for (const side of sides) send({ type: 'input', side, data: '\r' }); }, 250);
725
+ terminals.left.focus();
726
+ };
727
+ for (const example of examples) {
728
+ const item = document.createElement('button');
729
+ item.type = 'button';
730
+ item.setAttribute('role', 'menuitem');
731
+ item.textContent = example.label;
732
+ item.title = example.prompt;
733
+ item.onclick = () => runExample(example.prompt);
734
+ $('example-menu').append(item);
735
+ }
736
+ const menus = [['run-example', 'example-menu'], ['end-session', 'end-menu']];
737
+ const closeMenus = (except) => {
738
+ for (const [toggle, list] of menus) {
739
+ if (toggle === except) continue;
740
+ $(list).hidden = true;
741
+ $(toggle).setAttribute('aria-expanded', 'false');
742
+ }
743
+ };
744
+ for (const [toggle, list] of menus) {
745
+ $(toggle).onclick = (event) => {
746
+ event.stopPropagation();
747
+ closeMenus(toggle);
748
+ $(list).hidden = !$(list).hidden;
749
+ $(toggle).setAttribute('aria-expanded', String(!$(list).hidden));
750
+ };
751
+ }
752
+ document.addEventListener('click', () => closeMenus());
753
+ document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeMenus(); });
634
754
 
635
755
  // Files the agents write, previewed in a separate tab
636
756
  const previewTabs = new Map();
@@ -899,9 +1019,12 @@
899
1019
  `${side.totals.total.toLocaleString()} tokens · $${side.totals.cost.toFixed(3)}`;
900
1020
  $('results-sides').textContent = `${line('NATIVE', native)}\n${line('PENNY', penny)}`;
901
1021
  $('results-path').textContent = path ? `Report saved: ${path}` : '';
902
- $('results-note').textContent = hosted
903
- ? 'Your session has ended. Close this page to destroy its sandbox.'
904
- : 'Comparison complete. You can close this tab.';
1022
+ $('results-note').textContent = sessionEnded
1023
+ ? (hosted ? 'Your session has ended. Close this page to destroy its sandbox.'
1024
+ : 'Comparison complete. You can close this tab.')
1025
+ : 'Your session is still open. Add it to the gallery if you like, then end the session.';
1026
+ $('results-back').hidden = sessionEnded;
1027
+ $('results-close').textContent = sessionEnded ? 'Close tab' : 'End session';
905
1028
  $('results').hidden = false;
906
1029
  setStatus(path ? 'Report saved' : 'Finished');
907
1030
  };
@@ -981,9 +1104,14 @@
981
1104
  }
982
1105
  $('results-publish').hidden = false;
983
1106
  if (blocker) $('results-publish').textContent = blocker;
1107
+ $('send-comment').disabled = false;
984
1108
  if (publishAfterFinish && !blocker) openPublish();
1109
+ if (commentAfterFinish) openComment();
985
1110
  publishAfterFinish = false;
986
- if (hosted) window.parent.postMessage({ type: 'compare-demo-finished' }, config.parentOrigin);
1111
+ commentAfterFinish = false;
1112
+ if (hosted) {
1113
+ window.parent.postMessage({ type: 'compare-demo-report', report: message.report }, config.parentOrigin);
1114
+ }
987
1115
  };
988
1116
  const remembered = (key, value) => {
989
1117
  try {
@@ -1064,7 +1192,43 @@
1064
1192
  $('publish-preview').srcdoc = sandboxed(cover.content);
1065
1193
  }
1066
1194
  };
1195
+ // The sessions' transcripts never need uploading by hand: a local server bundles them when the
1196
+ // form is submitted, and a hosted sandbox sends them with its report before it is destroyed.
1197
+ // The file picker remains only for a comparison that produced none.
1198
+ const autoTranscript = () => gallery?.transcriptBytes > 0;
1199
+ const transcriptTooLarge = () => autoTranscript() && gallery.transcriptBytes > 10 * 1024 * 1024;
1200
+ // What a submission sends for the "include transcripts" box: a hosted run asks its sandbox for the
1201
+ // bundle (it is still up), a local run asks its server to attach one when the form is posted.
1202
+ let transcriptRequest = null;
1203
+ const sessionTranscript = () => {
1204
+ if (!hosted) return Promise.resolve(null);
1205
+ return new Promise((resolve) => {
1206
+ const timer = setTimeout(() => { transcriptRequest = null; resolve(null); }, 30_000);
1207
+ transcriptRequest = (text) => {
1208
+ clearTimeout(timer);
1209
+ transcriptRequest = null;
1210
+ resolve(text ? new File([text], 'transcripts.jsonl', { type: 'application/x-ndjson' }) : null);
1211
+ };
1212
+ send({ type: 'transcripts' });
1213
+ });
1214
+ };
1215
+ const resetTranscript = (prefix) => {
1216
+ const box = $(`${prefix}-include-transcript`);
1217
+ box.checked = false;
1218
+ box.disabled = transcriptTooLarge();
1219
+ $(`${prefix}-transcript-label`).hidden = true;
1220
+ $(`${prefix}-transcript`).disabled = true;
1221
+ $(`${prefix}-transcript`).value = '';
1222
+ $(`${prefix}-transcript-text`).textContent = transcriptTooLarge()
1223
+ ? 'Transcripts are over 10 MB and cannot be included'
1224
+ : autoTranscript() ? 'Include both session transcripts' : 'Include the conversation transcript';
1225
+ };
1226
+ const toggleTranscript = (prefix, checked) => {
1227
+ $(`${prefix}-transcript-label`).hidden = !checked || autoTranscript();
1228
+ $(`${prefix}-transcript`).disabled = !checked || autoTranscript();
1229
+ };
1067
1230
  const openPublish = () => {
1231
+ resetTranscript('publish');
1068
1232
  for (const [role, id] of [['NATIVE', 'publish-native'], ['PENNY', 'publish-penny']]) {
1069
1233
  const select = $(id);
1070
1234
  if (!select.options.length) {
@@ -1085,20 +1249,29 @@
1085
1249
  return finishComparison();
1086
1250
  };
1087
1251
  $('results-publish').onclick = openPublish;
1252
+ // A tab the user opened themselves cannot be closed by a page, so say so when it stays open.
1253
+ $('results-close').onclick = () => {
1254
+ endSession();
1255
+ if (hosted) window.parent.postMessage({ type: 'compare-demo-close' }, config.parentOrigin);
1256
+ else window.close();
1257
+ setTimeout(() => { $('results-note').textContent = 'Your browser could not automatically close tab. Please close manually.'; }, 400);
1258
+ };
1088
1259
  $('publish-cancel').onclick = () => { $('publish').hidden = true; };
1089
1260
  $('publish-form').addEventListener('input', updatePreview);
1090
1261
  $('publish-form').addEventListener('change', updatePreview);
1091
- const relayToParent = (payload) => new Promise((resolve) => {
1262
+ $('publish-include-transcript').onchange = (event) => toggleTranscript('publish', event.target.checked);
1263
+ $('comment-include-transcript').onchange = (event) => toggleTranscript('comment', event.target.checked);
1264
+ const relayToParent = (payload, kind = 'gallery') => new Promise((resolve) => {
1092
1265
  const timer = setTimeout(() => { removeEventListener('message', onResult); resolve({ error: 'The website did not respond. Please try again.' }); }, 10 * 60_000);
1093
1266
  const onResult = (event) => {
1094
1267
  if (event.origin !== config.parentOrigin || event.source !== window.parent ||
1095
- event.data?.type !== 'compare-gallery-result') return;
1268
+ event.data?.type !== `compare-${kind}-result`) return;
1096
1269
  clearTimeout(timer);
1097
1270
  removeEventListener('message', onResult);
1098
1271
  resolve(event.data);
1099
1272
  };
1100
1273
  addEventListener('message', onResult);
1101
- window.parent.postMessage({ type: 'compare-gallery-submit', ...payload }, config.parentOrigin);
1274
+ window.parent.postMessage({ type: `compare-${kind}-submit`, ...payload }, config.parentOrigin);
1102
1275
  });
1103
1276
  $('publish-form').onsubmit = async (event) => {
1104
1277
  event.preventDefault();
@@ -1107,6 +1280,9 @@
1107
1280
  const penny = chosen('PENNY');
1108
1281
  const featured = checkedValue('publish-cover');
1109
1282
  const video = $('publish-video').files[0] || null;
1283
+ const includeTranscript = $('publish-include-transcript').checked;
1284
+ const transcript = includeTranscript
1285
+ ? (autoTranscript() ? await sessionTranscript() : $('publish-transcript').files[0] || null) : null;
1110
1286
  const videoType = video && (video.type || (/\.webm$/i.test(video.name) ? 'video/webm' : /\.mp4$/i.test(video.name) ? 'video/mp4' : ''));
1111
1287
  $('publish-error').textContent = '';
1112
1288
  if (video && !['video/mp4', 'video/webm'].includes(videoType)) {
@@ -1117,6 +1293,16 @@
1117
1293
  $('publish-error').textContent = 'Videos can be at most 100 MB.';
1118
1294
  return;
1119
1295
  }
1296
+ if (transcript && transcript.size > 10 * 1024 * 1024) {
1297
+ $('publish-error').textContent = 'Transcripts can be at most 10 MB.';
1298
+ return;
1299
+ }
1300
+ if (includeTranscript && !transcript && (!autoTranscript() || hosted)) {
1301
+ $('publish-error').textContent = autoTranscript()
1302
+ ? 'The session transcripts are no longer available. Clear the include transcripts option to submit without them.'
1303
+ : 'Choose a transcript file or clear the include transcript selection.';
1304
+ return;
1305
+ }
1120
1306
  const meta = { title: $('publish-title').value.trim(), comment: $('publish-comment').value.trim(),
1121
1307
  tag: checkedValue('publish-tag'), display_name: $('publish-name').value.trim(),
1122
1308
  native_file: native?.path ?? null, penny_file: penny?.path ?? null,
@@ -1131,13 +1317,15 @@
1131
1317
  if (hosted) {
1132
1318
  result = await relayToParent({ report: gallery.report, signature: gallery.signature,
1133
1319
  reportNonce: gallery.reportNonce, meta, native: native?.content ?? null, penny: penny?.content ?? null, video,
1134
- cover: coverImage });
1320
+ cover: coverImage, transcript });
1135
1321
  } else {
1136
1322
  const form = new FormData();
1137
1323
  form.set('meta', JSON.stringify(meta));
1138
1324
  if (native) form.set('native', new Blob([native.content], { type: 'text/html' }), 'native.html');
1139
1325
  if (penny) form.set('penny', new Blob([penny.content], { type: 'text/html' }), 'penny.html');
1140
1326
  if (video) form.set('video', video, video.name);
1327
+ if (transcript) form.set('transcript', transcript, transcript.name);
1328
+ if (includeTranscript && autoTranscript() && !hosted) form.set('include_transcript', '1');
1141
1329
  if (coverImage) form.set('cover', coverImage, 'cover');
1142
1330
  const response = await fetch('/gallery/submit', { method: 'POST',
1143
1331
  headers: { Authorization: `Bearer ${gallery.publishToken}` }, body: form });
@@ -1167,6 +1355,76 @@
1167
1355
  $('publish').hidden = true;
1168
1356
  };
1169
1357
 
1358
+ // A private note to the team: ends the session like Submit to Gallery, then asks for the comment.
1359
+ const openComment = () => {
1360
+ resetTranscript('comment');
1361
+ $('comment-done').textContent = '';
1362
+ $('comment-error').textContent = '';
1363
+ $('comment-submit').disabled = false;
1364
+ $('comment-submit').textContent = 'Send comment';
1365
+ $('comment-cancel').textContent = 'Cancel';
1366
+ $('comment').hidden = false;
1367
+ $('comment-text').focus();
1368
+ };
1369
+ $('send-comment').onclick = () => {
1370
+ if (lastReport) return openComment();
1371
+ commentAfterFinish = true;
1372
+ return finishComparison();
1373
+ };
1374
+ $('comment-cancel').onclick = () => { $('comment').hidden = true; };
1375
+ $('comment-form').onsubmit = async (event) => {
1376
+ event.preventDefault();
1377
+ const comment = $('comment-text').value.trim();
1378
+ if (!comment) return;
1379
+ const includeTranscript = $('comment-include-transcript').checked;
1380
+ const transcript = includeTranscript
1381
+ ? (autoTranscript() ? await sessionTranscript() : $('comment-transcript').files[0] || null) : null;
1382
+ if (includeTranscript && !transcript && (!autoTranscript() || hosted)) {
1383
+ $('comment-error').textContent = autoTranscript()
1384
+ ? 'The session transcripts are no longer available. Clear the include transcripts option to send without them.'
1385
+ : 'Choose a transcript file or clear the include transcript selection.';
1386
+ return;
1387
+ }
1388
+ if (transcript && transcript.size > 10 * 1024 * 1024) {
1389
+ $('comment-error').textContent = 'Transcripts can be at most 10 MB.';
1390
+ return;
1391
+ }
1392
+ const submit = $('comment-submit');
1393
+ submit.disabled = true;
1394
+ submit.classList.add('busy');
1395
+ submit.textContent = 'Sending…';
1396
+ $('comment-error').textContent = '';
1397
+ let result;
1398
+ try {
1399
+ if (hosted) {
1400
+ result = await relayToParent({ comment, report: gallery?.report ?? '', transcript }, 'feedback');
1401
+ } else {
1402
+ const form = new FormData();
1403
+ form.set('comment', comment);
1404
+ if (transcript) form.set('transcript', transcript, transcript.name);
1405
+ if (includeTranscript && autoTranscript() && !hosted) form.set('include_transcript', '1');
1406
+ const response = await fetch('/feedback/submit', { method: 'POST',
1407
+ headers: { ...(gallery?.publishToken ? { Authorization: `Bearer ${gallery.publishToken}` } : {}) }, body: form });
1408
+ result = await response.json().catch(() => ({ error: 'Could not send the comment.' }));
1409
+ if (!response.ok && !result.error) result.error = 'Could not send the comment.';
1410
+ }
1411
+ } catch {
1412
+ result = { error: 'Could not send the comment.' };
1413
+ }
1414
+ submit.classList.remove('busy');
1415
+ if (result.error) {
1416
+ $('comment-error').textContent = result.error;
1417
+ submit.disabled = false;
1418
+ submit.textContent = 'Send comment';
1419
+ return;
1420
+ }
1421
+ $('comment-text').value = '';
1422
+ resetTranscript('comment');
1423
+ submit.textContent = 'Sent';
1424
+ $('comment-done').textContent = 'Sent to the PennyRouter team. Thank you.';
1425
+ $('comment-cancel').textContent = 'Close';
1426
+ };
1427
+
1170
1428
  // Messages common to both servers
1171
1429
  const handleMessage = (message) => {
1172
1430
  if (message.type === 'output') terminals[message.side]?.write(message.data);
@@ -1176,12 +1434,16 @@
1176
1434
  updateDelta();
1177
1435
  } else if (message.type === 'exit' || message.type === 'pane-exit') {
1178
1436
  terminals[message.side]?.write(`\r\n[process exited: ${message.code}]\r\n`);
1437
+ } else if (message.type === 'transcripts') {
1438
+ transcriptRequest?.(message.transcript);
1179
1439
  } else if (message.type === 'report') {
1180
- showReport(message);
1181
1440
  prepareGallery(message);
1441
+ if (resultsAfterFinish) { resultsAfterFinish = false; showResults(); }
1442
+ else setStatus('Finished · session still open');
1182
1443
  }
1183
1444
  else if (message.type === 'report-error') {
1184
1445
  publishAfterFinish = false;
1446
+ sessionEnded = true;
1185
1447
  showReportError(message.error);
1186
1448
  }
1187
1449
  else return false;
@@ -1216,7 +1478,7 @@
1216
1478
  $('download-logs').hidden = !message.captureLogs;
1217
1479
  if (message.subscription) {
1218
1480
  setStatus('Starting subscription test · costs are API-equivalent estimates');
1219
- send({ type: 'start', harness: message.subscriptionHarness || 'claude' });
1481
+ send({ type: 'start', harness: message.subscriptionHarness || 'claude', model: message.subscriptionModel || '' });
1220
1482
  return;
1221
1483
  }
1222
1484
  authenticated = true;
@@ -1239,6 +1501,14 @@
1239
1501
  $('finish').disabled = true;
1240
1502
  if (!lastReport) $('add-to-gallery').disabled = true;
1241
1503
  if (!$('results').hidden) return;
1504
+ if (lastReport && !sessionEnded) {
1505
+ // The sandbox ended on its own after the report; a form being filled in stays open.
1506
+ sessionEnded = true;
1507
+ if ($('publish').hidden && $('comment').hidden) showReport(lastReport);
1508
+ else setStatus('Session ended');
1509
+ toParent({ type: 'compare-demo-finished' });
1510
+ return;
1511
+ }
1242
1512
  setStatus('Session ended');
1243
1513
  toParent({ type: 'compare-demo-ended', error: launchError });
1244
1514
  });
@@ -1350,7 +1620,6 @@
1350
1620
  } else {
1351
1621
  $('left-label').textContent = 'LEFT';
1352
1622
  $('right-label').textContent = 'RIGHT';
1353
- $('finish').textContent = 'End session';
1354
1623
  }
1355
1624
  let pending = false;
1356
1625
  $('local-setup').addEventListener('submit', (event) => {
@@ -1417,7 +1686,7 @@
1417
1686
  if (!lastReport) $('add-to-gallery').disabled = true;
1418
1687
  if (!$('finish').disabled) {
1419
1688
  $('finish').disabled = true;
1420
- setStatus('Disconnected · reload to reconnect');
1689
+ if (!sessionEnded) setStatus('Disconnected · reload to reconnect');
1421
1690
  }
1422
1691
  });
1423
1692
  }
@@ -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
+ }