openzoo 0.48.92 → 0.48.96

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
@@ -12,7 +12,7 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSyn
12
12
  import { cpus, homedir } from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES } from './podagent.mjs';
15
- import { peekDirectiveStatus, STALE_THINKING_MS } from './livestatus.js';
15
+ import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS } from './livestatus.js';
16
16
  import { creditBalance } from './info.js';
17
17
  import {
18
18
  SUBSCRIPTIONS_PAGE,
@@ -221,10 +221,12 @@ subtasks), you may instead reply with EXACTLY one line, no prose, using one of:
221
221
  independent agent and give it a task
222
222
  SEND: <name> | <message> message an agent thread that already
223
223
  exists (yours or one you spawned)
224
- PING: <name> one-line status: still working, or
225
- its last result
224
+ PING: <name> wake that agent to take a turn now
225
+ (* / all / everyone = the whole project).
226
+ You get back "pinged, working" — that is
227
+ an ack, not the child's result
226
228
  PEEK: <name> a fuller look — its last few messages,
227
- not just the latest one
229
+ not just the latest one. Read-only.
228
230
  You are given the result before your next line, so none of these block you — check back
229
231
  later if it's still working.
230
232
 
@@ -500,7 +502,7 @@ spawn/delegate/create agents AND no other bot has already done it this round, re
500
502
  EXACTLY one line, no prose, using one of:
501
503
  SPAWN: <short name> | <task for the new agent> create a new thread with its own agent
502
504
  SEND: <name> | <message> message an existing agent thread
503
- PING: <name> one-line status, or its last result
505
+ PING: <name> wake that agent (* / all = the project)
504
506
  PEEK: <name> a fuller look at its last few messages
505
507
 
506
508
  You ALSO have real (sandboxed) filesystem access, scoped to THIS group's own directory —
@@ -661,6 +663,35 @@ const NUDGE = 'That reply announced work instead of doing it — no directive li
661
663
  const AUTO_CONTINUE = 'AUTO is still on — do not stop and do not ask the user to type continue. '
662
664
  + 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
663
665
  + 'or DONE: if the job is actually finished.';
666
+ // PING used to be a read: last-line status, no turn. Idle children stayed idle
667
+ // while the parent treated the dump as evidence they had acted. A ping is a
668
+ // wake — the same harness continue AUTO already uses, unless a custom message
669
+ // was given. Thinking threads are left alone; pendingRun stays on the human.
670
+ let runTurnOverride = null;
671
+ function setRunTurnForTest(fn) {
672
+ runTurnOverride = typeof fn === 'function' ? fn : null;
673
+ }
674
+ function kickTurn(threadId, userText, onEvent, images) {
675
+ // Default to emitToThread so a spawned/pinged kid streams when someone has
676
+ // that thread open. emitToThread is a no-op if nobody is watching.
677
+ const emit = onEvent === undefined ? (ev) => emitToThread(threadId, ev) : onEvent;
678
+ return (runTurnOverride || runTurn)(threadId, userText, emit, images);
679
+ }
680
+ function pingWakeText(extra) {
681
+ const msg = String(extra || '').trim();
682
+ // A restated spawn brief is not a nudge. MEASURED live: existing-SPAWN
683
+ // wrapped children in childKickoff({fresh:false}) → "CONTEXT REFRESH —
684
+ // you already exist" and they thought, then refused to redo the job.
685
+ if (!msg || /CONTEXT REFRESH|--- your specific job ---|ROOT ASK —/.test(msg)) return AUTO_CONTINUE;
686
+ return msg;
687
+ }
688
+ function pingCanWake(x) {
689
+ return Boolean(x) && !x.pendingRun && x.status !== 'thinking';
690
+ }
691
+ function wakeOnPing(x, extra) {
692
+ // Never childKickoff. Ping is a short continue, not a first-day re-brief.
693
+ kickTurn(x.id, pingWakeText(extra)).catch(() => {});
694
+ }
664
695
  // Ceiling on subagents per thread. Spawning is fire-and-forget and each child
665
696
  // can spawn too, so without a count it is unbounded — MEASURED as 15+ threads
666
697
  // all named tetris-contract, every one of them a live agent making paid calls.
@@ -927,7 +958,7 @@ const SLASH_COMMANDS = [
927
958
  { name: '/memory', args: '[text|clear]', help: 'facts injected into every turn' },
928
959
  { name: '/sessions', args: '', help: 'list all threads' },
929
960
  { name: '/all', args: '<message>', help: 'send a message to every bot in this project' },
930
- { name: '/ping', args: '', help: 'status of every bot in this project' },
961
+ { name: '/ping', args: '', help: 'wake idle bots below you to take a turn now' },
931
962
  { name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
932
963
  { name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
933
964
  { name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
@@ -1037,7 +1068,8 @@ async function handleSlash(task, t) {
1037
1068
  + ' SPAWN: <name> | <task> a NEW subagent (names are unique —\n'
1038
1069
  + ' spawning an existing one sends to it)\n'
1039
1070
  + ' SEND: <name> | <msg> more work for an EXISTING subagent\n'
1040
- + ' PING / PEEK reach or inspect another bot\n\n'
1071
+ + ' PING: <name> wake that bot (* wakes the project)\n'
1072
+ + ' PEEK: <name> read-only look at another bot\n\n'
1041
1073
  + 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
1042
1074
  + 'appear in one reply — four files cost one round trip, not four.';
1043
1075
  }
@@ -1206,23 +1238,30 @@ async function handleSlash(task, t) {
1206
1238
  }
1207
1239
  const crew = subtreeOf(t.id);
1208
1240
  if (!crew.length) return 'You have no subagents to send to.';
1209
- for (const x of crew) runTurn(x.id, arg).catch(() => {});
1241
+ for (const x of crew) kickTurn(x.id, arg).catch(() => {});
1210
1242
  return `Sent down your branch to ${crew.length} bot(s): ${crew.map((x) => x.name).join(', ')}`;
1211
1243
  }
1212
1244
 
1213
- // Read the room without spending anything: who is working, who is blocked on
1214
- // an approval, what each said last.
1245
+ // Wake the room. Used to be a free last-line dump idle children stayed
1246
+ // idle, and a parent reading "kid: <old reply>" thought they had acted.
1247
+ // Empty extra is a nudge (AUTO_CONTINUE), not a cancel. Thinking stays
1248
+ // thinking; pendingRun stays on the human. Same branch scope as /all.
1215
1249
  if (cmd === 'ping') {
1216
- // Same scoping as /all: your branch, not the whole project.
1217
1250
  const crew = subtreeOf(t.id, true);
1218
1251
  if (crew.length < 2) return 'You have no subagents yet.';
1219
1252
  return crew.map((x) => {
1220
1253
  const mark = x.id === t.id ? ' (here)' : '';
1221
- const last = x.history[x.history.length - 1];
1222
- return x.pendingRun ? ` ${x.name}${mark}: BLOCKED — waiting for your approval`
1223
- : x.status === 'thinking' ? ` ${x.name}${mark}: working`
1224
- : last ? ` ${x.name}${mark}: ${String(last.text).replace(/\s+/g, ' ').slice(0, 90)}`
1225
- : ` ${x.name}${mark}: nothing yet`;
1254
+ if (x.id === t.id) {
1255
+ const last = x.history[x.history.length - 1];
1256
+ return x.pendingRun ? ` ${x.name}${mark}: BLOCKED — waiting for your approval`
1257
+ : x.status === 'thinking' ? ` ${x.name}${mark}: working`
1258
+ : last ? ` ${x.name}${mark}: ${String(last.text).replace(/\s+/g, ' ').slice(0, 90)}`
1259
+ : ` ${x.name}${mark}: nothing yet`;
1260
+ }
1261
+ if (x.pendingRun) return ` ${x.name}${mark}: BLOCKED — waiting for your approval`;
1262
+ if (x.status === 'thinking') return ` ${x.name}${mark}: working`;
1263
+ wakeOnPing(x, arg);
1264
+ return ` ${x.name}${mark}: pinged, working`;
1226
1265
  }).join('\n');
1227
1266
  }
1228
1267
 
@@ -1273,7 +1312,7 @@ setInterval(() => {
1273
1312
  if (c.nextAt > now) continue;
1274
1313
  c.nextAt = now + c.everyMin * 60000;
1275
1314
  saveThreads();
1276
- runTurn(t.id, c.text).catch(() => {});
1315
+ kickTurn(t.id, c.text).catch(() => {});
1277
1316
  }
1278
1317
  }
1279
1318
  }, 15000).unref();
@@ -1767,14 +1806,15 @@ async function tryDirective(reply, originId, onEvent) {
1767
1806
  const notes = [];
1768
1807
  for (const { name, task } of parsed) {
1769
1808
  const existing = findByName(name);
1770
- if (existing) { notes.push(`${name} already exists — sending it the task.`); made.push({ t: existing, task, fresh: false }); continue; }
1809
+ if (existing) { notes.push(`${name} already exists — woke it to keep working.`); made.push({ t: existing, task, fresh: false }); continue; }
1771
1810
  const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
1772
1811
  if (siblings >= SPAWN_MAX_CHILDREN) { notes.push(`Not spawning "${name}": already at ${SPAWN_MAX_CHILDREN} subagents.`); continue; }
1773
1812
  made.push({ t: newThread(name, originId), task, fresh: true });
1774
1813
  }
1775
1814
  // Every thread now exists, so spawnPosition sees the COMPLETE cohort.
1776
1815
  for (const { t: sub, task, fresh } of made) {
1777
- runTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
1816
+ if (fresh) kickTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
1817
+ else wakeOnPing(sub);
1778
1818
  }
1779
1819
  const fresh = made.filter((m) => m.fresh).map((m) => m.t.name);
1780
1820
  return [fresh.length ? `Spawned ${fresh.length} together (they can each see the full crew): ${fresh.join(', ')}` : '', ...notes]
@@ -1793,8 +1833,11 @@ async function tryDirective(reply, originId, onEvent) {
1793
1833
  // is what SEND already does.
1794
1834
  const existing = findByName(name);
1795
1835
  if (existing) {
1796
- runTurn(existing.id, childKickoff(threads.get(originId), existing.name, task, { fresh: false })).catch(() => {});
1797
- return `${name} already exists sent it the task instead of spawning a duplicate.`;
1836
+ // Repeat SPAWN is a wake, not a CONTEXT REFRESH. childKickoff({fresh:false})
1837
+ // restates the original job and tells the child it already exists MEASURED,
1838
+ // the crew flipped to that preview, thought once, and sat.
1839
+ wakeOnPing(existing);
1840
+ return `${name} already exists — woke it to keep working.`;
1798
1841
  }
1799
1842
  // Storm guard. Fire-and-forget spawning is unbounded by construction: each
1800
1843
  // child can spawn, and nothing above it is counting.
@@ -1806,7 +1849,7 @@ async function tryDirective(reply, originId, onEvent) {
1806
1849
  }
1807
1850
  const sub = newThread(name, originId);
1808
1851
  // The child gets the ORIGINATING brief plus its own job — see spawnBrief.
1809
- runTurn(sub.id, childKickoff(threads.get(originId), name, task)).catch(() => {}); // fire and forget
1852
+ kickTurn(sub.id, childKickoff(threads.get(originId), name, task)).catch(() => {}); // fire and forget
1810
1853
  return `Spawned ${name} — working on it.`;
1811
1854
  }
1812
1855
  // SEND TO A NAME THAT DOES NOT EXIST YET *SPAWNS* IT.
@@ -1835,7 +1878,7 @@ async function tryDirective(reply, originId, onEvent) {
1835
1878
  const msg = sendM[2].trim();
1836
1879
  const target = findByName(name);
1837
1880
  if (target) {
1838
- runTurn(target.id, childKickoff(threads.get(originId), target.name, msg, { fresh: false })).catch(() => {});
1881
+ kickTurn(target.id, childKickoff(threads.get(originId), target.name, msg, { fresh: false })).catch(() => {});
1839
1882
  return `Messaged ${name}.`;
1840
1883
  }
1841
1884
  // The SAME storm guard SPAWN uses — promoting a SEND must not be a way
@@ -1847,7 +1890,7 @@ async function tryDirective(reply, originId, onEvent) {
1847
1890
  + `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <existing name> | <task>.`;
1848
1891
  }
1849
1892
  const sub = newThread(name, originId);
1850
- runTurn(sub.id, childKickoff(threads.get(originId), name, msg)).catch(() => {});
1893
+ kickTurn(sub.id, childKickoff(threads.get(originId), name, msg)).catch(() => {});
1851
1894
  return `${name} did not exist — spawned it with that message as its task.`;
1852
1895
  }
1853
1896
  // PING had the same anchor bug — no `m`, so a PING after any preamble (or
@@ -1861,27 +1904,29 @@ async function tryDirective(reply, originId, onEvent) {
1861
1904
  const ping = pingAll.length === 1 ? [null, pingAll[0]] : null;
1862
1905
  if (ping) {
1863
1906
  const name = ping[1].trim();
1864
- // PING: * (or 'all' / 'project') reaches EVERY bot in this project.
1907
+ // PING: * (or 'all' / 'project') WAKES every other bot in this project.
1865
1908
  // Coordinating a spawn tree by naming siblings one at a time is a chore
1866
1909
  // the parent should not have to do, and it cannot know who else exists.
1910
+ // The return is an ack ("pinged, working"), not a last-line dump that
1911
+ // lets the parent think the child already acted.
1867
1912
  if (/^(\*|all|project|everyone)$/i.test(name)) {
1868
1913
  const me = threads.get(originId);
1869
1914
  const root = me ? rootOf(me).rootId : null;
1870
1915
  const crew = [...threads.values()].filter((x) => x.id !== originId && rootOf(x).rootId === root);
1871
1916
  if (!crew.length) return 'No other bots in this project yet.';
1872
1917
  return crew.map((x) => {
1873
- const last = x.history[x.history.length - 1];
1874
- return x.pendingRun ? x.name + ': BLOCKED — waiting for approval'
1875
- : x.status === 'thinking' ? x.name + ': still working'
1876
- : last ? x.name + ': ' + String(last.text).slice(0, 200)
1877
- : x.name + ': no reply yet';
1918
+ if (x.pendingRun) return x.name + ': BLOCKED — waiting for approval';
1919
+ if (x.status === 'thinking') return x.name + ': still working';
1920
+ wakeOnPing(x);
1921
+ return x.name + ': pinged, working';
1878
1922
  }).join('\n');
1879
1923
  }
1880
1924
  const target = findByName(name);
1881
- const last = target?.history[target.history.length - 1];
1882
- return !target ? `No thread named "${name}".`
1883
- : target.status === 'thinking' ? `${name} is still working.`
1884
- : last ? `${name}: ${last.text}` : `${name} hasn't replied yet.`;
1925
+ if (!target) return `No thread named "${name}".`;
1926
+ if (target.pendingRun) return `${name}: BLOCKED waiting for approval`;
1927
+ if (target.status === 'thinking') return `${name} is still working.`;
1928
+ wakeOnPing(target);
1929
+ return `${name}: pinged, working`;
1885
1930
  }
1886
1931
  const peek = /^[ \t>*-]*PEEK:\s*(.+)/m.exec(reply);
1887
1932
  if (peek) {
@@ -2089,7 +2134,7 @@ async function tryDirective(reply, originId, onEvent) {
2089
2134
  const root = rootOf(t).rootId;
2090
2135
  const crew = [...threads.values()].filter((x) => x.id !== t.id && rootOf(x).rootId === root);
2091
2136
  for (const x of crew) {
2092
- runTurn(x.id, `[${t.name} finished] ${peek}\n`
2137
+ kickTurn(x.id, `[${t.name} finished] ${peek}\n`
2093
2138
  + `(${t.todos.length - left}/${t.todos.length} of its goals done. `
2094
2139
  + `This is a status peek — do NOT redo this work, and do not reply unless it changes yours.)`)
2095
2140
  .catch(() => {});
@@ -2257,11 +2302,11 @@ async function mcpDirective(url, tool, args) {
2257
2302
 
2258
2303
  // onEvent (optional) gets live progress for whoever's actually watching this
2259
2304
  // call: {type:'start',name,color} when a bot begins its turn, {type:'status',
2260
- // detail} while paying / waiting / walking tools, {type:'delta',name,color,
2261
- // delta} per streamed token, {type:'final',name,color,text} once its full
2262
- // reply (or directive ack) is settled. Background turns — a SPAWNed
2263
- // subagent nobody's looking at yet run with onEvent omitted and just use
2264
- // the plain non-streaming brain(), which is cheaper when nothing renders it.
2305
+ // detail} while paying / waiting / racing / walking tools, {type:'delta',name,
2306
+ // color,delta} per streamed token (replace:true swaps the bubble once),
2307
+ // {type:'final',name,color,text} once its full reply (or directive ack) is
2308
+ // settled. Background turns go through kickTurn emitToThread, which is a
2309
+ // no-op if nobody has the thread open.
2265
2310
  async function runTurn(threadId, userText, onEvent, images) {
2266
2311
  const t = threads.get(threadId);
2267
2312
  if (!t) return;
@@ -2282,7 +2327,9 @@ async function runTurn(threadId, userText, onEvent, images) {
2282
2327
  t.status = 'thinking';
2283
2328
  t.thinkingAt = Date.now();
2284
2329
  t.lastDeltaAt = Date.now();
2285
- t.liveStatus = 'waiting on model…';
2330
+ const raceN = Math.min(Number(t.race) || 0, 4);
2331
+ const raceNeed = Math.min(Math.max(Number(t.raceNeed) || 1, 1), raceN || 1);
2332
+ t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
2286
2333
  let chained = false;
2287
2334
  let parked = false;
2288
2335
  try {
@@ -2343,7 +2390,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2343
2390
  }
2344
2391
  t.messages.push({ role: 'user', content: contentFor(userText, images) });
2345
2392
  let reply = '';
2346
- paint({ type: 'start', name: t.name, color: t.color, detail: 'waiting on model…' });
2393
+ paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'waiting on model…' });
2347
2394
  // Transient: the nudge is appended for THIS call only and never pushed into
2348
2395
  // t.messages, so it can't accumulate across a chained auto run or get bound
2349
2396
  // into the thread's context.
@@ -2368,7 +2415,11 @@ async function runTurn(threadId, userText, onEvent, images) {
2368
2415
  // `attempt` exists because a retry must be allowed to land somewhere else:
2369
2416
  // see the empty-completion loop below.
2370
2417
  const ask = async (attempt = 0) => {
2371
- const emit = (delta) => paint({ type: 'delta', name: t.name, color: t.color, delta });
2418
+ const emit = (delta, meta) => paint({
2419
+ type: 'delta', name: t.name, color: t.color, delta,
2420
+ ...(meta?.replace ? { replace: true } : {}),
2421
+ ...(meta?.model ? { model: meta.model } : {}),
2422
+ });
2372
2423
  const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
2373
2424
  // Retrieval breadth scales with the PROJECT's corpus, not this thread's —
2374
2425
  // the holobrain is shared at the root, so that is the pool being searched.
@@ -2379,10 +2430,10 @@ async function runTurn(threadId, userText, onEvent, images) {
2379
2430
  // need = how many must come BACK before judging. need 1 is a plain
2380
2431
  // first-past-the-post race; need N waits for all of them. The point of
2381
2432
  // the middle (2 of 3) is a judged answer without the slowest entrant
2382
- // setting the latency.
2433
+ // setting the latency. Collection is first-X-back (non-empty);
2434
+ // classify runs only on those X.
2383
2435
  const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
2384
- emitStatus('waiting on model…');
2385
- return (await brainRace(callMsgs, emit, t.contextId, models, need)).trim();
2436
+ return (await brainRace(callMsgs, emit, t.contextId, models, need, undefined, emitStatus)).trim();
2386
2437
  }
2387
2438
  // A retry draws a DIFFERENT model from the tier rather than the same one.
2388
2439
  const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
@@ -3121,12 +3172,9 @@ const APP_HTML = `<!doctype html>
3121
3172
  </div>
3122
3173
  <div id="walletOverlay" data-component="wallet-modal">
3123
3174
  <div id="walletBox">
3124
- <h3>Your wallet</h3>
3125
- <div class="wsub">Two ways to pay, side by side. Wallet/x402 stays. A card subscription sits next to it not instead of it.</div>
3126
- <div class="wlanetitle">Wallet / x402</div>
3127
- <div class="wsub">This is <b>your</b> local burner on this machine (or this box). Keys stay in ~/.openzoo/wallet.json. It is not openzoo’s wallet, not a shared zoo account, not the model’s. You fund these deposit addresses; the app pays x402 per call from this wallet. Public addresses only — the UI never shows the key.</div>
3128
- <div id="walletBody">loading…</div>
3129
- <div class="wlane" id="subLane" data-component="subscribe-lane">
3175
+ <h3>Pay with a card</h3>
3176
+ <div class="wsub">Pay with a card. Basic, Pro, and Ultra are first. Wallet/x402 is the other option below it stays; it is not the lead.</div>
3177
+ <div id="subLane" data-component="subscribe-lane">
3130
3178
  <div class="wlanetitle">Subscribe with a card</div>
3131
3179
  <div class="wtag">Subscription key · no x402</div>
3132
3180
  <div class="wsub">Same plans as the public page. Checkout opens in the system browser — never an in-app Stripe window. After Stripe, this app polls the site’s key endpoint with the checkout session, or you paste the key from the success page.</div>
@@ -3142,6 +3190,11 @@ const APP_HTML = `<!doctype html>
3142
3190
  <div class="wnote" id="subNote"></div>
3143
3191
  <div class="wquiet"><a id="subPageLink" href="${SUBSCRIPTIONS_PAGE}" target="_blank" rel="noopener">Full subscriptions page</a></div>
3144
3192
  </div>
3193
+ <div class="wlane" id="x402Lane" data-component="x402-lane">
3194
+ <div class="wlanetitle">Wallet / x402</div>
3195
+ <div class="wsub">This is <b>your</b> local burner on this machine (or this box). Keys stay in ~/.openzoo/wallet.json. It is not openzoo’s wallet, not a shared zoo account, not the model’s. You fund these deposit addresses; the app pays x402 per call from this wallet. Public addresses only — the UI never shows the key.</div>
3196
+ <div id="walletBody">loading…</div>
3197
+ </div>
3145
3198
  </div>
3146
3199
  </div>
3147
3200
  <div id="main">
@@ -3176,7 +3229,7 @@ const APP_HTML = `<!doctype html>
3176
3229
  </optgroup>
3177
3230
  </select>
3178
3231
  <button class="dial" id="walletBtn" data-component="wallet-open"
3179
- title="Wallet/x402 or subscribe with a card — deposit addresses, live balances, Stripe plans">wallet</button>
3232
+ title="Pay with a card, or use wallet/x402">pay</button>
3180
3233
  <button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">&#8635;</button>
3181
3234
  <button class="icon-btn" id="hudBtn">◎</button>
3182
3235
  </div>
@@ -3486,7 +3539,7 @@ const APP_HTML = `<!doctype html>
3486
3539
  // subagents without retyping — and pressing it addressed the WHOLE
3487
3540
  // project, cousins included. Now every thread with descendants gets
3488
3541
  // one, scoped to its own branch.
3489
- (t.kids ? '<button class="pingall trow-ping" data-testid="ping-all" title="Message all '
3542
+ (t.kids ? '<button class="pingall trow-ping" data-testid="ping-all" title="Wake all '
3490
3543
  + t.kids + ' bot(s) below ' + escapeHtml(t.name) + '">\u21f2 ' + t.kids + '</button>' : '') +
3491
3544
  '<button class="tclose" title="Remove">✕</button>';
3492
3545
  row.addEventListener('click', () => {
@@ -3501,18 +3554,17 @@ const APP_HTML = `<!doctype html>
3501
3554
  if (pingBtn) {
3502
3555
  pingBtn.addEventListener('click', async (e) => {
3503
3556
  e.stopPropagation();
3504
- const msg = prompt('Send to all ' + t.kids + ' bot(s) below ' + t.name + ':');
3505
- // Empty is a cancel sending "" would spend a paid turn on every
3506
- // bot in the branch for nothing.
3507
- if (msg === null || !msg.trim()) return;
3557
+ // Default click = wake with the harness continue. window.prompt is
3558
+ // missing or blocked in Electron, so a modal here silently no-op'd
3559
+ // the only UI path that tried to reach the crew. /all still sends
3560
+ // exact text; ping is "poke them to work".
3508
3561
  pingBtn.disabled = true;
3509
3562
  const was = pingBtn.textContent;
3510
3563
  pingBtn.textContent = '…';
3511
3564
  try {
3512
- // Routed through THIS thread, so /all scopes to its own subtree.
3513
3565
  await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
3514
- body: JSON.stringify({ threadId: t.id, task: '/all ' + msg.trim() }) });
3515
- pingBtn.textContent = 'sent';
3566
+ body: JSON.stringify({ threadId: t.id, task: '/ping' }) });
3567
+ pingBtn.textContent = 'pinged';
3516
3568
  } catch (err) { pingBtn.textContent = 'failed'; }
3517
3569
  setTimeout(() => { pingBtn.disabled = false; pingBtn.textContent = was; }, 1500);
3518
3570
  await loadThreads();
@@ -3623,7 +3675,7 @@ const APP_HTML = `<!doctype html>
3623
3675
  if (!w || (!w.solana && !w.evm && w.creditUsd == null)) {
3624
3676
  const p = document.createElement('div');
3625
3677
  p.className = 'wnote wempty';
3626
- p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds. You can still subscribe with a card below.';
3678
+ p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds. You can still subscribe with a card above.';
3627
3679
  walletBody.appendChild(p);
3628
3680
  renderSubLane(w && w.subscription ? w.subscription : null);
3629
3681
  return;
@@ -3665,7 +3717,7 @@ const APP_HTML = `<!doctype html>
3665
3717
  note.textContent = subOn
3666
3718
  ? ('Wallet is optional while a subscription is active. ' + (w.funding || ''))
3667
3719
  : (w.funded === false
3668
- ? 'This wallet is EMPTY — wallet/x402 calls will fail with HTTP 402 until you fund the addresses above, or subscribe with a card below. ' + (w.funding || '')
3720
+ ? 'This wallet is EMPTY — wallet/x402 calls will fail with HTTP 402 until you fund the addresses above, or subscribe with a card at the top. ' + (w.funding || '')
3669
3721
  : (w.funding || ''));
3670
3722
  if (w.funded === false && !subOn) note.classList.add('wempty');
3671
3723
  if (note.textContent.trim()) walletBody.appendChild(note);
@@ -3832,8 +3884,9 @@ const APP_HTML = `<!doctype html>
3832
3884
  if (e.key === 'Enter') { e.preventDefault(); savePastedSub(); }
3833
3885
  });
3834
3886
  // First launch: if the burner is empty AND there is no subscription, open
3835
- // the wallet once so they see addresses — and the card lane. localStorage
3836
- // so a funded session, a saved key, or a dismiss does not keep popping it.
3887
+ // the pay modal once so they see card plans first — and burner addresses
3888
+ // below. localStorage so a funded session, a saved key, or a dismiss does
3889
+ // not keep popping it.
3837
3890
  (async function maybeOpenWalletOnce() {
3838
3891
  if (localStorage.getItem('openzoo.wallet.seen')) return;
3839
3892
  for (let i = 0; i < 8; i++) {
@@ -4449,7 +4502,10 @@ const APP_HTML = `<!doctype html>
4449
4502
  try { ev = JSON.parse(e.data); } catch { return; }
4450
4503
  if (ev.type === 'start') { streamBuf = ''; streamStatus = ev.detail || 'waiting on model…'; paintStream(); }
4451
4504
  else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
4452
- else if (ev.type === 'delta') { streamBuf += ev.delta || ''; paintStream(); }
4505
+ else if (ev.type === 'delta') {
4506
+ streamBuf = ev.replace ? (ev.delta || '') : streamBuf + (ev.delta || '');
4507
+ paintStream();
4508
+ }
4453
4509
  else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; streamStatus = ''; render(); }
4454
4510
  };
4455
4511
  es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
@@ -5128,7 +5184,7 @@ const server = http.createServer((req, res) => {
5128
5184
  saveThreads();
5129
5185
  res.writeHead(200, { 'content-type': 'application/json' });
5130
5186
  res.end('{"ok":true}');
5131
- runTurn(t.id, '(you denied running that command)').catch(() => {});
5187
+ kickTurn(t.id, '(you denied running that command)').catch(() => {});
5132
5188
  return;
5133
5189
  }
5134
5190
  entry.runStatus = 'running';
@@ -5139,7 +5195,7 @@ const server = http.createServer((req, res) => {
5139
5195
  entry.runStatus = 'done';
5140
5196
  entry.runOutput = output;
5141
5197
  saveThreads();
5142
- runTurn(t.id, `(command output)\n${output}`).catch(() => {});
5198
+ kickTurn(t.id, `(command output)\n${output}`).catch(() => {});
5143
5199
  });
5144
5200
  return;
5145
5201
  }
@@ -5237,4 +5293,6 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
5237
5293
  export {
5238
5294
  tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
5239
5295
  parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
5296
+ handleSlash, newThread, setRunTurnForTest, AUTO_CONTINUE, pingWakeText, pingCanWake,
5297
+ childKickoff,
5240
5298
  };
package/lib/livestatus.js CHANGED
@@ -28,6 +28,134 @@ export function formatPayStatus(attempt = 0) {
28
28
  return Number(attempt) > 0 ? 'waiting on x402…' : 'paying…';
29
29
  }
30
30
 
31
+ /** First-X-back race: how many of the K we asked for have actually landed. */
32
+ export function formatRaceStatus(back, need) {
33
+ const b = Math.max(0, Number(back) || 0);
34
+ const n = Math.max(1, Number(need) || 1);
35
+ return `racing ${b}/${n} back…`;
36
+ }
37
+
38
+ /** Real answers count toward X. Empty and HTTP/pay/timeout notes do not. */
39
+ export function isRaceCountable(text) {
40
+ const s = String(text || '').trim();
41
+ if (!s) return false;
42
+ return !/^\((?:upstream error|request failed|payment failed|rate limited|stream timed out|stream stalled)/i.test(s);
43
+ }
44
+
45
+ function shortModel(id) {
46
+ const s = String(id || '');
47
+ if (!s) return '';
48
+ return s.includes('/') ? s.split('/').pop() : s;
49
+ }
50
+
51
+ /**
52
+ * Last completion that arrived, even if empty-ish or error-y.
53
+ * Never blank: synthesize a visible error when nobody produced text.
54
+ */
55
+ export function raceLastShip(arrivals) {
56
+ const list = Array.isArray(arrivals) ? arrivals : [];
57
+ const last = list[list.length - 1];
58
+ if (!last) return { model: '', text: '(race: every model failed — no reply)', error: true };
59
+ const raw = String(last.text || '');
60
+ if (raw.trim()) return { ...last, text: raw };
61
+ const who = shortModel(last.model) || 'model';
62
+ if (last.error) return { ...last, text: `(${who} failed: ${last.error})`, error: true };
63
+ return { ...last, text: `(${who} returned nothing)`, error: true };
64
+ }
65
+
66
+ /** Default bar a classified race answer must clear (0–10). Overridable. */
67
+ export const RACE_MIN_SCORE = Number(process.env.OZ_RACE_MIN_SCORE || 6);
68
+
69
+ /**
70
+ * Parse a cheap classify reply into a 0–10 score.
71
+ * Prefers `SCORE 7` / `SCORE: 7`; falls back to a lone 0–10.
72
+ * Unparseable → 0 (does not clear the bar).
73
+ */
74
+ export function parseClassifyScore(text) {
75
+ const s = String(text || '');
76
+ const tagged = /SCORE\s*[:=]?\s*(-?\d+(?:\.\d+)?)/i.exec(s);
77
+ const lone = tagged || /\b(10|[0-9])(?:\s*\/\s*10)?\b/.exec(s);
78
+ if (!lone) return 0;
79
+ const n = Number(lone[1]);
80
+ if (!Number.isFinite(n)) return 0;
81
+ return Math.max(0, Math.min(10, n));
82
+ }
83
+
84
+ /**
85
+ * Pick a winner among the first-X-back candidates after they have been scored.
86
+ * Passing = score >= minScore. Highest score wins; a tie is returned as
87
+ * `reason: 'tie'` so the caller can pairwise-break it. If nobody clears the
88
+ * bar, the last of the X is accepted — never blank.
89
+ */
90
+ export function pickRaceWinner(cands, minScore = RACE_MIN_SCORE) {
91
+ const list = Array.isArray(cands) ? cands.filter(Boolean) : [];
92
+ if (!list.length) return { winner: null, reason: 'empty', tied: [] };
93
+ const passing = list.filter((c) => (Number(c.score) || 0) >= minScore);
94
+ if (!passing.length) {
95
+ return { winner: list[list.length - 1], reason: 'fallback-last', tied: [] };
96
+ }
97
+ let max = -Infinity;
98
+ for (const c of passing) {
99
+ const sc = Number(c.score) || 0;
100
+ if (sc > max) max = sc;
101
+ }
102
+ const tied = passing.filter((c) => (Number(c.score) || 0) === max);
103
+ if (tied.length === 1) return { winner: tied[0], reason: 'score', tied };
104
+ return { winner: null, reason: 'tie', tied };
105
+ }
106
+
107
+ /**
108
+ * Live race bubble: stream the fastest still-alive entrant, swap once if the
109
+ * winner is someone else. `onDelta(text, { replace, model })`.
110
+ */
111
+ export function createRaceFeed(onDelta, onStatus, need) {
112
+ let live = null;
113
+ let settled = false;
114
+ let back = 0;
115
+ const buf = new Map();
116
+ const dead = new Set();
117
+ const paintStatus = () => { onStatus?.(formatRaceStatus(back, need)); };
118
+ return {
119
+ start() { paintStatus(); },
120
+ liveModel() { return live; },
121
+ onToken(model, chunk) {
122
+ if (settled || chunk == null || chunk === '') return;
123
+ buf.set(model, (buf.get(model) || '') + chunk);
124
+ if (!live) {
125
+ live = model;
126
+ onDelta?.(chunk, { model });
127
+ return;
128
+ }
129
+ if (live === model) onDelta?.(chunk, { model });
130
+ },
131
+ onFail(model) {
132
+ dead.add(model);
133
+ if (settled || live !== model) return;
134
+ const next = [...buf.entries()].find(([m, t]) => m !== model && t && !dead.has(m));
135
+ if (next) {
136
+ live = next[0];
137
+ onDelta?.(next[1], { replace: true, model: live });
138
+ } else {
139
+ live = null;
140
+ }
141
+ },
142
+ onBack() {
143
+ back += 1;
144
+ paintStatus();
145
+ },
146
+ settle(winner) {
147
+ settled = true;
148
+ const text = String(winner?.text || '').trim()
149
+ ? winner.text
150
+ : '(race: every model failed — no reply)';
151
+ // Live stream already showing this answer — keep going, do not re-dump.
152
+ if (winner?.model && live === winner.model && !winner.error) return;
153
+ live = winner?.model || live;
154
+ onDelta?.(text, { replace: true, model: winner?.model });
155
+ },
156
+ };
157
+ }
158
+
31
159
  export function peekDirectiveStatus(reply, runCmd) {
32
160
  if (runCmd) return `RUN: ${clipStatusArg(runCmd)}`;
33
161
  const raw = String(reply || '');
package/lib/podagent.mjs CHANGED
@@ -24,6 +24,8 @@ import { appendFileSync } from 'node:fs';
24
24
  import { randomUUID } from 'node:crypto';
25
25
  import {
26
26
  formatPayStatus, startModelWait, readWithIdleTimeout, STREAM_IDLE_MS,
27
+ createRaceFeed, pickRaceWinner, parseClassifyScore, RACE_MIN_SCORE,
28
+ isRaceCountable, raceLastShip,
27
29
  } from './livestatus.js';
28
30
 
29
31
  const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
@@ -630,26 +632,65 @@ export async function tierModels(tier, n = 1, random = false) {
630
632
  * count toward K — otherwise the fastest model to FAIL would decide the race,
631
633
  * the exact bug this exists to fix.
632
634
  *
633
- * Streaming is deliberately not forwarded while the race runs: nobody knows who
634
- * is winning until they finish, and interleaving deltas from three models would
635
- * render as noise. The winner's text is emitted whole.
635
+ * Live tokens: the fastest still-alive entrant is forwarded into onDelta as
636
+ * they arrive (not swallowed). When a winner is picked, if it is that live
637
+ * stream the bubble keeps going; if it is someone else the bubble is replaced
638
+ * once (`onDelta(text, { replace: true })`), not left on mute dots.
639
+ *
640
+ * After the first X land, a cheap classify call scores each of those X
641
+ * (correctness, completeness, actually did the asked thing — a RUN:/DONE:
642
+ * directive is success, not a flaw). Highest score that clears the bar wins;
643
+ * a tie is pairwise-broken. If nobody clears, the last of the X is shipped
644
+ * anyway. If X never fills (every entrant empty/5xx), ship the last
645
+ * completion that arrived — even if empty-ish or error-y. If nobody produced
646
+ * any text, surface a real error in the bubble. Never blank, never hang.
636
647
  *
637
648
  * Every entrant is paid for, including the abandoned one — this trades money
638
649
  * for latency and quality, which is why it is opt-in and capped.
650
+ *
651
+ * `hooks` is for tests: `{ stream, classify, pairwise, minScore }`.
639
652
  */
640
- export async function brainRace(messages, onDelta, contextId, models, need = 1, maxTokens) {
653
+ export async function brainRace(messages, onDelta, contextId, models, need = 1, maxTokens, onStatus, hooks = {}) {
654
+ const stream = hooks.stream || brainStream;
655
+ const classify = hooks.classify || classifyRaceAnswer;
656
+ const pairwise = hooks.pairwise || pairwiseTied;
657
+ const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
641
658
  const list = (models || []).filter(Boolean).slice(0, RACE_MAX);
642
- if (list.length < 2) return brainStream(messages, onDelta, contextId, list[0], maxTokens);
659
+ if (list.length < 2) return stream(messages, onDelta, contextId, list[0], maxTokens, 0, 0, onStatus);
643
660
  const want = Math.max(1, Math.min(Number(need) || 1, list.length));
644
661
 
662
+ const feed = createRaceFeed(onDelta, onStatus, want);
663
+ feed.start();
664
+
645
665
  const done = [];
666
+ const arrivals = [];
646
667
  let finished = 0;
647
668
  let release;
648
669
  const enough = new Promise((r) => { release = r; });
649
670
 
650
- const attempts = list.map((m) => brainStream(messages, () => {}, contextId, m, maxTokens)
651
- .then((text) => { if (text && text.trim()) done.push({ model: m, text }); })
652
- .catch(() => { /* one entrant dying is not the race dying */ })
671
+ const ship = (cand) => {
672
+ const out = cand && String(cand.text || '').trim() ? cand : raceLastShip(arrivals);
673
+ feed.settle(out);
674
+ return out.text;
675
+ };
676
+
677
+ // Do not pass onStatus into each entrant — their "waiting on model…" would
678
+ // clobber the race line. Race owns the status until a winner ships.
679
+ const attempts = list.map((m) => stream(messages, (chunk) => feed.onToken(m, chunk), contextId, m, maxTokens)
680
+ .then((text) => {
681
+ const raw = text == null ? '' : String(text);
682
+ arrivals.push({ model: m, text: raw });
683
+ if (isRaceCountable(raw)) {
684
+ done.push({ model: m, text: raw });
685
+ feed.onBack();
686
+ } else {
687
+ feed.onFail(m);
688
+ }
689
+ })
690
+ .catch((e) => {
691
+ arrivals.push({ model: m, text: '', error: e?.message || 'error' });
692
+ feed.onFail(m);
693
+ })
653
694
  .finally(() => {
654
695
  finished += 1;
655
696
  // Either we have what we asked for, or everyone is done and no more is
@@ -662,58 +703,79 @@ export async function brainRace(messages, onDelta, contextId, models, need = 1,
662
703
  for (const p of attempts) p.catch(() => {});
663
704
 
664
705
  await enough;
665
- // Completion order, so this really is the first K back — not the first K
666
- // launched.
706
+ // Completion order, so this really is the first X back — not the first X
707
+ // launched. A slow 3rd never enters this set. Empty/5xx stay in arrivals
708
+ // so we can still ship the last one if X never fills.
667
709
  const cands = done.slice(0, want);
668
- if (!cands.length) return '';
669
- // Nothing to compare do not spend a judging call to rubber-stamp one answer.
670
- if (cands.length === 1) { onDelta(cands[0].text); return cands[0].text; }
710
+ if (!cands.length) return ship(raceLastShip(arrivals));
711
+ // One real answer — nothing to compare. Ship it; do not spend a classify
712
+ // call to rubber-stamp the only candidate.
713
+ if (cands.length === 1) return ship(cands[0]);
714
+
715
+ onStatus?.('judging…');
716
+ const scored = await Promise.all(cands.map(async (c) => {
717
+ let score = 0;
718
+ try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
719
+ return { ...c, score };
720
+ }));
721
+
722
+ let picked = pickRaceWinner(scored, minScore);
723
+ if (picked.reason === 'tie' && picked.tied.length > 1) {
724
+ let broken = null;
725
+ try { broken = await pairwise(messages, picked.tied); } catch { /* last of the tie */ }
726
+ const usable = broken && String(broken.text || '').trim();
727
+ // Malformed verdict / all equally bad → last finished of the tie, not empty.
728
+ picked = { winner: usable ? broken : picked.tied[picked.tied.length - 1], reason: 'tiebreak', tied: picked.tied };
729
+ }
730
+ return ship(picked.winner || scored[scored.length - 1] || raceLastShip(arrivals));
731
+ }
732
+
733
+ function raceQuestion(messages) {
734
+ const asked = [...messages].reverse().find((m) => m.role === 'user')?.content;
735
+ return typeof asked === 'string' ? asked : '(see candidates)';
736
+ }
671
737
 
672
- const winner = await judge(messages, cands);
673
- onDelta(winner.text);
674
- return winner.text;
738
+ /**
739
+ * Cheap structured score of ONE finished answer vs the question.
740
+ * This is grokui's own classify call — not OpenRouter's async log-tag
741
+ * Classifiers beta, which does not pick winners.
742
+ */
743
+ async function classifyRaceAnswer(messages, cand) {
744
+ const prompt = 'Score this answer to one question from 0 to 10.\n\n'
745
+ + 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
746
+ + 'ANSWER:\n' + String(cand?.text || '').slice(0, 6000) + '\n\n'
747
+ + 'Judge on: correctness first, then completeness, then whether it actually did what was asked '
748
+ + '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
749
+ + 'Ignore length and confidence of tone.\n'
750
+ + 'Reply with exactly: SCORE <n>';
751
+ const verdict = await brainStream(
752
+ [{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 24,
753
+ );
754
+ return parseClassifyScore(verdict);
675
755
  }
676
756
 
677
757
  /**
678
- * Pick the best of several finished answers with a small model.
679
- *
680
- * BLIND, as A/B/C/D. A judge told "this one is Claude and this one is a 4B
681
- * llama" is being handed the answer and will take it, which would turn the
682
- * whole thing into an expensive way to re-pick the tier's first entry.
683
- *
684
- * Cheap on purpose: reading finished replies and comparing them against a
685
- * question is a far easier task than answering it, and paying frontier prices
686
- * to referee frontier models would roughly double the cost of the expensive
687
- * tier for no measured gain.
758
+ * Pairwise break among same-score passers. Blind A/B/C so the model names
759
+ * cannot leak the answer. Last of the tied set if the call dies.
688
760
  */
689
- async function judge(messages, cands) {
690
- const letters = cands.map((_, i) => String.fromCharCode(65 + i));
691
- // The question, not the transcript: the judge needs to know what was ASKED,
692
- // and a full history would cost more to judge than the turn cost to answer.
693
- const asked = [...messages].reverse().find((m) => m.role === 'user')?.content;
694
- const question = typeof asked === 'string' ? asked : '(see candidates)';
761
+ async function pairwiseTied(messages, tied) {
762
+ const letters = tied.map((_, i) => String.fromCharCode(65 + i));
695
763
  const prompt = 'You are judging answers to one question. Pick the single best one.\n\n'
696
- + 'QUESTION:\n' + String(question).slice(0, 4000) + '\n\n'
697
- + cands.map((c, i) => 'ANSWER ' + letters[i] + ':\n' + c.text.slice(0, 6000)).join('\n\n')
764
+ + 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
765
+ + tied.map((c, i) => 'ANSWER ' + letters[i] + ':\n' + String(c.text || '').slice(0, 6000)).join('\n\n')
698
766
  + '\n\nJudge on: correctness first, then completeness, then whether it actually did what was asked '
699
767
  + '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
700
768
  + 'Ignore length and confidence of tone.\n'
701
769
  + 'Reply with ONE letter and nothing else: ' + letters.join(' or ') + '.';
702
770
  try {
703
771
  const verdict = await brainStream([{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 8);
704
- // First in-range letter anywhere in the reply. A judge that ignores "one
705
- // letter and nothing else" and writes "The best is B." still counts, which
706
- // is most of them.
707
772
  const hit = String(verdict).toUpperCase().split('').find((ch) => {
708
773
  const n = ch.charCodeAt(0) - 65;
709
- return n >= 0 && n < cands.length;
774
+ return n >= 0 && n < tied.length;
710
775
  });
711
- if (hit) return cands[hit.charCodeAt(0) - 65];
776
+ if (hit) return tied[hit.charCodeAt(0) - 65];
712
777
  } catch { /* fall through */ }
713
- // A dead or delisted judge must not lose the answers. Falling back to the
714
- // first finisher degrades this to "fastest wins" — worse than judged, far
715
- // better than empty.
716
- return cands[0];
778
+ return tied[tied.length - 1];
717
779
  }
718
780
 
719
781
  const RACE_MAX = Number(process.env.OZ_RACE_MAX || 4);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.92",
3
+ "version": "0.48.96",
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",