openzoo 0.48.92 → 0.48.94

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.
Files changed (2) hide show
  1. package/lib/grokui.mjs +93 -47
  2. package/package.json +1 -1
package/lib/grokui.mjs CHANGED
@@ -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,32 @@ 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
+ return (runTurnOverride || runTurn)(threadId, userText, onEvent, images);
676
+ }
677
+ function pingWakeText(extra) {
678
+ const msg = String(extra || '').trim();
679
+ // A restated spawn brief is not a nudge. MEASURED live: existing-SPAWN
680
+ // wrapped children in childKickoff({fresh:false}) → "CONTEXT REFRESH —
681
+ // you already exist" and they thought, then refused to redo the job.
682
+ if (!msg || /CONTEXT REFRESH|--- your specific job ---|ROOT ASK —/.test(msg)) return AUTO_CONTINUE;
683
+ return msg;
684
+ }
685
+ function pingCanWake(x) {
686
+ return Boolean(x) && !x.pendingRun && x.status !== 'thinking';
687
+ }
688
+ function wakeOnPing(x, extra) {
689
+ // Never childKickoff. Ping is a short continue, not a first-day re-brief.
690
+ kickTurn(x.id, pingWakeText(extra)).catch(() => {});
691
+ }
664
692
  // Ceiling on subagents per thread. Spawning is fire-and-forget and each child
665
693
  // can spawn too, so without a count it is unbounded — MEASURED as 15+ threads
666
694
  // all named tetris-contract, every one of them a live agent making paid calls.
@@ -927,7 +955,7 @@ const SLASH_COMMANDS = [
927
955
  { name: '/memory', args: '[text|clear]', help: 'facts injected into every turn' },
928
956
  { name: '/sessions', args: '', help: 'list all threads' },
929
957
  { 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' },
958
+ { name: '/ping', args: '', help: 'wake idle bots below you to take a turn now' },
931
959
  { name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
932
960
  { name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
933
961
  { name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
@@ -1037,7 +1065,8 @@ async function handleSlash(task, t) {
1037
1065
  + ' SPAWN: <name> | <task> a NEW subagent (names are unique —\n'
1038
1066
  + ' spawning an existing one sends to it)\n'
1039
1067
  + ' SEND: <name> | <msg> more work for an EXISTING subagent\n'
1040
- + ' PING / PEEK reach or inspect another bot\n\n'
1068
+ + ' PING: <name> wake that bot (* wakes the project)\n'
1069
+ + ' PEEK: <name> read-only look at another bot\n\n'
1041
1070
  + 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
1042
1071
  + 'appear in one reply — four files cost one round trip, not four.';
1043
1072
  }
@@ -1210,19 +1239,26 @@ async function handleSlash(task, t) {
1210
1239
  return `Sent down your branch to ${crew.length} bot(s): ${crew.map((x) => x.name).join(', ')}`;
1211
1240
  }
1212
1241
 
1213
- // Read the room without spending anything: who is working, who is blocked on
1214
- // an approval, what each said last.
1242
+ // Wake the room. Used to be a free last-line dump idle children stayed
1243
+ // idle, and a parent reading "kid: <old reply>" thought they had acted.
1244
+ // Empty extra is a nudge (AUTO_CONTINUE), not a cancel. Thinking stays
1245
+ // thinking; pendingRun stays on the human. Same branch scope as /all.
1215
1246
  if (cmd === 'ping') {
1216
- // Same scoping as /all: your branch, not the whole project.
1217
1247
  const crew = subtreeOf(t.id, true);
1218
1248
  if (crew.length < 2) return 'You have no subagents yet.';
1219
1249
  return crew.map((x) => {
1220
1250
  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`;
1251
+ if (x.id === t.id) {
1252
+ const last = x.history[x.history.length - 1];
1253
+ return x.pendingRun ? ` ${x.name}${mark}: BLOCKED — waiting for your approval`
1254
+ : x.status === 'thinking' ? ` ${x.name}${mark}: working`
1255
+ : last ? ` ${x.name}${mark}: ${String(last.text).replace(/\s+/g, ' ').slice(0, 90)}`
1256
+ : ` ${x.name}${mark}: nothing yet`;
1257
+ }
1258
+ if (x.pendingRun) return ` ${x.name}${mark}: BLOCKED — waiting for your approval`;
1259
+ if (x.status === 'thinking') return ` ${x.name}${mark}: working`;
1260
+ wakeOnPing(x, arg);
1261
+ return ` ${x.name}${mark}: pinged, working`;
1226
1262
  }).join('\n');
1227
1263
  }
1228
1264
 
@@ -1767,14 +1803,15 @@ async function tryDirective(reply, originId, onEvent) {
1767
1803
  const notes = [];
1768
1804
  for (const { name, task } of parsed) {
1769
1805
  const existing = findByName(name);
1770
- if (existing) { notes.push(`${name} already exists — sending it the task.`); made.push({ t: existing, task, fresh: false }); continue; }
1806
+ if (existing) { notes.push(`${name} already exists — woke it to keep working.`); made.push({ t: existing, task, fresh: false }); continue; }
1771
1807
  const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
1772
1808
  if (siblings >= SPAWN_MAX_CHILDREN) { notes.push(`Not spawning "${name}": already at ${SPAWN_MAX_CHILDREN} subagents.`); continue; }
1773
1809
  made.push({ t: newThread(name, originId), task, fresh: true });
1774
1810
  }
1775
1811
  // Every thread now exists, so spawnPosition sees the COMPLETE cohort.
1776
1812
  for (const { t: sub, task, fresh } of made) {
1777
- runTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
1813
+ if (fresh) runTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
1814
+ else wakeOnPing(sub);
1778
1815
  }
1779
1816
  const fresh = made.filter((m) => m.fresh).map((m) => m.t.name);
1780
1817
  return [fresh.length ? `Spawned ${fresh.length} together (they can each see the full crew): ${fresh.join(', ')}` : '', ...notes]
@@ -1793,8 +1830,11 @@ async function tryDirective(reply, originId, onEvent) {
1793
1830
  // is what SEND already does.
1794
1831
  const existing = findByName(name);
1795
1832
  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.`;
1833
+ // Repeat SPAWN is a wake, not a CONTEXT REFRESH. childKickoff({fresh:false})
1834
+ // restates the original job and tells the child it already exists MEASURED,
1835
+ // the crew flipped to that preview, thought once, and sat.
1836
+ wakeOnPing(existing);
1837
+ return `${name} already exists — woke it to keep working.`;
1798
1838
  }
1799
1839
  // Storm guard. Fire-and-forget spawning is unbounded by construction: each
1800
1840
  // child can spawn, and nothing above it is counting.
@@ -1861,27 +1901,29 @@ async function tryDirective(reply, originId, onEvent) {
1861
1901
  const ping = pingAll.length === 1 ? [null, pingAll[0]] : null;
1862
1902
  if (ping) {
1863
1903
  const name = ping[1].trim();
1864
- // PING: * (or 'all' / 'project') reaches EVERY bot in this project.
1904
+ // PING: * (or 'all' / 'project') WAKES every other bot in this project.
1865
1905
  // Coordinating a spawn tree by naming siblings one at a time is a chore
1866
1906
  // the parent should not have to do, and it cannot know who else exists.
1907
+ // The return is an ack ("pinged, working"), not a last-line dump that
1908
+ // lets the parent think the child already acted.
1867
1909
  if (/^(\*|all|project|everyone)$/i.test(name)) {
1868
1910
  const me = threads.get(originId);
1869
1911
  const root = me ? rootOf(me).rootId : null;
1870
1912
  const crew = [...threads.values()].filter((x) => x.id !== originId && rootOf(x).rootId === root);
1871
1913
  if (!crew.length) return 'No other bots in this project yet.';
1872
1914
  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';
1915
+ if (x.pendingRun) return x.name + ': BLOCKED — waiting for approval';
1916
+ if (x.status === 'thinking') return x.name + ': still working';
1917
+ wakeOnPing(x);
1918
+ return x.name + ': pinged, working';
1878
1919
  }).join('\n');
1879
1920
  }
1880
1921
  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.`;
1922
+ if (!target) return `No thread named "${name}".`;
1923
+ if (target.pendingRun) return `${name}: BLOCKED waiting for approval`;
1924
+ if (target.status === 'thinking') return `${name} is still working.`;
1925
+ wakeOnPing(target);
1926
+ return `${name}: pinged, working`;
1885
1927
  }
1886
1928
  const peek = /^[ \t>*-]*PEEK:\s*(.+)/m.exec(reply);
1887
1929
  if (peek) {
@@ -3121,12 +3163,9 @@ const APP_HTML = `<!doctype html>
3121
3163
  </div>
3122
3164
  <div id="walletOverlay" data-component="wallet-modal">
3123
3165
  <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">
3166
+ <h3>Pay with a card</h3>
3167
+ <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>
3168
+ <div id="subLane" data-component="subscribe-lane">
3130
3169
  <div class="wlanetitle">Subscribe with a card</div>
3131
3170
  <div class="wtag">Subscription key · no x402</div>
3132
3171
  <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 +3181,11 @@ const APP_HTML = `<!doctype html>
3142
3181
  <div class="wnote" id="subNote"></div>
3143
3182
  <div class="wquiet"><a id="subPageLink" href="${SUBSCRIPTIONS_PAGE}" target="_blank" rel="noopener">Full subscriptions page</a></div>
3144
3183
  </div>
3184
+ <div class="wlane" id="x402Lane" data-component="x402-lane">
3185
+ <div class="wlanetitle">Wallet / x402</div>
3186
+ <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>
3187
+ <div id="walletBody">loading…</div>
3188
+ </div>
3145
3189
  </div>
3146
3190
  </div>
3147
3191
  <div id="main">
@@ -3176,7 +3220,7 @@ const APP_HTML = `<!doctype html>
3176
3220
  </optgroup>
3177
3221
  </select>
3178
3222
  <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>
3223
+ title="Pay with a card, or use wallet/x402">pay</button>
3180
3224
  <button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">&#8635;</button>
3181
3225
  <button class="icon-btn" id="hudBtn">◎</button>
3182
3226
  </div>
@@ -3486,7 +3530,7 @@ const APP_HTML = `<!doctype html>
3486
3530
  // subagents without retyping — and pressing it addressed the WHOLE
3487
3531
  // project, cousins included. Now every thread with descendants gets
3488
3532
  // one, scoped to its own branch.
3489
- (t.kids ? '<button class="pingall trow-ping" data-testid="ping-all" title="Message all '
3533
+ (t.kids ? '<button class="pingall trow-ping" data-testid="ping-all" title="Wake all '
3490
3534
  + t.kids + ' bot(s) below ' + escapeHtml(t.name) + '">\u21f2 ' + t.kids + '</button>' : '') +
3491
3535
  '<button class="tclose" title="Remove">✕</button>';
3492
3536
  row.addEventListener('click', () => {
@@ -3501,18 +3545,17 @@ const APP_HTML = `<!doctype html>
3501
3545
  if (pingBtn) {
3502
3546
  pingBtn.addEventListener('click', async (e) => {
3503
3547
  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;
3548
+ // Default click = wake with the harness continue. window.prompt is
3549
+ // missing or blocked in Electron, so a modal here silently no-op'd
3550
+ // the only UI path that tried to reach the crew. /all still sends
3551
+ // exact text; ping is "poke them to work".
3508
3552
  pingBtn.disabled = true;
3509
3553
  const was = pingBtn.textContent;
3510
3554
  pingBtn.textContent = '…';
3511
3555
  try {
3512
- // Routed through THIS thread, so /all scopes to its own subtree.
3513
3556
  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';
3557
+ body: JSON.stringify({ threadId: t.id, task: '/ping' }) });
3558
+ pingBtn.textContent = 'pinged';
3516
3559
  } catch (err) { pingBtn.textContent = 'failed'; }
3517
3560
  setTimeout(() => { pingBtn.disabled = false; pingBtn.textContent = was; }, 1500);
3518
3561
  await loadThreads();
@@ -3623,7 +3666,7 @@ const APP_HTML = `<!doctype html>
3623
3666
  if (!w || (!w.solana && !w.evm && w.creditUsd == null)) {
3624
3667
  const p = document.createElement('div');
3625
3668
  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.';
3669
+ 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
3670
  walletBody.appendChild(p);
3628
3671
  renderSubLane(w && w.subscription ? w.subscription : null);
3629
3672
  return;
@@ -3665,7 +3708,7 @@ const APP_HTML = `<!doctype html>
3665
3708
  note.textContent = subOn
3666
3709
  ? ('Wallet is optional while a subscription is active. ' + (w.funding || ''))
3667
3710
  : (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 || '')
3711
+ ? '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
3712
  : (w.funding || ''));
3670
3713
  if (w.funded === false && !subOn) note.classList.add('wempty');
3671
3714
  if (note.textContent.trim()) walletBody.appendChild(note);
@@ -3832,8 +3875,9 @@ const APP_HTML = `<!doctype html>
3832
3875
  if (e.key === 'Enter') { e.preventDefault(); savePastedSub(); }
3833
3876
  });
3834
3877
  // 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.
3878
+ // the pay modal once so they see card plans first — and burner addresses
3879
+ // below. localStorage so a funded session, a saved key, or a dismiss does
3880
+ // not keep popping it.
3837
3881
  (async function maybeOpenWalletOnce() {
3838
3882
  if (localStorage.getItem('openzoo.wallet.seen')) return;
3839
3883
  for (let i = 0; i < 8; i++) {
@@ -5237,4 +5281,6 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
5237
5281
  export {
5238
5282
  tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
5239
5283
  parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
5284
+ handleSlash, newThread, setRunTurnForTest, AUTO_CONTINUE, pingWakeText, pingCanWake,
5285
+ childKickoff,
5240
5286
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.92",
3
+ "version": "0.48.94",
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",