openzoo 0.49.4 → 0.49.5

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/lib/grokui.mjs CHANGED
@@ -431,6 +431,7 @@ function loadThreads() {
431
431
  if (t.status === 'thinking') {
432
432
  t.status = 'idle';
433
433
  t.liveStatus = '';
434
+ t.liveRace = null;
434
435
  }
435
436
  if (Array.isArray(t.history)) {
436
437
  for (const h of t.history) {
@@ -1058,6 +1059,7 @@ const SLASH_COMMANDS = [
1058
1059
  { name: '/models', args: '[filter]', help: 'search the ~435 served models' },
1059
1060
  { name: '/tier', args: 'cheap|medium|expensive|grok 4.6', help: 'how much to spend per turn when no model is pinned' },
1060
1061
  { name: '/race', args: '<n> | <k> <n>', help: 'launch n models; judge the first k back (k=1 = fastest wins)' },
1062
+ { name: '/sitrep', args: '', help: 'session sitrep (drawer)' },
1061
1063
  { name: '/compact', args: '', help: 'summarise history to shrink context' },
1062
1064
  { name: '/clear', args: '', help: 'wipe this thread’s history' },
1063
1065
  { name: '/undo', args: '', help: 'drop the last exchange' },
@@ -1150,6 +1152,7 @@ async function handleSlash(task, t) {
1150
1152
  const cmd = m[1].toLowerCase();
1151
1153
  const arg = m[2].trim();
1152
1154
 
1155
+ if (cmd === 'sitrep') return null; // drawer-only — never a transcript line
1153
1156
  if (cmd === 'help') {
1154
1157
  return 'Commands:\n'
1155
1158
  + SLASH_COMMANDS.map((c) => ` ${(c.name + ' ' + c.args).padEnd(26)} ${c.help}`).join('\n')
@@ -1448,6 +1451,7 @@ setInterval(() => {
1448
1451
  } else {
1449
1452
  t.status = 'idle';
1450
1453
  t.liveStatus = '';
1454
+ t.liveRace = null;
1451
1455
  unlockWorktree(t);
1452
1456
  }
1453
1457
  dirty = true;
@@ -2448,11 +2452,12 @@ async function mcpDirective(url, tool, args) {
2448
2452
 
2449
2453
  // onEvent (optional) gets live progress for whoever's actually watching this
2450
2454
  // call: {type:'start',name,color} when a bot begins its turn, {type:'status',
2451
- // detail} while paying / waiting / racing / walking tools, {type:'delta',name,
2452
- // color,delta} per streamed token (replace:true swaps the bubble once),
2453
- // {type:'final',name,color,text} once its full reply (or directive ack) is
2454
- // settled. Background turns go through kickTurn emitToThread, which is a
2455
- // no-op if nobody has the thread open.
2455
+ // detail} while paying / waiting / racing / walking tools, {type:'race',race}
2456
+ // for the spectator grid (one cell per launched model + a judging beat),
2457
+ // {type:'delta',name,color,delta} per streamed token (replace:true swaps the
2458
+ // bubble once), {type:'final',name,color,text} once its full reply (or
2459
+ // directive ack) is settled. Background turns go through kickTurn →
2460
+ // emitToThread, which is a no-op if nobody has the thread open.
2456
2461
  async function runTurn(threadId, userText, onEvent, images) {
2457
2462
  const t = threads.get(threadId);
2458
2463
  if (!t) return;
@@ -2469,7 +2474,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2469
2474
  const paint = (ev) => {
2470
2475
  if (!stillMine()) return;
2471
2476
  if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
2472
- if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start') t.lastDeltaAt = Date.now();
2477
+ if (ev.type === 'race' && ev.race && t.status === 'thinking') t.liveRace = ev.race;
2478
+ if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race') t.lastDeltaAt = Date.now();
2473
2479
  onEvent?.(ev);
2474
2480
  };
2475
2481
  t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
@@ -2483,6 +2489,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2483
2489
  lockWorktree(t);
2484
2490
  const raceN = Math.min(Number(t.race) || 0, 4);
2485
2491
  const raceNeed = Math.min(Math.max(Number(t.raceNeed) || 1, 1), raceN || 1);
2492
+ t.liveRace = null;
2486
2493
  t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
2487
2494
  let chained = false;
2488
2495
  let parked = false;
@@ -2604,6 +2611,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2604
2611
  return (await brainRace(callMsgs, emit, t.contextId, models, need, undefined, emitStatus, {
2605
2612
  signal: turnAbort.signal,
2606
2613
  onArrivals: (arr) => { t.lastRaceFail = summarizeRaceFailures(arr); },
2614
+ onRace: (snap) => paint({ type: 'race', name: t.name, color: t.color, race: snap }),
2607
2615
  tier: t.tier || 'medium',
2608
2616
  })).trim();
2609
2617
  }
@@ -2757,6 +2765,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2757
2765
  } else if (!t.pendingRun) {
2758
2766
  t.status = 'idle';
2759
2767
  t.liveStatus = '';
2768
+ t.liveRace = null;
2760
2769
  unlockWorktree(t);
2761
2770
  }
2762
2771
  }
@@ -3031,12 +3040,20 @@ const APP_HTML = `<!doctype html>
3031
3040
  whose entire premise is that the box pays for itself. Click-to-copy is a
3032
3041
  shortcut; the address is selectable so Cmd/Ctrl+C still works if copy
3033
3042
  fails. */
3034
- #walletOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.66); z-index: 1200;
3043
+ #walletOverlay, #sitrepOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.66); z-index: 1200;
3035
3044
  display: none; align-items: center; justify-content: center; padding: 24px; }
3036
- #walletOverlay.show { display: flex; }
3037
- #walletBox { width: 100%; max-width: 560px; max-height: 82vh; overflow-y: auto; background: #111113;
3045
+ #walletOverlay.show, #sitrepOverlay.show { display: flex; }
3046
+ #walletBox, #sitrepBox { width: 100%; max-width: 560px; max-height: 82vh; overflow-y: auto; background: #111113;
3038
3047
  border: 1px solid #2c2c2e; border-radius: 16px; padding: 20px 22px; }
3039
- #walletBox h3 { margin: 0 0 2px; font-size: 15px; font-weight: 600; }
3048
+ #walletBox h3, #sitrepBox h3 { margin: 0 0 2px; font-size: 15px; font-weight: 600; }
3049
+ #sitrepBox { max-width: 420px; }
3050
+ .srow { display: flex; justify-content: space-between; align-items: baseline; gap: 14px;
3051
+ padding: 8px 0; border-bottom: 1px solid #1c1c1e; }
3052
+ .srow:last-child { border-bottom: 0; }
3053
+ .slab { color: #6f7080; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; flex: 0 0 auto; }
3054
+ .sval { color: #ececec; font-size: 13px; text-align: right; word-break: break-word; min-width: 0; }
3055
+ .sval.hlime { color: #b8f240; }
3056
+ .sval.hember { color: #f28c4d; }
3040
3057
  .wsub { color: #8e8e93; font-size: 12px; margin-bottom: 16px; }
3041
3058
  .wrow { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
3042
3059
  display: flex; align-items: center; gap: 10px; cursor: pointer; }
@@ -3121,7 +3138,7 @@ const APP_HTML = `<!doctype html>
3121
3138
  color: #f0c9a8; font-size: 10.5px; line-height: 1.45; }
3122
3139
  #hud .hhint.show { display: block; }
3123
3140
  #hud .hhint b { color: #f28c4d; font-weight: 600; }
3124
- #sidebar, #main, #walletOverlay, #composeOverlay,
3141
+ #sidebar, #main, #walletOverlay, #sitrepOverlay, #composeOverlay,
3125
3142
  #inp, #search, #composeInp, .bubble, .md-pre, .runoutput, .runcmd {
3126
3143
  -webkit-app-region: no-drag; }
3127
3144
  #log { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 20px 24px 12px;
@@ -3223,6 +3240,46 @@ const APP_HTML = `<!doctype html>
3223
3240
  @keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
3224
3241
  .tstatus { color: #8e8e93; font-size: 13px; margin-left: 6px; }
3225
3242
  .ttrail { display: block; color: #8e8e93; font-size: 12.5px; margin-top: 8px; }
3243
+ /* Race spectator board. Lives in the transcript, never over the header
3244
+ dials / wallet / cost HUD. 2×2 for four, a row for two, wrap otherwise. */
3245
+ .row.bot:has(.raceboard) { max-width: 92%; }
3246
+ .bubble.raceboard { padding: 10px 11px; background: #1a1a1d; }
3247
+ .racewrap { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
3248
+ .racecaption { color: #6f7080; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
3249
+ .racegrid { display: grid; gap: 7px; grid-template-columns: 1fr 1fr; }
3250
+ .racegrid.n1 { grid-template-columns: 1fr; }
3251
+ .racegrid.n2 { grid-template-columns: 1fr 1fr; }
3252
+ .racegrid.n3 { grid-template-columns: 1fr 1fr; }
3253
+ @media (min-width: 720px) { .racegrid.n3 { grid-template-columns: 1fr 1fr 1fr; } }
3254
+ .racecell { border: 1px solid #2c2c32; border-radius: 12px; padding: 8px 9px 7px;
3255
+ background: #141416; min-width: 0; min-height: 0; transition: opacity .18s ease, border-color .18s ease; }
3256
+ .racecell.streaming { border-color: #3d3d4a; }
3257
+ .racecell.back { border-color: #2a3a18; }
3258
+ .racecell.failed { border-color: #3a2424; }
3259
+ .racecell.abandoned { opacity: .42; }
3260
+ .racecell.winner { border-color: #b8f240; box-shadow: 0 0 0 1px rgba(184,242,64,.28); }
3261
+ .racehead { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; min-width: 0; }
3262
+ .racename { font-size: 12px; font-weight: 600; color: #ececec; white-space: nowrap;
3263
+ overflow: hidden; text-overflow: ellipsis; min-width: 0; }
3264
+ .racechip { flex: 0 0 auto; font-size: 10px; letter-spacing: .03em; color: #8e8e93;
3265
+ border: 1px solid #2c2c32; border-radius: 999px; padding: 1px 7px; }
3266
+ .racecell.streaming .racechip { color: #b8f240; border-color: #3a4a18; }
3267
+ .racecell.back .racechip { color: #b8f240; border-color: #2a3a18; }
3268
+ .racecell.failed .racechip { color: #f28c4d; border-color: #5a3020; }
3269
+ .racecell.abandoned .racechip { color: #6f7080; }
3270
+ .racecell.winner .racechip { color: #0b0b0d; background: #b8f240; border-color: #b8f240; }
3271
+ .raceprev { font: 11.5px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; color: #9a9aa6;
3272
+ white-space: pre-wrap; word-break: break-word; max-height: 8.2em; overflow: hidden; }
3273
+ .racefail { display: inline-block; margin-top: 2px; font-size: 11px; color: #f28c4d; letter-spacing: .03em; }
3274
+ .racejudge { border: 1px dashed #3a4a18; border-radius: 12px; padding: 9px 11px;
3275
+ background: rgba(184,242,64,.05); color: #c8c8b8; }
3276
+ .racejudge.won { border-style: solid; border-color: #b8f240; }
3277
+ .racejudge-lab { font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: #b8f240;
3278
+ margin-bottom: 3px; }
3279
+ .racejudge-msg { font-size: 13px; color: #ececec; }
3280
+ @media (prefers-reduced-motion: reduce) {
3281
+ .racecell { transition: none; }
3282
+ }
3226
3283
  #bar { padding: 10px 16px 18px; position: relative; }
3227
3284
  #row-input { display: flex; align-items: center; gap: 8px; }
3228
3285
  #plusMenu { position: absolute; bottom: 62px; left: 16px; background: #1c1c1e; border-radius: 14px;
@@ -3354,6 +3411,13 @@ const APP_HTML = `<!doctype html>
3354
3411
  </div>
3355
3412
  </div>
3356
3413
  </div>
3414
+ <div id="sitrepOverlay" data-component="sitrep-drawer">
3415
+ <div id="sitrepBox">
3416
+ <h3>Sitrep</h3>
3417
+ <div class="wsub">This thread and this session. No keys.</div>
3418
+ <div id="sitrepBody">loading…</div>
3419
+ </div>
3420
+ </div>
3357
3421
  <div id="main">
3358
3422
  <div id="chatHeader">
3359
3423
  <div id="chatHeaderId"></div>
@@ -3373,17 +3437,17 @@ const APP_HTML = `<!doctype html>
3373
3437
  </select>
3374
3438
  <select class="dial" id="raceSel" data-component="model-race" aria-label="Race models"
3375
3439
  title="Ask N models from the tier at once, drawn at random — fastest real answer wins. You pay for every entrant.">
3376
- <option value="0" selected>1 model</option>
3440
+ <option value="0" selected>1 model 0%</option>
3377
3441
  <optgroup label="first back wins">
3378
- <option value="2">race 2</option>
3379
- <option value="3">race 3</option>
3380
- <option value="4">race 4</option>
3442
+ <option value="2">race 2 −50%</option>
3443
+ <option value="3">race 3 −67%</option>
3444
+ <option value="4">race 4 −75%</option>
3381
3445
  </optgroup>
3382
3446
  <optgroup label="judge the first k back">
3383
- <option value="2 3">best 2 of 3</option>
3384
- <option value="2 4">best 2 of 4</option>
3385
- <option value="3 4">best 3 of 4</option>
3386
- <option value="4 4">best 4 of 4</option>
3447
+ <option value="2 3">best 2 of 3 −67%</option>
3448
+ <option value="2 4">best 2 of 4 −75%</option>
3449
+ <option value="3 4">best 3 of 4 −75%</option>
3450
+ <option value="4 4">best 4 of 4 −75%</option>
3387
3451
  </optgroup>
3388
3452
  </select>
3389
3453
  <button class="dial" id="walletBtn" data-component="wallet-open"
@@ -3411,6 +3475,10 @@ const APP_HTML = `<!doctype html>
3411
3475
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
3412
3476
  <span>Attach files</span>
3413
3477
  </div>
3478
+ <div class="pop-item" id="sitrepBtn">
3479
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="3" width="14" height="18" rx="2"/><line x1="8" y1="8" x2="16" y2="8"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="16" x2="13" y2="16"/></svg>
3480
+ <span>Sitrep</span>
3481
+ </div>
3414
3482
  </div>
3415
3483
  <input id="fileInp" type="file" multiple style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;">
3416
3484
  <div id="attachChips"></div>
@@ -4068,8 +4136,73 @@ const APP_HTML = `<!doctype html>
4068
4136
  walletOverlay.addEventListener('click', (e) => {
4069
4137
  if (e.target === walletOverlay) walletOverlay.classList.remove('show');
4070
4138
  });
4139
+ const sitrepOverlay = document.getElementById('sitrepOverlay');
4140
+ const sitrepBody = document.getElementById('sitrepBody');
4141
+ function raceCutPct(y) {
4142
+ const n = Math.max(1, Number(y) || 1);
4143
+ return Math.round((1 - 1 / n) * 100);
4144
+ }
4145
+ function raceChoiceLabel(y, need) {
4146
+ const n = Math.max(1, Number(y) || 1);
4147
+ const k = Math.max(1, Math.min(Number(need) || 1, n));
4148
+ const cut = raceCutPct(n);
4149
+ const cutTxt = cut === 0 ? '0%' : ('−' + cut + '%');
4150
+ if (n < 2) return '1 model ' + cutTxt;
4151
+ if (k > 1) return 'best ' + k + ' of ' + n + ' ' + cutTxt;
4152
+ return 'race ' + n + ' ' + cutTxt;
4153
+ }
4154
+ function sitrepRow(lab, val, cls) {
4155
+ return '<div class="srow"><span class="slab">' + escapeHtml(lab) + '</span><span class="sval'
4156
+ + (cls ? (' ' + cls) : '') + '">' + escapeHtml(val) + '</span></div>';
4157
+ }
4158
+ async function openSitrep() {
4159
+ sitrepOverlay.classList.add('show');
4160
+ sitrepBody.textContent = 'loading…';
4161
+ const t = knownThreads.find((x) => x.id === activeId) || {};
4162
+ let full = null;
4163
+ try { if (activeId) full = await (await fetch(API + '/threads/' + activeId)).json(); } catch (e) { full = null; }
4164
+ let you = {};
4165
+ try { you = await (await fetch(API + '/hud-summary')).json(); } catch (e) { you = {}; }
4166
+ const y = Number(t.race) || 0;
4167
+ const need = Number(t.raceNeed) || 1;
4168
+ const raceY = y >= 2 ? y : 1;
4169
+ const spent = Number(you.spentUsd) || 0;
4170
+ const cogs = Number(you.cogsUsd) || 0;
4171
+ const direct = Number(you.directUsd) || 0;
4172
+ const mult = spent > 0 ? direct / spent : null;
4173
+ const saved = mult == null ? '—'
4174
+ : ((mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x');
4175
+ const savedCls = mult == null ? '' : (mult >= 1 ? 'hlime' : 'hember');
4176
+ const thinking = (full && full.status === 'thinking') || t.status === 'thinking';
4177
+ const race = (full && full.liveRace) || null;
4178
+ let flight = 'idle';
4179
+ if (thinking && race && race.phase === 'judging') flight = 'classifier judging';
4180
+ else if (thinking && race && race.phase === 'winner') flight = 'winner';
4181
+ else if (thinking && (full && full.liveStatus)) flight = full.liveStatus;
4182
+ else if (thinking && t.liveStatus) flight = t.liveStatus;
4183
+ else if (thinking) flight = 'in flight';
4184
+ const cwd = (full && full.dir) || t.dir || '—';
4185
+ sitrepBody.innerHTML =
4186
+ sitrepRow('race', raceChoiceLabel(raceY, need))
4187
+ + sitrepRow('band', t.tier || 'medium')
4188
+ + sitrepRow('mode', t.runMode || 'ask')
4189
+ + sitrepRow('cwd', cwd)
4190
+ + sitrepRow('in flight', flight)
4191
+ + '<div class="wlanetitle" style="margin-top:16px">this session</div>'
4192
+ + sitrepRow('paid', '$' + (spent >= 0.01 || spent === 0 ? spent.toFixed(2) : spent.toFixed(5)))
4193
+ + sitrepRow('cogs', '$' + (cogs >= 0.01 || cogs === 0 ? cogs.toFixed(2) : cogs.toFixed(5)))
4194
+ + sitrepRow('direct', '$' + (direct >= 0.01 || direct === 0 ? direct.toFixed(2) : direct.toFixed(5)))
4195
+ + sitrepRow('saved vs naked', saved, savedCls)
4196
+ + sitrepRow('paid calls', String(you.paidCalls || 0))
4197
+ + sitrepRow('prepaid', (Number(you.creditUsd) > 0) ? 'yes' : 'no');
4198
+ }
4199
+ function closeSitrep() { sitrepOverlay.classList.remove('show'); }
4200
+ sitrepOverlay.addEventListener('click', (e) => {
4201
+ if (e.target === sitrepOverlay) closeSitrep();
4202
+ });
4071
4203
  document.addEventListener('keydown', (e) => {
4072
4204
  if (e.key === 'Escape' && walletOverlay.classList.contains('show')) walletOverlay.classList.remove('show');
4205
+ if (e.key === 'Escape' && sitrepOverlay.classList.contains('show')) closeSitrep();
4073
4206
  });
4074
4207
 
4075
4208
  document.getElementById('tierSel').addEventListener('change', (e) => setDial('tier', e.target.value));
@@ -4611,6 +4744,12 @@ const APP_HTML = `<!doctype html>
4611
4744
  }
4612
4745
  if (full.status === 'thinking') {
4613
4746
  if (full.liveStatus) streamStatus = full.liveStatus;
4747
+ if (full.liveRace && full.liveRace.racers && full.liveRace.racers.length >= 2) {
4748
+ streamRace = full.liveRace;
4749
+ streamRaceId = full.id;
4750
+ } else if (streamRaceId !== full.id) {
4751
+ streamRace = null;
4752
+ }
4614
4753
  addRow('bot', streamBuf || '…', t.color, t.name);
4615
4754
  // Tag the live bubble so deltas can repaint just this node instead of
4616
4755
  // re-rendering (and re-fetching) the whole thread on every token.
@@ -4625,8 +4764,60 @@ const APP_HTML = `<!doctype html>
4625
4764
  // so a turn showed "…" for its whole duration and then arrived in one lump.
4626
4765
  let streamBuf = '';
4627
4766
  let streamStatus = '';
4767
+ let streamRace = null;
4768
+ let streamRaceId = '';
4769
+ let raceHandoff = 0;
4628
4770
  let es = null, esId = null;
4771
+ function raceIsLive(r) {
4772
+ return !!(r && r.racers && r.racers.length >= 2 && streamRaceId === activeId);
4773
+ }
4774
+ function shortRaceName(id) {
4775
+ const s = String(id || '');
4776
+ const i = s.lastIndexOf('/');
4777
+ return (i >= 0 ? s.slice(i + 1) : s) || 'model';
4778
+ }
4779
+ function raceGridHtml(race) {
4780
+ const racers = race.racers || [];
4781
+ const n = racers.length;
4782
+ const need = Math.max(1, Number(race.need) || 1);
4783
+ const caption = (need > 1 ? ('first ' + need + ' of ' + n) : (n + ' launched'))
4784
+ + (race.recut ? ' · recut' : '');
4785
+ let cells = '';
4786
+ for (let i = 0; i < racers.length; i++) {
4787
+ const r = racers[i];
4788
+ const win = race.phase === 'winner' && race.winner && r.model === race.winner;
4789
+ const cls = 'racecell ' + (r.status || 'waiting') + (win ? ' winner' : '');
4790
+ const chip = win ? 'winner' : (r.status || 'waiting');
4791
+ let body = '';
4792
+ if (r.status === 'failed') {
4793
+ body = '<span class="racefail">' + escapeHtml(r.fail ? ('fail · ' + r.fail) : 'fail') + '</span>';
4794
+ } else if (r.preview) {
4795
+ body = '<div class="raceprev">' + escapeHtml(r.preview) + '</div>';
4796
+ } else if (r.status === 'abandoned') {
4797
+ body = '<div class="raceprev">abandoned</div>';
4798
+ } else {
4799
+ body = '<div class="raceprev"></div>';
4800
+ }
4801
+ cells += '<div class="' + cls + '"><div class="racehead"><span class="racename">'
4802
+ + escapeHtml(r.short || shortRaceName(r.model)) + '</span><span class="racechip">'
4803
+ + escapeHtml(chip) + '</span></div>' + body + '</div>';
4804
+ }
4805
+ let judge = '';
4806
+ if (need > 1 && (race.phase === 'judging' || race.phase === 'winner')) {
4807
+ const won = race.phase === 'winner' && race.winner;
4808
+ const msg = won
4809
+ ? ('goes to ' + shortRaceName(race.winner))
4810
+ : ('looking at the ' + need + ' that made it back');
4811
+ judge = '<div class="racejudge' + (won ? ' won' : '') + '"><div class="racejudge-lab">classifier</div>'
4812
+ + '<div class="racejudge-msg">' + escapeHtml(msg)
4813
+ + (won ? '' : ' <span class="dots"><span></span><span></span><span></span></span>')
4814
+ + '</div></div>';
4815
+ }
4816
+ return '<div class="racewrap"><div class="racecaption">' + escapeHtml(caption) + '</div>'
4817
+ + '<div class="racegrid n' + n + '">' + cells + '</div>' + judge + '</div>';
4818
+ }
4629
4819
  function liveBubbleHtml() {
4820
+ if (raceIsLive(streamRace)) return raceGridHtml(streamRace);
4630
4821
  if (streamBuf) {
4631
4822
  const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
4632
4823
  ? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
@@ -4639,6 +4830,13 @@ const APP_HTML = `<!doctype html>
4639
4830
  function paintStream() {
4640
4831
  const b = document.getElementById('streamBubble');
4641
4832
  if (!b) { render(); return; }
4833
+ if (raceIsLive(streamRace)) {
4834
+ b.classList.add('raceboard');
4835
+ b.innerHTML = liveBubbleHtml();
4836
+ if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
4837
+ return;
4838
+ }
4839
+ b.classList.remove('raceboard');
4642
4840
  // Deltas stay as text; a silent wait paints dots + one mutating status
4643
4841
  // line so a 20–40s pay/model wait is obviously alive.
4644
4842
  if (streamBuf && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
@@ -4654,17 +4852,50 @@ const APP_HTML = `<!doctype html>
4654
4852
  esId = id;
4655
4853
  streamBuf = '';
4656
4854
  streamStatus = '';
4855
+ streamRace = null;
4856
+ streamRaceId = id;
4857
+ raceHandoff += 1;
4657
4858
  es = new EventSource('/stream/' + id); // EventSource reconnects on its own
4658
4859
  es.onmessage = (e) => {
4659
4860
  let ev;
4660
4861
  try { ev = JSON.parse(e.data); } catch { return; }
4661
- if (ev.type === 'start') { streamBuf = ''; streamStatus = ev.detail || 'waiting on model…'; paintStream(); }
4862
+ if (ev.type === 'start') {
4863
+ streamBuf = '';
4864
+ streamStatus = ev.detail || 'waiting on model…';
4865
+ streamRace = null;
4866
+ streamRaceId = id;
4867
+ paintStream();
4868
+ }
4662
4869
  else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
4870
+ else if (ev.type === 'race') {
4871
+ if (ev.race && ev.race.racers && ev.race.racers.length >= 2) {
4872
+ streamRace = ev.race;
4873
+ streamRaceId = id;
4874
+ }
4875
+ paintStream();
4876
+ }
4663
4877
  else if (ev.type === 'delta') {
4664
4878
  streamBuf = ev.replace ? (ev.delta || '') : streamBuf + (ev.delta || '');
4665
4879
  paintStream();
4666
4880
  }
4667
- else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; streamStatus = ''; render(); }
4881
+ else if (ev.type === 'final' || ev.type === 'run-pending') {
4882
+ if (ev.type === 'final' && raceIsLive(streamRace)) {
4883
+ paintStream();
4884
+ const token = ++raceHandoff;
4885
+ setTimeout(function () {
4886
+ if (token !== raceHandoff) return;
4887
+ streamBuf = '';
4888
+ streamStatus = '';
4889
+ streamRace = null;
4890
+ render();
4891
+ }, 420);
4892
+ return;
4893
+ }
4894
+ streamBuf = '';
4895
+ streamStatus = '';
4896
+ streamRace = null;
4897
+ render();
4898
+ }
4668
4899
  };
4669
4900
  es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
4670
4901
  }
@@ -4721,6 +4952,12 @@ const APP_HTML = `<!doctype html>
4721
4952
 
4722
4953
  async function submit() {
4723
4954
  const task = inp.value.trim();
4955
+ if (/^\/sitrep\b/i.test(task)) {
4956
+ inp.value = '';
4957
+ send.classList.remove('show');
4958
+ openSitrep();
4959
+ return;
4960
+ }
4724
4961
  if ((!task && !pendingFiles.length && !pendingImages.length) || !activeId) return;
4725
4962
  inp.value = '';
4726
4963
  send.classList.remove('show');
@@ -4775,6 +5012,12 @@ const APP_HTML = `<!doctype html>
4775
5012
  function slashAccept(i) {
4776
5013
  const c = slashHits[i];
4777
5014
  if (!c) return;
5015
+ if (c.name === '/sitrep') {
5016
+ inp.value = '';
5017
+ slashMenu.classList.remove('show');
5018
+ openSitrep();
5019
+ return;
5020
+ }
4778
5021
  // Commands that take arguments keep the caret going; ones that don't are
4779
5022
  // ready to send, so don't make the user delete a trailing space.
4780
5023
  inp.value = c.name + (c.args ? ' ' : '');
@@ -4807,6 +5050,11 @@ const APP_HTML = `<!doctype html>
4807
5050
  plusBtn.addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.toggle('show'); });
4808
5051
  document.addEventListener('click', () => plusMenu.classList.remove('show'));
4809
5052
  document.getElementById('attachBtn').addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.remove('show'); fileInp.click(); });
5053
+ document.getElementById('sitrepBtn').addEventListener('click', (e) => {
5054
+ e.stopPropagation();
5055
+ plusMenu.classList.remove('show');
5056
+ openSitrep();
5057
+ });
4810
5058
  fileInp.addEventListener('change', async () => {
4811
5059
  for (const f of Array.from(fileInp.files)) {
4812
5060
  const looksText = /^text\\//.test(f.type) || /\\.(txt|md|js|mjs|ts|tsx|jsx|py|json|css|html|csv|log|ya?ml|sh)$/i.test(f.name);
@@ -5325,6 +5573,7 @@ const server = http.createServer((req, res) => {
5325
5573
  res.end(t ? JSON.stringify({
5326
5574
  id: t.id, history: t.history, status: t.status,
5327
5575
  liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
5576
+ liveRace: t.status === 'thinking' ? (t.liveRace || null) : null,
5328
5577
  lastRaceFail: t.lastRaceFail || null,
5329
5578
  workspacePort: workspacePort || 0, dir: t.dir || WORKSPACE_DIR,
5330
5579
  }) : '{}');
@@ -5417,6 +5666,8 @@ const server = http.createServer((req, res) => {
5417
5666
  // checking your spend or clearing a thread never costs anything.
5418
5667
  // /dir and /mode keep their own handlers below, untouched.
5419
5668
  if (t && /^\//.test(task.trim())) {
5669
+ // Drawer-only. Never dump sitrep into the transcript.
5670
+ if (/^\/sitrep\b/i.test(task.trim())) return;
5420
5671
  const handled = await handleSlash(task.trim(), t).catch((e) => `error: ${e.message}`);
5421
5672
  if (handled !== null && handled !== undefined) {
5422
5673
  t.history.push({ who: 'bot', text: handled });
package/lib/livestatus.js CHANGED
@@ -28,6 +28,68 @@ export function formatPayStatus(attempt = 0) {
28
28
  return Number(attempt) > 0 ? 'waiting on x402…' : 'paying…';
29
29
  }
30
30
 
31
+ /** Cut from 1-model savings. X does not change it — they pay every launched racer. */
32
+ export function raceSavingsCutPct(y) {
33
+ const n = Math.max(1, Number(y) || 1);
34
+ return Math.round((1 - 1 / n) * 100);
35
+ }
36
+
37
+ function sitrepUsd(n) {
38
+ const x = Number(n) || 0;
39
+ return x >= 0.01 || x === 0 ? `$${x.toFixed(2)}` : `$${x.toFixed(5)}`;
40
+ }
41
+
42
+ function sitrepFlight({ status, liveStatus, liveRace } = {}) {
43
+ if (status !== 'thinking') return 'idle';
44
+ if (liveRace?.phase === 'judging') return 'classifier judging';
45
+ if (liveRace?.phase === 'winner') return 'winner';
46
+ if (liveStatus) return String(liveStatus);
47
+ return 'in flight';
48
+ }
49
+
50
+ /**
51
+ * Compact sitrep field list (drawer). No keys, no .npmrc.
52
+ * Prepaid is yes/no — never a secret.
53
+ */
54
+ export function formatSitrep(info = {}) {
55
+ const y = Number(info.race) >= 2 ? Number(info.race) : 1;
56
+ const need = Number(info.raceNeed) || 1;
57
+ const spent = Number(info.spentUsd) || 0;
58
+ const cogs = Number(info.cogsUsd) || 0;
59
+ const direct = Number(info.directUsd) || 0;
60
+ const mult = spent > 0 ? direct / spent : null;
61
+ const saved = mult == null
62
+ ? '—'
63
+ : `${mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)}x`;
64
+ const credit = Number(info.creditUsd);
65
+ const prepaid = Number.isFinite(credit) && credit > 0 ? 'yes' : 'no';
66
+ return [
67
+ 'Sitrep',
68
+ ` race ${raceChoiceLabel(y, need)}`,
69
+ ` band ${info.tier || 'medium'}`,
70
+ ` mode ${info.runMode || 'ask'}`,
71
+ ` cwd ${info.dir || '—'}`,
72
+ ` in flight ${sitrepFlight(info)}`,
73
+ ` paid ${sitrepUsd(spent)}`,
74
+ ` cogs ${sitrepUsd(cogs)}`,
75
+ ` direct ${sitrepUsd(direct)}`,
76
+ ` saved ${saved}`,
77
+ ` paid calls ${Number(info.paidCalls) || 0}`,
78
+ ` prepaid ${prepaid}`,
79
+ ].join('\n');
80
+ }
81
+
82
+ /** Picker / sitrep label: `best 2 of 4 −75%`, `1 model 0%`. */
83
+ export function raceChoiceLabel(y, need = 1) {
84
+ const n = Math.max(1, Number(y) || 1);
85
+ const k = Math.max(1, Math.min(Number(need) || 1, n));
86
+ const cut = raceSavingsCutPct(n);
87
+ const cutTxt = cut === 0 ? '0%' : `−${cut}%`;
88
+ if (n < 2) return `1 model ${cutTxt}`;
89
+ if (k > 1) return `best ${k} of ${n} ${cutTxt}`;
90
+ return `race ${n} ${cutTxt}`;
91
+ }
92
+
31
93
  /** First-X-back race: how many of the K we asked for have actually landed. */
32
94
  export function formatRaceStatus(back, need) {
33
95
  const n = Math.max(1, Number(need) || 1);
@@ -35,6 +97,24 @@ export function formatRaceStatus(back, need) {
35
97
  return `racing ${b}/${n} back…`;
36
98
  }
37
99
 
100
+ /** OpenRouter id → short cell label. `z-ai/glm-4.7` → `glm-4.7`. */
101
+ export function shortModelName(id) {
102
+ const s = String(id || '').trim();
103
+ if (!s) return 'model';
104
+ const parts = s.split('/');
105
+ return parts[parts.length - 1] || s;
106
+ }
107
+
108
+ /** Compact spectator preview — opening lines, not the whole answer. */
109
+ export function clipRacePreview(text, maxLines = 8, maxChars = 420) {
110
+ const s = String(text || '').replace(/\r/g, '');
111
+ if (!s) return '';
112
+ const lines = s.split('\n');
113
+ let out = lines.length > maxLines ? lines.slice(0, maxLines).join('\n') + '\n…' : s;
114
+ if (out.length > maxChars) out = `${out.slice(0, maxChars - 1)}…`;
115
+ return out;
116
+ }
117
+
38
118
  /** Race-level failure when no countable answer exists. Never a single model name. */
39
119
  export const RACE_EVERY_FAILED = '(race: every model failed — no reply)';
40
120
 
@@ -154,20 +234,76 @@ export function pickRaceWinner(cands, minScore = RACE_MIN_SCORE) {
154
234
  /**
155
235
  * Live race bubble: stream the fastest still-alive entrant, swap once if the
156
236
  * winner is someone else. `onDelta(text, { replace, model })`.
237
+ *
238
+ * `onRace(snap)` is the spectator feed — one cell per launched model, with
239
+ * waiting/streaming/back/failed/abandoned plus a truncated preview. Not a
240
+ * second racer: `phase: 'judging'` is the classifier looking at the X that
241
+ * already made it back.
157
242
  */
158
- export function createRaceFeed(onDelta, onStatus, need) {
243
+ export function createRaceFeed(onDelta, onStatus, need, onRace) {
159
244
  let live = null;
160
245
  let settled = false;
161
246
  let back = 0;
247
+ let phase = 'racing';
248
+ let winnerModel = '';
249
+ let recutNote = '';
162
250
  const buf = new Map();
163
251
  const dead = new Set();
252
+ const order = [];
253
+ const cells = new Map();
164
254
  const paintStatus = () => { onStatus?.(formatRaceStatus(back, need)); };
255
+ const ensure = (model) => {
256
+ const id = String(model || '').trim() || 'model';
257
+ if (cells.has(id)) return cells.get(id);
258
+ const row = { model: id, status: 'waiting', preview: '', fail: '' };
259
+ cells.set(id, row);
260
+ order.push(id);
261
+ return row;
262
+ };
263
+ const freezeStragglers = () => {
264
+ if (back < need) return;
265
+ for (const row of cells.values()) {
266
+ if (row.status === 'waiting' || row.status === 'streaming') row.status = 'abandoned';
267
+ }
268
+ };
269
+ const snapshot = () => ({
270
+ need,
271
+ launched: order.length,
272
+ back,
273
+ phase,
274
+ winner: winnerModel || '',
275
+ recut: recutNote || '',
276
+ racers: order.map((id) => {
277
+ const row = cells.get(id);
278
+ return {
279
+ model: id,
280
+ short: shortModelName(id),
281
+ status: row.status,
282
+ preview: row.status === 'failed' ? '' : clipRacePreview(row.preview),
283
+ fail: row.fail || '',
284
+ };
285
+ }),
286
+ });
287
+ const emitRace = () => { onRace?.(snapshot()); };
165
288
  return {
166
- start() { paintStatus(); },
289
+ start(models, extra) {
290
+ if (Array.isArray(models)) {
291
+ for (const m of models) if (m) ensure(m);
292
+ }
293
+ if (extra && extra.recut) recutNote = String(extra.recut);
294
+ paintStatus();
295
+ emitRace();
296
+ },
297
+ snapshot,
167
298
  liveModel() { return live; },
168
299
  onToken(model, chunk) {
169
300
  if (settled || chunk == null || chunk === '') return;
301
+ const row = ensure(model);
302
+ if (row.status === 'abandoned' || row.status === 'failed' || row.status === 'back') return;
170
303
  buf.set(model, (buf.get(model) || '') + chunk);
304
+ row.preview = buf.get(model) || '';
305
+ if (row.status === 'waiting') row.status = 'streaming';
306
+ emitRace();
171
307
  if (!live) {
172
308
  live = model;
173
309
  onDelta?.(chunk, { model });
@@ -175,8 +311,15 @@ export function createRaceFeed(onDelta, onStatus, need) {
175
311
  }
176
312
  if (live === model) onDelta?.(chunk, { model });
177
313
  },
178
- onFail(model) {
314
+ onFail(model, arrival) {
179
315
  dead.add(model);
316
+ const row = ensure(model);
317
+ if (row.status !== 'abandoned' && row.status !== 'back') {
318
+ row.status = 'failed';
319
+ row.fail = raceFailKind(arrival || { model, text: '', error: 'error' });
320
+ row.preview = '';
321
+ emitRace();
322
+ }
180
323
  if (settled || live !== model) return;
181
324
  const next = [...buf.entries()].find(([m, t]) => m !== model && t && !dead.has(m));
182
325
  if (next) {
@@ -186,18 +329,44 @@ export function createRaceFeed(onDelta, onStatus, need) {
186
329
  live = null;
187
330
  }
188
331
  },
189
- onBack() {
332
+ onBack(model) {
190
333
  // Late countable stragglers after ship used to paint "racing 4/2 back…"
191
334
  // onto an already-idle thread (GET /threads/:id returns raw liveStatus).
192
335
  if (settled || back >= need) return;
193
336
  back += 1;
337
+ let row = model ? ensure(model) : null;
338
+ if (!row) {
339
+ row = [...cells.values()].find((r) => r.status === 'streaming' || r.status === 'waiting');
340
+ }
341
+ if (row && row.status !== 'abandoned' && row.status !== 'failed') {
342
+ row.status = 'back';
343
+ if (buf.has(row.model)) row.preview = buf.get(row.model);
344
+ }
345
+ freezeStragglers();
194
346
  paintStatus();
347
+ emitRace();
348
+ },
349
+ judge() {
350
+ if (settled) return;
351
+ phase = 'judging';
352
+ freezeStragglers();
353
+ emitRace();
195
354
  },
196
355
  settle(winner) {
197
356
  settled = true;
357
+ phase = 'winner';
198
358
  const text = String(winner?.text || '').trim()
199
359
  ? winner.text
200
360
  : RACE_EVERY_FAILED;
361
+ winnerModel = winner?.error ? '' : (winner?.model || '');
362
+ if (winnerModel) {
363
+ const row = ensure(winnerModel);
364
+ if (row.status !== 'failed') {
365
+ row.status = 'back';
366
+ if (winner?.text) row.preview = String(winner.text);
367
+ }
368
+ }
369
+ emitRace();
201
370
  // Live stream already showing this answer — keep going, do not re-dump.
202
371
  if (winner?.model && live === winner.model && !winner.error) return;
203
372
  live = winner?.model || live;
package/lib/podagent.mjs CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  } from './livestatus.js';
31
31
  import {
32
32
  probeGatewayRace, capRaceByCredit, inferRaceTier, RACE_NO_CREDIT,
33
+ recutRaceByHud, sessionDollarX,
33
34
  } from './racesettle.js';
34
35
  import { homedir } from 'node:os';
35
36
 
@@ -679,8 +680,16 @@ async function readProxySession(proxy = completionsProxy()) {
679
680
  }
680
681
 
681
682
  async function raceBudget(hooks) {
682
- if (hooks.creditUsd != null || hooks.quoteUsd != null) {
683
- return { creditUsd: hooks.creditUsd, quoteUsd: hooks.quoteUsd };
683
+ const injected = hooks.creditUsd != null || hooks.quoteUsd != null
684
+ || hooks.spentUsd != null || hooks.directUsd != null || hooks.dollarX != null;
685
+ if (injected) {
686
+ return {
687
+ creditUsd: hooks.creditUsd,
688
+ quoteUsd: hooks.quoteUsd,
689
+ spentUsd: hooks.spentUsd,
690
+ directUsd: hooks.directUsd,
691
+ dollarX: hooks.dollarX,
692
+ };
684
693
  }
685
694
  // Injected stream = unit-test N-parallel path. Do not poke :8402.
686
695
  if (hooks.stream) return {};
@@ -688,6 +697,8 @@ async function raceBudget(hooks) {
688
697
  return {
689
698
  creditUsd: s?.creditUsd,
690
699
  quoteUsd: s?.lastQuoteUsd ?? s?.quoteUsd,
700
+ spentUsd: s?.spentUsd,
701
+ directUsd: s?.directUsd,
691
702
  };
692
703
  }
693
704
 
@@ -719,8 +730,8 @@ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxT
719
730
  const classify = hooks.classify || classifyRaceAnswer;
720
731
  const pairwise = hooks.pairwise || pairwiseTied;
721
732
  const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
722
- const feed = createRaceFeed(onDelta, onStatus, want);
723
- feed.start();
733
+ const feed = createRaceFeed(onDelta, onStatus, want, hooks.onRace);
734
+ feed.start(models, hooks.raceRecut ? { recut: hooks.raceRecut } : undefined);
724
735
 
725
736
  const arrivals = [];
726
737
  const done = [];
@@ -772,7 +783,7 @@ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxT
772
783
  if (isRaceCountable(lastFail)) {
773
784
  arrivals.push(lastFail);
774
785
  done.push(lastFail);
775
- feed.onBack();
786
+ feed.onBack(lastFail.model);
776
787
  if (text) onDelta(text);
777
788
  break;
778
789
  }
@@ -785,9 +796,9 @@ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxT
785
796
  arrivals.push(a);
786
797
  if (isRaceCountable(a)) {
787
798
  done.push(a);
788
- feed.onBack();
799
+ feed.onBack(a.model);
789
800
  } else {
790
- feed.onFail(a.model);
801
+ feed.onFail(a.model, a);
791
802
  }
792
803
  }
793
804
  lastFail = parsed.arrivals[parsed.arrivals.length - 1] || lastFail;
@@ -804,6 +815,7 @@ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxT
804
815
  if (cands.length === 1) return ship(cands[0]);
805
816
 
806
817
  onStatus?.('judging…');
818
+ feed.judge();
807
819
  const scored = await Promise.all(cands.map(async (c) => {
808
820
  let score = 0;
809
821
  try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
@@ -960,6 +972,31 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
960
972
  const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
961
973
  let list = (models || []).filter(Boolean).slice(0, RACE_MAX);
962
974
  const budget = await raceBudget(hooks);
975
+ let tier = hooks.tier || inferRaceTier(list, 'medium');
976
+ let wantNeed = Math.max(1, Math.min(Number(need) || 1, list.length || 1));
977
+ const dollarX = sessionDollarX(budget);
978
+ const hud = recutRaceByHud({
979
+ y: list.length, need: wantNeed, dollarX, tier,
980
+ });
981
+ let raceRecut = '';
982
+ if (hud.recut) {
983
+ raceRecut = 'savings';
984
+ if (hud.tier !== tier && !hooks.stream) {
985
+ try {
986
+ const next = await tierModels(hud.tier, hud.y, true);
987
+ if (next.length) { list = next; tier = hud.tier; }
988
+ else list = list.slice(0, hud.y);
989
+ } catch { list = list.slice(0, hud.y); }
990
+ } else {
991
+ list = list.slice(0, hud.y);
992
+ tier = hud.tier;
993
+ }
994
+ wantNeed = Math.min(hud.need, list.length);
995
+ onStatus?.(hud.y < 2
996
+ ? 'race recut to 1 — savings'
997
+ : `race recut to ${hud.y} — savings`);
998
+ }
999
+ hooks = { ...hooks, tier, raceRecut };
963
1000
  const capped = capRaceByCredit(list.length, budget);
964
1001
  if (capped.n < 1) {
965
1002
  onStatus?.('race refused — no credit');
@@ -970,7 +1007,7 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
970
1007
  onStatus?.(capped.n < 2 ? 'race shrunk to 1 — credit' : `race shrunk to ${capped.n} — credit`);
971
1008
  }
972
1009
  if (list.length < 2) return stream(messages, onDelta, contextId, list[0], maxTokens, 0, 0, onStatus);
973
- const want = Math.max(1, Math.min(Number(need) || 1, list.length));
1010
+ const want = Math.max(1, Math.min(wantNeed, list.length));
974
1011
 
975
1012
  // One Fly settle when the completions door honors `race:`. Custom stream
976
1013
  // hooks (unit tests of the N-parallel judge) keep the old path. Old
@@ -985,8 +1022,8 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
985
1022
  return brainGatewayRace(messages, onDelta, contextId, list, want, maxTokens, onStatus, hooks);
986
1023
  }
987
1024
 
988
- const feed = createRaceFeed(onDelta, onStatus, want);
989
- feed.start();
1025
+ const feed = createRaceFeed(onDelta, onStatus, want, hooks.onRace);
1026
+ feed.start(list, raceRecut ? { recut: raceRecut } : undefined);
990
1027
 
991
1028
  const done = [];
992
1029
  const arrivals = [];
@@ -1034,7 +1071,7 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
1034
1071
  if (isRaceCountable(last)) {
1035
1072
  arrivals.push(last);
1036
1073
  done.push(last);
1037
- feed.onBack();
1074
+ feed.onBack(m);
1038
1075
  return;
1039
1076
  }
1040
1077
  } catch (e) {
@@ -1043,7 +1080,7 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
1043
1080
  if (!shouldRetryRaceArrival(last) || attempt === 1 || raceAbort.signal.aborted) break;
1044
1081
  }
1045
1082
  arrivals.push(last);
1046
- feed.onFail(m);
1083
+ feed.onFail(m, last);
1047
1084
  };
1048
1085
 
1049
1086
  const attempts = list.map((m) => runOne(m).finally(() => {
@@ -1068,6 +1105,7 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
1068
1105
  if (cands.length === 1) return ship(cands[0]);
1069
1106
 
1070
1107
  onStatus?.('judging…');
1108
+ feed.judge();
1071
1109
  const scored = await Promise.all(cands.map(async (c) => {
1072
1110
  let score = 0;
1073
1111
  try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
package/lib/racesettle.js CHANGED
@@ -8,6 +8,62 @@
8
8
 
9
9
  export const FLY_GATEWAY_HOST = 'x402-tokens.fly.dev';
10
10
  export const RACE_NO_CREDIT = '(race: not enough prepaid credit — shrink N or top up, rather than fire on $0)';
11
+ /** Low end of the pitched 5–10× vs frontier. Session green HUD below this recuts Y. */
12
+ export const RACE_HUD_TARGET = 5;
13
+
14
+ const TIER_DOWN = {
15
+ 'grok4.6': 'medium',
16
+ grok46: 'medium',
17
+ expensive: 'medium',
18
+ medium: 'cheap',
19
+ cheap: 'cheap',
20
+ };
21
+
22
+ export function cheaperRaceTier(tier) {
23
+ const t = String(tier || 'medium');
24
+ return TIER_DOWN[t] || 'cheap';
25
+ }
26
+
27
+ /** Same number the HUD green `x` uses: direct/spent. */
28
+ export function sessionDollarX({ dollarX, spentUsd, directUsd } = {}) {
29
+ const given = Number(dollarX);
30
+ if (Number.isFinite(given) && given > 0) return given;
31
+ const spent = Number(spentUsd);
32
+ const direct = Number(directUsd);
33
+ if (spent > 0 && Number.isFinite(direct)) return direct / spent;
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Recut launched Y (and maybe drop a band) when session green HUD is thin.
39
+ * Assumes the current multiple already includes this Y tax, so implied
40
+ * single-model x ≈ dollarX × y. Need (X) scales with Y. No user refunds.
41
+ *
42
+ * 2.09x on a 4-racer → implied ~8.4x single → Y=1 (back in the 5–10× band).
43
+ */
44
+ export function recutRaceByHud({
45
+ y, need = 1, dollarX, tier = 'medium', target = RACE_HUD_TARGET,
46
+ } = {}) {
47
+ const launched = Math.max(1, Math.floor(Number(y) || 1));
48
+ const k = Math.max(1, Math.min(Math.floor(Number(need) || 1), launched));
49
+ const x = Number(dollarX);
50
+ const band = String(tier || 'medium');
51
+ if (!Number.isFinite(x) || x <= 0 || x >= target) {
52
+ return { y: launched, need: k, tier: band, recut: false, reason: null };
53
+ }
54
+ const impliedSingle = x * launched;
55
+ const maxY = Math.max(1, Math.min(launched, Math.floor(impliedSingle / target)));
56
+ const nextTier = impliedSingle < target ? cheaperRaceTier(band) : band;
57
+ const nextNeed = Math.max(1, Math.min(k, maxY));
58
+ const recut = maxY < launched || nextTier !== band;
59
+ return {
60
+ y: maxY,
61
+ need: nextNeed,
62
+ tier: nextTier,
63
+ recut,
64
+ reason: recut ? 'savings' : null,
65
+ };
66
+ }
11
67
 
12
68
  const FLY_RE = /x402-tokens\.fly\.dev/i;
13
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.49.4",
3
+ "version": "0.49.5",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",