pennyrouter 0.3.50 → 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.
@@ -0,0 +1,47 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { QUICK_PROMPT } from "./pilots.js";
6
+
7
+ // Binding rules are kept verbatim, including import references. No summaries or truncation.
8
+ export function quickSystemPrompt(cwd = process.cwd(), env = process.env) {
9
+ const configDir = env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
10
+ let project = resolve(cwd);
11
+ try {
12
+ project = execFileSync("git", ["rev-parse", "--show-toplevel"], {
13
+ cwd, encoding: "utf8", timeout: 500, stdio: ["ignore", "pipe", "ignore"],
14
+ }).trim();
15
+ } catch {}
16
+ const directories = [];
17
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
18
+ directories.unshift(dir);
19
+ if (dirname(dir) === dir) break;
20
+ }
21
+ const sources = [join(configDir, "CLAUDE.md")];
22
+ for (const dir of directories) {
23
+ sources.push(join(dir, "AGENTS.md"), join(dir, "CLAUDE.md"),
24
+ join(dir, "CLAUDE.local.md"), join(dir, ".claude", "CLAUDE.md"));
25
+ const rules = join(dir, ".claude", "rules");
26
+ if (existsSync(rules)) {
27
+ const collect = (path) => {
28
+ for (const entry of readdirSync(path, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
29
+ if (entry.isDirectory()) collect(join(path, entry.name));
30
+ else if (entry.isFile() && entry.name.endsWith(".md")) sources.push(join(path, entry.name));
31
+ }
32
+ };
33
+ collect(rules);
34
+ }
35
+ }
36
+ if (!["1", "true"].includes(String(env.CLAUDE_CODE_DISABLE_AUTO_MEMORY).toLowerCase())) {
37
+ sources.push(join(configDir, "projects", project.replace(/[^a-zA-Z0-9]/g, "-"), "memory", "MEMORY.md"));
38
+ }
39
+ const parts = [QUICK_PROMPT, `Working directory: ${resolve(cwd)}`,
40
+ "Project/user rules below are authoritative within their scope. Preserve their precedence: user instructions take priority; more specific project rules govern their files. Read referenced instruction imports before acting when required. Path-scoped rule files apply only to their declared paths."];
41
+ for (const path of new Set(sources)) {
42
+ if (!existsSync(path)) continue;
43
+ // Existing but unreadable rules stop launch rather than silently weakening constraints.
44
+ parts.push(`\nInstructions source: ${path}\n${readFileSync(path, "utf8")}`);
45
+ }
46
+ return parts.join("\n");
47
+ }
package/src/session.js CHANGED
@@ -71,10 +71,13 @@ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider
71
71
  return response.json();
72
72
  }
73
73
 
74
- export async function fetchGatewayCapabilities({ gatewayBaseUrl, apiKey }) {
75
- const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/auth/capabilities`;
74
+ export async function fetchGatewayCapabilities({ gatewayBaseUrl, apiKey, timeoutMs = 5000 }) {
76
75
  try {
77
- const response = await fetch(url, { headers: { authorization: `Bearer ${apiKey}` } });
76
+ const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/auth/capabilities`;
77
+ const response = await fetch(url, {
78
+ headers: { authorization: `Bearer ${apiKey}` },
79
+ signal: AbortSignal.timeout(timeoutMs),
80
+ });
78
81
  if (!response.ok) return null;
79
82
  return await response.json();
80
83
  } catch {
@@ -44,6 +44,7 @@ native_proxy=""
44
44
  model=""
45
45
  wire=""
46
46
  wire_https=""
47
+ harness_args=()
47
48
  while [ $# -gt 0 ]; do
48
49
  case "$1" in
49
50
  --dir)
@@ -65,11 +66,15 @@ while [ $# -gt 0 ]; do
65
66
  --native-proxy) native_proxy="--native-proxy"; shift ;;
66
67
  --wire) wire="--wire"; shift ;;
67
68
  --wire-https) wire="--wire"; wire_https="--wire-https"; shift ;;
69
+ --quick|--no-quick|--prefetch|--no-prefetch)
70
+ harness_args+=("$1"); shift
71
+ ;;
68
72
  -h|--help)
69
73
  sed -n '2,8p' "$source"
70
74
  echo
71
75
  echo "Options: --dir PATH --label NAME --model MODEL --pricing-profile PROFILE --native-proxy"
72
76
  echo " --wire --wire-https"
77
+ echo " --quick --no-quick --prefetch --no-prefetch (Penny Claude)"
73
78
  exit 0
74
79
  ;;
75
80
  *) echo "unknown option: $1" >&2; exit 2 ;;
@@ -88,6 +93,10 @@ case "$pricing_profile" in
88
93
  esac
89
94
 
90
95
  args=("$here/pane.py" "$variant" --side single --pricing-profile "$pricing_profile")
96
+ if [ ${#harness_args[@]} -gt 0 ]; then
97
+ harness_args_json="$(python3 -c 'import json, sys; print(json.dumps(sys.argv[1:]))' "${harness_args[@]}")"
98
+ args+=(--harness-args-json "$harness_args_json")
99
+ fi
91
100
  [ -n "$model" ] && args+=(--model "$model")
92
101
  [ -n "$label" ] && args+=(--label "$label")
93
102
  [ -n "$native_proxy" ] && args+=("$native_proxy")
@@ -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(homedir(), '.codex', 'sessions');
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
- <button id="finish" disabled>Finish comparison</button>
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
- <button class="primary" id="results-publish" type="button" hidden>Add to gallery</button>
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
 
@@ -318,13 +341,18 @@
318
341
  <label><input type="radio" name="publish-cover" value="native"><span>Native page</span></label>
319
342
  <label><input type="radio" name="publish-cover" value="none"><span>No page</span></label>
320
343
  </div>
321
- <label>Your name, as shown publicly
344
+ <label>Username
322
345
  <input id="publish-name" maxlength="40" required autocomplete="nickname"></label>
323
346
  <label>Screenshot for the card (optional; choose a file or paste one here)
324
347
  <input id="publish-cover-image" type="file" accept="image/png,image/jpeg,image/webp"></label>
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
- if (hosted) window.parent.postMessage({ type: 'compare-demo-finished' }, config.parentOrigin);
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
- const relayToParent = (payload) => new Promise((resolve) => {
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 !== 'compare-gallery-result') return;
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: 'compare-gallery-submit', ...payload }, config.parentOrigin);
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
+ }
@@ -22,7 +22,22 @@ const cacheTtl = {
22
22
  },
23
23
  };
24
24
 
25
- export const paneOptions = [cacheTtl];
25
+ const quickMode = {
26
+ id: 'quickMode',
27
+ label: 'Context',
28
+ variants: ['penny-claude'],
29
+ default: 'standard',
30
+ choices: [
31
+ { value: 'standard', label: 'Standard (default)', summary: 'Standard context' },
32
+ { value: 'quick', label: 'Quick', summary: 'Quick context' },
33
+ ],
34
+ hint: 'Quick uses fewer tools and shorter instructions for focused coding questions and small edits.',
35
+ apply(env, value) {
36
+ env.PENNYROUTER_QUICK_MODE_ENABLED = String(value === 'quick');
37
+ },
38
+ };
39
+
40
+ export const paneOptions = [cacheTtl, quickMode];
26
41
 
27
42
  const supportedBy = (variant) => paneOptions.filter((option) => option.variants.includes(variant));
28
43
 
@@ -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}`]);