pennyrouter 0.3.52 → 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.52",
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();
@@ -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
  }
@@ -109,8 +109,7 @@
109
109
  .eyebrow { display: block; margin-bottom: 10px; color: var(--accent); font-size: 10px; font-weight: 600;
110
110
  letter-spacing: 1.8px; text-transform: uppercase; }
111
111
  .card h2 { margin: 0 0 8px; font-size: 20px; font-weight: 650; letter-spacing: -.4px; }
112
- .card .lede { margin: 0 0 20px; color: var(--muted); }
113
- color: #b4b4be; font-size: 12px; }
112
+ .card .lede { margin: 0 0 20px; color: #b4b4be; font-size: 12px; }
114
113
  .card label { display: block; margin: 14px 0 0; color: #b8b8c1; font-size: 12px; }
115
114
  .card input:not([type=checkbox]), .card select { display: block; width: 100%; min-width: 0; margin-top: 8px;
116
115
  padding: 10px 12px; border: 1px solid #3a3a44; border-radius: 8px; background: var(--field); color: #eee;
@@ -229,9 +228,9 @@
229
228
  <button id="end-session" class="menu-toggle" disabled aria-haspopup="menu" aria-expanded="false"
230
229
  aria-controls="end-menu">End session</button>
231
230
  <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>
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>
235
234
  </div>
236
235
  </div>
237
236
  <button id="download-logs" hidden>Download logs</button>
@@ -308,8 +307,9 @@
308
307
  <div class="report-path" id="results-path"></div>
309
308
  <p class="footnote" id="results-note"></p>
310
309
  <div class="actions split">
310
+ <button class="secondary" id="results-back" type="button" hidden>Back to session</button>
311
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>
312
+ <button class="primary" id="results-close" type="button">End session</button>
313
313
  </div>
314
314
  </div>
315
315
  </section>
@@ -349,7 +349,7 @@
349
349
  <label>Video (optional, MP4 or WebM, up to 100 MB)
350
350
  <input id="publish-video" type="file" accept="video/mp4,video/webm"></label>
351
351
  <label class="check-label"><input id="publish-include-transcript" type="checkbox">
352
- Include the conversation transcript</label>
352
+ <span id="publish-transcript-text">Include the conversation transcript</span></label>
353
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
354
  <label id="publish-transcript-label" hidden>Transcript file (JSON or JSONL, up to 10 MB)
355
355
  <input id="publish-transcript" type="file" accept=".json,.jsonl,application/json,text/plain" disabled></label>
@@ -386,7 +386,7 @@
386
386
  <label>Comment
387
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
388
  <label class="check-label"><input id="comment-include-transcript" type="checkbox">
389
- Include the conversation transcript</label>
389
+ <span id="comment-transcript-text">Include the conversation transcript</span></label>
390
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
391
  <label id="comment-transcript-label" hidden>Transcript file (JSON or JSONL, up to 10 MB)
392
392
  <input id="comment-transcript" type="file" accept=".json,.jsonl,application/json,text/plain" disabled></label>
@@ -670,10 +670,13 @@
670
670
  let finishing = false;
671
671
  let publishAfterFinish = false;
672
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.
673
677
  const finishComparison = async () => {
674
678
  if (finishing || lastReport || !started) return;
675
679
  finishing = true;
676
- $('finish').disabled = true;
677
680
  $('add-to-gallery').disabled = true;
678
681
  $('send-comment').disabled = true;
679
682
  $('run-example').disabled = true;
@@ -681,10 +684,29 @@
681
684
  clearInterval(fileTimer);
682
685
  await pollFiles().catch(() => {});
683
686
  await snapshotFiles();
684
- send({ type: 'exit' });
687
+ send({ type: 'finish' });
685
688
  setStatus(hosted ? 'Finishing…' : 'Saving report…');
686
689
  };
687
- $('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
+ };
688
710
 
689
711
  // Starter prompts typed into both panes, exactly as a visitor would type them.
690
712
  const examples = [
@@ -997,9 +1019,12 @@
997
1019
  `${side.totals.total.toLocaleString()} tokens · $${side.totals.cost.toFixed(3)}`;
998
1020
  $('results-sides').textContent = `${line('NATIVE', native)}\n${line('PENNY', penny)}`;
999
1021
  $('results-path').textContent = path ? `Report saved: ${path}` : '';
1000
- $('results-note').textContent = hosted
1001
- ? 'Your session has ended. Close this page to destroy its sandbox.'
1002
- : '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';
1003
1028
  $('results').hidden = false;
1004
1029
  setStatus(path ? 'Report saved' : 'Finished');
1005
1030
  };
@@ -1086,7 +1111,6 @@
1086
1111
  commentAfterFinish = false;
1087
1112
  if (hosted) {
1088
1113
  window.parent.postMessage({ type: 'compare-demo-report', report: message.report }, config.parentOrigin);
1089
- window.parent.postMessage({ type: 'compare-demo-finished' }, config.parentOrigin);
1090
1114
  }
1091
1115
  };
1092
1116
  const remembered = (key, value) => {
@@ -1168,11 +1192,43 @@
1168
1192
  $('publish-preview').srcdoc = sandboxed(cover.content);
1169
1193
  }
1170
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
+ };
1171
1230
  const openPublish = () => {
1172
- $('publish-include-transcript').checked = false;
1173
- $('publish-transcript-label').hidden = true;
1174
- $('publish-transcript').disabled = true;
1175
- $('publish-transcript').value = '';
1231
+ resetTranscript('publish');
1176
1232
  for (const [role, id] of [['NATIVE', 'publish-native'], ['PENNY', 'publish-penny']]) {
1177
1233
  const select = $(id);
1178
1234
  if (!select.options.length) {
@@ -1195,6 +1251,7 @@
1195
1251
  $('results-publish').onclick = openPublish;
1196
1252
  // A tab the user opened themselves cannot be closed by a page, so say so when it stays open.
1197
1253
  $('results-close').onclick = () => {
1254
+ endSession();
1198
1255
  if (hosted) window.parent.postMessage({ type: 'compare-demo-close' }, config.parentOrigin);
1199
1256
  else window.close();
1200
1257
  setTimeout(() => { $('results-note').textContent = 'Your browser could not automatically close tab. Please close manually.'; }, 400);
@@ -1202,14 +1259,8 @@
1202
1259
  $('publish-cancel').onclick = () => { $('publish').hidden = true; };
1203
1260
  $('publish-form').addEventListener('input', updatePreview);
1204
1261
  $('publish-form').addEventListener('change', updatePreview);
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
- };
1262
+ $('publish-include-transcript').onchange = (event) => toggleTranscript('publish', event.target.checked);
1263
+ $('comment-include-transcript').onchange = (event) => toggleTranscript('comment', event.target.checked);
1213
1264
  const relayToParent = (payload, kind = 'gallery') => new Promise((resolve) => {
1214
1265
  const timer = setTimeout(() => { removeEventListener('message', onResult); resolve({ error: 'The website did not respond. Please try again.' }); }, 10 * 60_000);
1215
1266
  const onResult = (event) => {
@@ -1230,7 +1281,8 @@
1230
1281
  const featured = checkedValue('publish-cover');
1231
1282
  const video = $('publish-video').files[0] || null;
1232
1283
  const includeTranscript = $('publish-include-transcript').checked;
1233
- const transcript = includeTranscript ? $('publish-transcript').files[0] || null : null;
1284
+ const transcript = includeTranscript
1285
+ ? (autoTranscript() ? await sessionTranscript() : $('publish-transcript').files[0] || null) : null;
1234
1286
  const videoType = video && (video.type || (/\.webm$/i.test(video.name) ? 'video/webm' : /\.mp4$/i.test(video.name) ? 'video/mp4' : ''));
1235
1287
  $('publish-error').textContent = '';
1236
1288
  if (video && !['video/mp4', 'video/webm'].includes(videoType)) {
@@ -1245,8 +1297,10 @@
1245
1297
  $('publish-error').textContent = 'Transcripts can be at most 10 MB.';
1246
1298
  return;
1247
1299
  }
1248
- if (includeTranscript && !transcript) {
1249
- $('publish-error').textContent = 'Choose a transcript file or clear the include transcript selection.';
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.';
1250
1304
  return;
1251
1305
  }
1252
1306
  const meta = { title: $('publish-title').value.trim(), comment: $('publish-comment').value.trim(),
@@ -1271,6 +1325,7 @@
1271
1325
  if (penny) form.set('penny', new Blob([penny.content], { type: 'text/html' }), 'penny.html');
1272
1326
  if (video) form.set('video', video, video.name);
1273
1327
  if (transcript) form.set('transcript', transcript, transcript.name);
1328
+ if (includeTranscript && autoTranscript() && !hosted) form.set('include_transcript', '1');
1274
1329
  if (coverImage) form.set('cover', coverImage, 'cover');
1275
1330
  const response = await fetch('/gallery/submit', { method: 'POST',
1276
1331
  headers: { Authorization: `Bearer ${gallery.publishToken}` }, body: form });
@@ -1302,10 +1357,7 @@
1302
1357
 
1303
1358
  // A private note to the team: ends the session like Submit to Gallery, then asks for the comment.
1304
1359
  const openComment = () => {
1305
- $('comment-include-transcript').checked = false;
1306
- $('comment-transcript-label').hidden = true;
1307
- $('comment-transcript').disabled = true;
1308
- $('comment-transcript').value = '';
1360
+ resetTranscript('comment');
1309
1361
  $('comment-done').textContent = '';
1310
1362
  $('comment-error').textContent = '';
1311
1363
  $('comment-submit').disabled = false;
@@ -1325,9 +1377,12 @@
1325
1377
  const comment = $('comment-text').value.trim();
1326
1378
  if (!comment) return;
1327
1379
  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.';
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.';
1331
1386
  return;
1332
1387
  }
1333
1388
  if (transcript && transcript.size > 10 * 1024 * 1024) {
@@ -1347,6 +1402,7 @@
1347
1402
  const form = new FormData();
1348
1403
  form.set('comment', comment);
1349
1404
  if (transcript) form.set('transcript', transcript, transcript.name);
1405
+ if (includeTranscript && autoTranscript() && !hosted) form.set('include_transcript', '1');
1350
1406
  const response = await fetch('/feedback/submit', { method: 'POST',
1351
1407
  headers: { ...(gallery?.publishToken ? { Authorization: `Bearer ${gallery.publishToken}` } : {}) }, body: form });
1352
1408
  result = await response.json().catch(() => ({ error: 'Could not send the comment.' }));
@@ -1363,10 +1419,7 @@
1363
1419
  return;
1364
1420
  }
1365
1421
  $('comment-text').value = '';
1366
- $('comment-include-transcript').checked = false;
1367
- $('comment-transcript-label').hidden = true;
1368
- $('comment-transcript').disabled = true;
1369
- $('comment-transcript').value = '';
1422
+ resetTranscript('comment');
1370
1423
  submit.textContent = 'Sent';
1371
1424
  $('comment-done').textContent = 'Sent to the PennyRouter team. Thank you.';
1372
1425
  $('comment-cancel').textContent = 'Close';
@@ -1381,12 +1434,16 @@
1381
1434
  updateDelta();
1382
1435
  } else if (message.type === 'exit' || message.type === 'pane-exit') {
1383
1436
  terminals[message.side]?.write(`\r\n[process exited: ${message.code}]\r\n`);
1437
+ } else if (message.type === 'transcripts') {
1438
+ transcriptRequest?.(message.transcript);
1384
1439
  } else if (message.type === 'report') {
1385
- showReport(message);
1386
1440
  prepareGallery(message);
1441
+ if (resultsAfterFinish) { resultsAfterFinish = false; showResults(); }
1442
+ else setStatus('Finished · session still open');
1387
1443
  }
1388
1444
  else if (message.type === 'report-error') {
1389
1445
  publishAfterFinish = false;
1446
+ sessionEnded = true;
1390
1447
  showReportError(message.error);
1391
1448
  }
1392
1449
  else return false;
@@ -1444,6 +1501,14 @@
1444
1501
  $('finish').disabled = true;
1445
1502
  if (!lastReport) $('add-to-gallery').disabled = true;
1446
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
+ }
1447
1512
  setStatus('Session ended');
1448
1513
  toParent({ type: 'compare-demo-ended', error: launchError });
1449
1514
  });
@@ -1621,7 +1686,7 @@
1621
1686
  if (!lastReport) $('add-to-gallery').disabled = true;
1622
1687
  if (!$('finish').disabled) {
1623
1688
  $('finish').disabled = true;
1624
- setStatus('Disconnected · reload to reconnect');
1689
+ if (!sessionEnded) setStatus('Disconnected · reload to reconnect');
1625
1690
  }
1626
1691
  });
1627
1692
  }
@@ -14,6 +14,7 @@ import { applyPaneOptions, describePaneOptions, normalizePaneOptions } from './p
14
14
  import { listArtifacts, readArtifact } from './artifacts.mjs';
15
15
  import { readTelemetry, telemetryEnv, telemetryRoute } from './telemetry.mjs';
16
16
  import { Readable } from 'node:stream';
17
+ import { bundleTranscripts, maxTranscriptBytes, transcriptBytes } from './session-transcripts.mjs';
17
18
 
18
19
  const here = dirname(fileURLToPath(import.meta.url));
19
20
  const compareDir = dirname(here);
@@ -46,6 +47,8 @@ const artifactSessions = new Map();
46
47
  const telemetryMeters = new Map();
47
48
  // Finished comparisons that may still be published, by one-time publish token.
48
49
  const publishable = new Map();
50
+ // Each finished comparison's session transcript files, by the same token as its report.
51
+ const sessionTranscripts = new Map();
49
52
  const publishWindowMs = 60 * 60_000;
50
53
  // `npm start` runs from this package's folder; INIT_CWD is where it was invoked.
51
54
  const launchDirectory = process.env.INIT_CWD || process.cwd();
@@ -132,14 +135,26 @@ async function submitToGallery(request, response) {
132
135
  const form = await new Request('http://localhost/gallery/submit', { method: 'POST',
133
136
  headers: { 'content-type': request.headers['content-type'] || '' },
134
137
  body: Readable.toWeb(request), duplex: 'half' }).formData();
138
+ await attachSessionTranscripts(form, token);
135
139
  const result = await publishToGallery(report, form);
136
140
  publishable.delete(token);
141
+ sessionTranscripts.delete(token);
137
142
  reply(201, result);
138
143
  } catch (error) {
139
144
  reply(400, { error: error.message || 'Could not publish to the gallery.' });
140
145
  }
141
146
  }
142
147
 
148
+ // When the submitter ticked "include transcripts", the page sends no file: the server bundles the
149
+ // two sessions it launched.
150
+ async function attachSessionTranscripts(form, token) {
151
+ if (form.get('include_transcript') !== '1') return;
152
+ const bundle = await bundleTranscripts(sessionTranscripts.get(token) ?? []);
153
+ if (!bundle) throw new Error('No session transcripts were found to include.');
154
+ if (bundle.size > maxTranscriptBytes) throw new Error('The session transcripts are over 10 MB.');
155
+ form.set('transcript', bundle, 'transcripts.jsonl');
156
+ }
157
+
143
158
  async function submitFeedback(request, response) {
144
159
  const reply = (status, body) => response.writeHead(status, { 'Content-Type': 'application/json',
145
160
  'Cache-Control': 'no-store' }).end(JSON.stringify(body));
@@ -163,6 +178,7 @@ async function submitFeedback(request, response) {
163
178
  if (typeof comment !== 'string' || !comment.trim()) { reply(400, { error: 'Write a comment first.' }); return; }
164
179
  const token = request.headers.authorization?.replace(/^Bearer /, '');
165
180
  const report = publishable.get(token) ?? '';
181
+ await attachSessionTranscripts(form, token);
166
182
  reply(201, await sendFeedback(comment.trim(), report, form.get('transcript')));
167
183
  } catch (error) {
168
184
  reply(400, { error: error.message || 'Could not send the comment.' });
@@ -300,6 +316,39 @@ wss.on('connection', (ws) => {
300
316
  sessions.clear();
301
317
  if (meterTimer) clearInterval(meterTimer);
302
318
  };
319
+ let finishing = null;
320
+ // Reports the comparison once and stops its panes; resolves false when no report could be made.
321
+ const finishSession = () => finishing ??= (async () => {
322
+ try {
323
+ // Telemetry is exported every second; let the last batch land before closing the books.
324
+ if (telemetryRoutesBySide.size) await new Promise((done) => setTimeout(done, 1500));
325
+ await pollMeters();
326
+ for (const meter of meters.values()) meter.chargeSideCalls({ final: true });
327
+ const left = meters.get('left');
328
+ const right = meters.get('right');
329
+ if (!left || !right) throw new Error('Both compare panes must start before a report can be saved.');
330
+ const report = galleryReport(left, right, { environment, harnessVersions: await harnessVersions });
331
+ const publishToken = report ? randomUUID() : null;
332
+ if (publishToken) {
333
+ publishable.set(publishToken, report);
334
+ const transcripts = [left, right].map((meter) => ({ role: meter.role, harness: meter.snapshot().harness,
335
+ files: meter.transcriptFiles }));
336
+ sessionTranscripts.set(publishToken, transcripts);
337
+ setTimeout(() => { publishable.delete(publishToken); sessionTranscripts.delete(publishToken); },
338
+ publishWindowMs).unref();
339
+ }
340
+ send({ type: 'report', ...await saveReport(left, right, join(compareDir, 'reports'), environment),
341
+ gallery: publishToken ? { publishToken, transcriptBytes: await transcriptBytes(sessionTranscripts.get(publishToken)) } : null });
342
+ if (meterTimer) clearInterval(meterTimer);
343
+ for (const session of sessions.values()) {
344
+ try { session.kill(); } catch { /* already exited */ }
345
+ }
346
+ return true;
347
+ } catch (error) {
348
+ send({ type: 'report-error', error: error.message });
349
+ return false;
350
+ }
351
+ })();
303
352
  const handle = async (raw) => {
304
353
  let message;
305
354
  try { message = JSON.parse(raw); } catch { return; }
@@ -327,30 +376,16 @@ wss.on('connection', (ws) => {
327
376
  }
328
377
  return;
329
378
  }
379
+ // "finish" freezes the comparison and reports it while the panes' transcripts stay on disk, so a
380
+ // Gallery submission or comment can still read them; "exit" then ends the server-side session.
381
+ if (message.type === 'finish') {
382
+ if (compare && !await finishSession()) { dispose(); ws.close(); }
383
+ return;
384
+ }
330
385
  if (message.type === 'exit') {
331
386
  if (exiting) return;
332
387
  exiting = true;
333
- if (compare) {
334
- try {
335
- // Telemetry is exported every second; let the last batch land before closing the books.
336
- if (telemetryRoutesBySide.size) await new Promise((done) => setTimeout(done, 1500));
337
- await pollMeters();
338
- for (const meter of meters.values()) meter.chargeSideCalls({ final: true });
339
- const left = meters.get('left');
340
- const right = meters.get('right');
341
- if (!left || !right) throw new Error('Both compare panes must start before a report can be saved.');
342
- const report = galleryReport(left, right, { environment, harnessVersions: await harnessVersions });
343
- const publishToken = report ? randomUUID() : null;
344
- if (publishToken) {
345
- publishable.set(publishToken, report);
346
- setTimeout(() => publishable.delete(publishToken), publishWindowMs).unref();
347
- }
348
- send({ type: 'report', ...await saveReport(left, right, join(compareDir, 'reports'), environment),
349
- gallery: publishToken ? { publishToken } : null });
350
- } catch (error) {
351
- send({ type: 'report-error', error: error.message });
352
- }
353
- }
388
+ if (compare && !await finishSession()) { /* the error was already reported */ }
354
389
  dispose();
355
390
  ws.close();
356
391
  return;
@@ -0,0 +1,37 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+
3
+ // The Gallery stores one transcript per submission. A finished comparison has two sessions, so
4
+ // they travel as a single JSONL file: a header line naming each session, then that session's
5
+ // own lines unchanged (the root transcript first, then any subagent or child transcripts).
6
+ export const maxTranscriptBytes = 10 * 1024 * 1024;
7
+
8
+ const header = (role, harness, file) => `${JSON.stringify({ penny_compare_session: role, harness, file })}\n`;
9
+
10
+ async function sessionParts({ role, harness, files }) {
11
+ const parts = [];
12
+ for (const [index, path] of files.entries()) {
13
+ let text;
14
+ try { text = await readFile(path, 'utf8'); } catch { continue; }
15
+ if (!text) continue;
16
+ parts.push(header(role, harness, index === 0 ? 'root' : 'child'), text.endsWith('\n') ? text : `${text}\n`);
17
+ }
18
+ return parts;
19
+ }
20
+
21
+ /** The two sessions' transcripts as one JSONL blob, or null when neither left a readable file. */
22
+ export async function bundleTranscripts(sessions) {
23
+ const parts = (await Promise.all(sessions.map(sessionParts))).flat();
24
+ return parts.length ? new Blob(parts, { type: 'application/x-ndjson' }) : null;
25
+ }
26
+
27
+ /** Bytes the bundle will occupy, without reading the files (a file with no final newline gains one). */
28
+ export async function transcriptBytes(sessions) {
29
+ let total = 0;
30
+ for (const { role, harness, files } of sessions) {
31
+ for (const [index, path] of files.entries()) {
32
+ const info = await stat(path).catch(() => null);
33
+ if (info?.size) total += info.size + Buffer.byteLength(header(role, harness, index === 0 ? 'root' : 'child'));
34
+ }
35
+ }
36
+ return total;
37
+ }