openzoo 0.48.82 → 0.48.83

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 +186 -71
  2. package/package.json +1 -1
package/lib/grokui.mjs CHANGED
@@ -497,17 +497,25 @@ function newGroupThread(names) {
497
497
  // chunk after the first carries the PREVIOUS chunk's context_id so the
498
498
  // sidecar appends to the same bound context instead of starting fresh.
499
499
  const BIND_CHUNK_BYTES = 512 * 1024;
500
- // Chained auto-run commands per user message. Each hop is a paid call.
501
- // 8 was far too tight. A real taskinstall a toolchain, read a tree, wire a
502
- // service is dozens of directives, so auto kept halting mid-job and telling
503
- // the user to type "continue", which is the exact thing auto exists to avoid.
504
- // Still bounded, because every hop is a paid call, but bounded at a number
505
- // that lets a job finish.
506
- const AUTO_MAX_STEPS = Number(process.env.OZ_AUTO_MAX_STEPS || 60);
500
+ // Soft hop counter per user message. Each hop is a paid call. 8 parked a real
501
+ // job mid-toolchain and told the user to type "continue" the thing auto
502
+ // exists to avoid. 60 was still a "type continue" trap on anything with a
503
+ // crew. Hitting the number now INJECTS continue and keeps going until the
504
+ // model emits DONE or the user flips to ask. The env var is a nudge interval,
505
+ // not a stop.
506
+ const AUTO_MAX_STEPS = Number(process.env.OZ_AUTO_MAX_STEPS || 500);
507
507
  // An empty completion is a provider hiccup, not an answer. In auto we retry it
508
508
  // silently rather than parking the thread behind a "say continue" note the
509
509
  // user then has to answer — that turned a transient blip into a manual step.
510
510
  const AUTO_EMPTY_RETRIES = Number(process.env.OZ_AUTO_EMPTY_RETRIES || 3);
511
+ const NUDGE = 'That reply announced work instead of doing it — no directive line reached the harness, '
512
+ + 'so nothing ran. Emit the directive NOW, as the first line of your reply, with no preamble: '
513
+ + 'RUN:, SPAWN:, READ:, WRITE:, GLOB:, FETCH:, MCP: or SERVE:. '
514
+ + 'Exactly the syntax from your instructions — not [TOOL_CALL], not JSON, not a function-call envelope. '
515
+ + 'If several steps are needed, emit the FIRST one; you get its real output back and continue from there.';
516
+ const AUTO_CONTINUE = 'AUTO is still on — do not stop and do not ask the user to type continue. '
517
+ + 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
518
+ + 'or DONE: if the job is actually finished.';
511
519
  // Ceiling on subagents per thread. Spawning is fire-and-forget and each child
512
520
  // can spawn too, so without a count it is unbounded — MEASURED as 15+ threads
513
521
  // all named tetris-contract, every one of them a live agent making paid calls.
@@ -1345,17 +1353,81 @@ function unwrapDirectiveLine(reply) {
1345
1353
  * last wrote to the parent, which is where constraints like "this is
1346
1354
  * ProofNetwork" and "use token22 <mint>" always live.
1347
1355
  */
1348
- function spawnBrief(parent) {
1356
+ function isHarnessUserText(text) {
1357
+ return /^\((command output|directive result)\)/.test(String(text || ''))
1358
+ || String(text || '') === NUDGE
1359
+ || String(text || '') === AUTO_CONTINUE;
1360
+ }
1361
+
1362
+ function firstUserAsk(t) {
1363
+ return (t?.history || []).find((m) => m.who === 'user' && !isHarnessUserText(m.text));
1364
+ }
1365
+
1366
+ function lastUserAsk(t) {
1367
+ return [...(t?.history || [])].reverse().find((m) => m.who === 'user' && !isHarnessUserText(m.text));
1368
+ }
1369
+
1370
+ function siblingJob(sib) {
1371
+ const text = String(firstUserAsk(sib)?.text || '');
1372
+ const job = text.includes('--- your specific job ---')
1373
+ ? text.split('--- your specific job ---').pop().split('--- your place in the team ---')[0]
1374
+ : text;
1375
+ return job.trim().replace(/\s+/g, ' ').slice(0, 400);
1376
+ }
1377
+
1378
+ function spawnBrief(parent, { refresh = false } = {}) {
1349
1379
  if (!parent) return '';
1350
- const lastUser = [...(parent.history || [])].reverse()
1351
- .find((m) => m.who === 'user' && !/^\((command output|directive result)\)/.test(String(m.text || '')));
1352
- const text = String(lastUser?.text || '').trim();
1353
- if (!text) return '';
1354
- return 'CONTEXT this is what was asked of the team you were just spawned into. '
1355
- + 'Constraints in here (the stack, addresses, what NOT to do) apply to you, and outrank '
1356
- + 'any assumption you would otherwise make from your own training:\n\n'
1357
- + text.slice(0, 4000)
1358
- + '\n\n--- your specific job ---\n';
1380
+ const rootId = rootOf(parent).rootId;
1381
+ const root = threads.get(rootId) || parent;
1382
+ const rootAsk = String(firstUserAsk(root)?.text || '').trim();
1383
+ const latest = String(lastUserAsk(parent)?.text || '').trim();
1384
+ const recent = (parent.history || []).filter((m) => !isHarnessUserText(m.text)).slice(-8);
1385
+ const siblings = [...threads.values()].filter((x) => x.parent === parent.id);
1386
+ const cwd = parent.dir || WORKSPACE_DIR;
1387
+ const mode = parent.runMode || 'ask';
1388
+ const tier = parent.tier || 'medium';
1389
+ const race = Number(parent.race) || 0;
1390
+ const raceNeed = Number(parent.raceNeed) || 1;
1391
+ const model = parent.model || '';
1392
+ const lines = [
1393
+ refresh
1394
+ ? 'CONTEXT REFRESH — you already exist; this is the current brief. Constraints (stack, token, MCP URL, what NOT to do) outrank your training prior.'
1395
+ : 'CONTEXT — this is what was asked of the team you were just spawned into. Constraints in here (the stack, addresses, what NOT to do) apply to you, and outrank any assumption you would otherwise make from your own training.',
1396
+ ];
1397
+ // The root ask is kept WHOLE. A 4000-char slice ate "proofnetwork can do it
1398
+ // all" / token / MCP URL and the child invented a stack from training prior.
1399
+ if (rootAsk) {
1400
+ lines.push('', 'ROOT ASK — the original user request for this project (not a paraphrase):', rootAsk);
1401
+ }
1402
+ if (latest && latest !== rootAsk) {
1403
+ lines.push('', 'LATEST from the parent thread:', latest);
1404
+ }
1405
+ if (recent.length) {
1406
+ lines.push('', 'RECENT PARENT TURNS (what the orchestrator already decided):');
1407
+ for (const m of recent) {
1408
+ const who = m.who === 'user' ? 'user' : (m.name || parent.name);
1409
+ lines.push('[' + who + '] ' + String(m.text || '').trim().slice(0, 1200));
1410
+ }
1411
+ }
1412
+ lines.push('', 'WORKING SET:');
1413
+ lines.push('cwd: ' + cwd);
1414
+ lines.push('run mode: ' + mode);
1415
+ lines.push('tier: ' + tier
1416
+ + (race >= 2 ? ' · race ' + (raceNeed > 1 ? raceNeed + ' of ' + race : race) : '')
1417
+ + (model ? ' · pinned model: ' + model : ''));
1418
+ if (siblings.length) {
1419
+ lines.push('', 'CREW — the split already happened. Do not re-split work they own:');
1420
+ for (const s of siblings) {
1421
+ const job = siblingJob(s);
1422
+ lines.push('- ' + s.name + ': ' + (job || '(no task recorded yet)'));
1423
+ }
1424
+ }
1425
+ lines.push('', '--- your specific job ---', '');
1426
+ return lines.join('\n');
1427
+ }
1428
+
1429
+ function childKickoff(parent, childName, task, { fresh = true } = {}) {
1430
+ return spawnBrief(parent, { refresh: !fresh }) + task + spawnPosition(parent, childName);
1359
1431
  }
1360
1432
 
1361
1433
  /**
@@ -1375,14 +1447,14 @@ function spawnBrief(parent) {
1375
1447
  */
1376
1448
  function spawnPosition(parent, childName) {
1377
1449
  if (!parent) return '';
1378
- const siblings = [...threads.values()].filter((x) => x.parent === parent.id).map((x) => x.name);
1379
1450
  const depth = rootOf(parent).depth + 1;
1380
- const others = siblings.filter((n) => n !== childName);
1451
+ const others = [...threads.values()].filter((x) => x.parent === parent.id && x.name !== childName);
1381
1452
  return '\n\n--- your place in the team ---\n'
1382
1453
  + `You are "${childName}", spawned by "${parent.name}". You are at tier ${depth} of this project.\n`
1383
1454
  + (others.length
1384
- ? `The work was ALREADY SPLIT before you existed. Your siblings under ${parent.name} are: `
1385
- + others.join(', ') + '.\nThey hold the other slices. Yours is yours to BUILD.\n'
1455
+ ? `The work was ALREADY SPLIT before you existed. Your siblings under ${parent.name} are:\n`
1456
+ + others.map((s) => `- ${s.name}: ${siblingJob(s) || '(no task recorded yet)'}`).join('\n')
1457
+ + '\nThey hold the other slices. Yours is yours to BUILD.\n'
1386
1458
  : '')
1387
1459
  + 'Do NOT re-split your own slice into more agents just because it has several parts — '
1388
1460
  + 'that is how a team becomes sixteen bots and zero artifacts. SPAWN only if your slice '
@@ -1489,8 +1561,7 @@ async function tryDirective(reply, originId) {
1489
1561
  }
1490
1562
  // Every thread now exists, so spawnPosition sees the COMPLETE cohort.
1491
1563
  for (const { t: sub, task, fresh } of made) {
1492
- runTurn(sub.id, (fresh ? spawnBrief(parent) : '') + task
1493
- + (fresh ? spawnPosition(parent, sub.name) : '')).catch(() => {});
1564
+ runTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
1494
1565
  }
1495
1566
  const fresh = made.filter((m) => m.fresh).map((m) => m.t.name);
1496
1567
  return [fresh.length ? `Spawned ${fresh.length} together (they can each see the full crew): ${fresh.join(', ')}` : '', ...notes]
@@ -1509,7 +1580,7 @@ async function tryDirective(reply, originId) {
1509
1580
  // is what SEND already does.
1510
1581
  const existing = findByName(name);
1511
1582
  if (existing) {
1512
- runTurn(existing.id, task).catch(() => {});
1583
+ runTurn(existing.id, childKickoff(threads.get(originId), existing.name, task, { fresh: false })).catch(() => {});
1513
1584
  return `${name} already exists — sent it the task instead of spawning a duplicate.`;
1514
1585
  }
1515
1586
  // Storm guard. Fire-and-forget spawning is unbounded by construction: each
@@ -1522,7 +1593,7 @@ async function tryDirective(reply, originId) {
1522
1593
  }
1523
1594
  const sub = newThread(name, originId);
1524
1595
  // The child gets the ORIGINATING brief plus its own job — see spawnBrief.
1525
- runTurn(sub.id, spawnBrief(threads.get(originId)) + task + spawnPosition(threads.get(originId), name)).catch(() => {}); // fire and forget
1596
+ runTurn(sub.id, childKickoff(threads.get(originId), name, task)).catch(() => {}); // fire and forget
1526
1597
  return `Spawned ${name} — working on it.`;
1527
1598
  }
1528
1599
  // SEND TO A NAME THAT DOES NOT EXIST YET *SPAWNS* IT.
@@ -1551,7 +1622,7 @@ async function tryDirective(reply, originId) {
1551
1622
  const msg = sendM[2].trim();
1552
1623
  const target = findByName(name);
1553
1624
  if (target) {
1554
- runTurn(target.id, msg).catch(() => {});
1625
+ runTurn(target.id, childKickoff(threads.get(originId), target.name, msg, { fresh: false })).catch(() => {});
1555
1626
  return `Messaged ${name}.`;
1556
1627
  }
1557
1628
  // The SAME storm guard SPAWN uses — promoting a SEND must not be a way
@@ -1563,7 +1634,7 @@ async function tryDirective(reply, originId) {
1563
1634
  + `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <existing name> | <task>.`;
1564
1635
  }
1565
1636
  const sub = newThread(name, originId);
1566
- runTurn(sub.id, spawnBrief(threads.get(originId)) + msg + spawnPosition(threads.get(originId), name)).catch(() => {});
1637
+ runTurn(sub.id, childKickoff(threads.get(originId), name, msg)).catch(() => {});
1567
1638
  return `${name} did not exist — spawned it with that message as its task.`;
1568
1639
  }
1569
1640
  // PING had the same anchor bug — no `m`, so a PING after any preamble (or
@@ -2011,11 +2082,12 @@ async function runTurn(threadId, userText, onEvent, images) {
2011
2082
  bindThread(t).catch(() => {});
2012
2083
  return;
2013
2084
  }
2014
- // A real message from the user resets the auto budget AND the one-shot
2015
- // announcement nudge otherwise a thread that got nudged once could never be
2016
- // nudged again for the rest of its life, which is the opposite of a per-turn
2017
- // guard. The nudge itself is not a reset: it must not re-arm its own budget.
2018
- if (!/^\(command output\)/.test(userText) && userText !== NUDGE) {
2085
+ // A real message from the user resets the auto budget AND the announcement
2086
+ // nudge. Harness-injected hops (command output, directive result, nudge,
2087
+ // auto-continue) must not re-arm that would make AUTO_MAX_STEPS a no-op
2088
+ // on the directive path (it used to reset every hop because only
2089
+ // "(command output)" was excluded).
2090
+ if (!isHarnessUserText(userText) && !/^\((command output|directive result)\)/.test(userText)) {
2019
2091
  t.autoSteps = 0;
2020
2092
  delete t.autoNudged;
2021
2093
  }
@@ -2126,11 +2198,11 @@ async function runTurn(threadId, userText, onEvent, images) {
2126
2198
  bindThread(t).catch(() => {});
2127
2199
  runTurn(threadId, condense('(command output)', output), onEvent).catch(() => {});
2128
2200
  } else {
2129
- const note = `(auto-run paused after ${AUTO_MAX_STEPS} chained commands — that is the spend ceiling, not the end of the job. Send anything to carry on, or raise OZ_AUTO_MAX_STEPS.)`;
2130
- t.history.push({ who: 'bot', text: note });
2131
- onEvent?.({ type: 'final', name: t.name, color: t.color, text: note });
2132
- saveThreads();
2201
+ // Do not park. A "say continue" note is the failure mode auto exists
2202
+ // to avoid inject continue and keep going until DONE or ask.
2203
+ t.autoSteps = 0;
2133
2204
  bindThread(t).catch(() => {});
2205
+ runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
2134
2206
  }
2135
2207
  return;
2136
2208
  }
@@ -2164,17 +2236,17 @@ async function runTurn(threadId, userText, onEvent, images) {
2164
2236
  //
2165
2237
  // Same budget as RUN (shared t.autoSteps, reset when the user speaks), so
2166
2238
  // this cannot spend more than a chained RUN loop already could.
2167
- if (t.runMode === 'auto' && ack !== null && ack !== undefined) {
2239
+ if (t.runMode === 'auto' && ack !== null && ack !== undefined
2240
+ && !/^[ \t>*-]*DONE:/m.test(reply)) {
2168
2241
  t.autoSteps = (t.autoSteps || 0) + 1;
2242
+ bindThread(t).catch(() => {}); // bind every hop, not just the last one
2169
2243
  if (t.autoSteps < AUTO_MAX_STEPS) {
2170
- bindThread(t).catch(() => {}); // bind every hop, not just the last one
2171
2244
  runTurn(threadId, condense('(directive result)', ack), onEvent).catch(() => {});
2172
- return;
2245
+ } else {
2246
+ t.autoSteps = 0;
2247
+ runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
2173
2248
  }
2174
- const note = `(auto paused after ${AUTO_MAX_STEPS} steps — that is the spend ceiling, not the end of the job. Send anything to carry on, or raise OZ_AUTO_MAX_STEPS.)`;
2175
- t.history.push({ who: 'bot', text: note });
2176
- onEvent?.({ type: 'final', name: t.name, color: t.color, text: note });
2177
- saveThreads();
2249
+ return;
2178
2250
  }
2179
2251
 
2180
2252
  // ANNOUNCING IS NOT DOING — so do not let it end the turn.
@@ -2193,13 +2265,22 @@ async function runTurn(threadId, userText, onEvent, images) {
2193
2265
  // finished reply would spend a turn arguing with a bot that already
2194
2266
  // succeeded. Once per turn (autoNudged), so a model that announces twice
2195
2267
  // still stops instead of looping on the user's wallet.
2196
- if (t.runMode === 'auto' && (ack === null || ack === undefined) && !t.autoNudged
2197
- && (t.autoSteps || 0) < AUTO_MAX_STEPS && ANNOUNCEMENT.test(reply)) {
2268
+ if (t.runMode === 'auto' && (ack === null || ack === undefined)
2269
+ && (STALLED_OFFER.test(reply) || ANNOUNCEMENT.test(reply))) {
2270
+ // Offers and "Spawned X — working on it" with no directive must not end
2271
+ // the run. The old once-only autoNudged gate parked the thread after one
2272
+ // hedge and the user typed continue. Keep going; AUTO_MAX_STEPS injects
2273
+ // another continue rather than stopping.
2198
2274
  t.autoNudged = true;
2199
2275
  t.autoSteps = (t.autoSteps || 0) + 1;
2200
2276
  saveThreads();
2201
2277
  bindThread(t).catch(() => {});
2202
- runTurn(threadId, NUDGE, onEvent).catch(() => {});
2278
+ if (t.autoSteps < AUTO_MAX_STEPS) {
2279
+ runTurn(threadId, NUDGE, onEvent).catch(() => {});
2280
+ } else {
2281
+ t.autoSteps = 0;
2282
+ runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
2283
+ }
2203
2284
  return;
2204
2285
  }
2205
2286
  bindThread(t).catch(() => {});
@@ -2209,12 +2290,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2209
2290
  // in here because they are FALSE without a SPAWN: in the same reply — the bot
2210
2291
  // reports success for something the harness never saw.
2211
2292
  const ANNOUNCEMENT = /\b(?:I(?:'| a)?ll |I will |let me |I'm going to |I am going to |first,? |next,? |now I'll |starting|kicking off|spawn(?:ing|ed)|about to|going to (?:check|run|create|start|install))\b/i;
2212
-
2213
- const NUDGE = 'That reply announced work instead of doing it — no directive line reached the harness, '
2214
- + 'so nothing ran. Emit the directive NOW, as the first line of your reply, with no preamble: '
2215
- + 'RUN:, SPAWN:, READ:, WRITE:, GLOB:, FETCH:, MCP: or SERVE:. '
2216
- + 'Exactly the syntax from your instructions — not [TOOL_CALL], not JSON, not a function-call envelope. '
2217
- + 'If several steps are needed, emit the FIRST one; you get its real output back and continue from there.';
2293
+ const STALLED_OFFER = /\b(?:if you want(?:ed)?,? I can|should I\b|let me know(?: and I['’]ll)?|ready to proceed|want me to|would you like(?: me to)?|spawned \S[\s\S]{0,80}working on it|kicked that off)\b/i;
2218
2294
 
2219
2295
  /**
2220
2296
  * The PROJECT a thread belongs to = the root of its spawn tree, and how deep
@@ -2420,17 +2496,22 @@ const APP_HTML = `<!doctype html>
2420
2496
  @keyframes twarnpulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
2421
2497
  @media (prefers-reduced-motion: reduce) { .twarn { animation: none; } }
2422
2498
  #main { flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
2423
- #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
2424
- font-weight: 600; }
2499
+ #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
2500
+ flex-wrap: wrap; gap: 8px 10px; font-weight: 600; }
2425
2501
  #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
2426
- .hname { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
2502
+ /* Title shrinks and wraps; the spend dials must stay on screen. margin-left:auto
2503
+ on #modeToggle used to shove cheap/race/wallet off the right edge. */
2504
+ #chatHeaderId { display: flex; align-items: center; gap: 10px; flex: 1 1 120px; min-width: 0; overflow: hidden; }
2505
+ #headerDials { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-left: auto; flex: 0 1 auto; }
2506
+ .hname { display: flex; flex-direction: column; gap: 1px; min-width: 0; overflow: hidden; }
2507
+ .hname > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2427
2508
  .hdir { font-weight: 400; font-size: 11px; color: #8e8e93; white-space: nowrap; overflow: hidden;
2428
2509
  text-overflow: ellipsis; max-width: 420px; }
2429
2510
  /* Run-mode toggle. The "/mode auto|ask" chat command still works and is
2430
2511
  still what the bots are told about, but it is invisible until you know it
2431
2512
  exists — and it controls whether shell commands run without asking, which
2432
2513
  is exactly the setting a user should be able to SEE at a glance. */
2433
- #modeToggle { margin-left: auto; display: flex; align-items: center; gap: 0;
2514
+ #modeToggle { display: flex; align-items: center; gap: 0;
2434
2515
  background: #1c1c1e; border: 1px solid #333340; border-radius: 999px; padding: 2px; }
2435
2516
  .modebtn { border: 0; background: none; color: #8e8e93; font: inherit; font-size: 11px;
2436
2517
  font-weight: 600; padding: 4px 11px; border-radius: 999px; cursor: pointer;
@@ -2453,9 +2534,9 @@ const APP_HTML = `<!doctype html>
2453
2534
  .dial.pinned { color: #4c4d5a; border-color: #1c1c1e; }
2454
2535
  /* WALLET. GET /wallet shipped in 1.5.22 with nothing pointing at it, so the
2455
2536
  box's own deposit addresses were reachable only by curl — on a product
2456
- whose entire premise is that the box pays for itself. An address you
2457
- cannot copy is an address you cannot fund, so every one of them is a
2458
- click-to-copy row, not selectable text. */
2537
+ whose entire premise is that the box pays for itself. Click-to-copy is a
2538
+ shortcut; the address is selectable so Cmd/Ctrl+C still works if copy
2539
+ fails. */
2459
2540
  #walletOverlay, #assetsOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.66); z-index: 1200;
2460
2541
  display: none; align-items: center; justify-content: center; padding: 24px; }
2461
2542
  #walletOverlay.show, #assetsOverlay.show { display: flex; }
@@ -2471,8 +2552,10 @@ const APP_HTML = `<!doctype html>
2471
2552
  pushes the copy affordance out of the box — the same flexbox trap that
2472
2553
  once pushed the cost button off-screen. */
2473
2554
  .wrow .waddr { flex: 1; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
2474
- font-size: 12px; word-break: break-all; line-height: 1.45; }
2475
- .wrow .wcopy { flex: 0 0 auto; color: #6f7080; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
2555
+ font-size: 12px; word-break: break-all; line-height: 1.45; cursor: text;
2556
+ -webkit-user-select: all; user-select: all; -webkit-app-region: no-drag; }
2557
+ .wrow .wcopy { flex: 0 0 auto; color: #6f7080; font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
2558
+ user-select: none; }
2476
2559
  .wrow:hover .wcopy { color: #b8f240; }
2477
2560
  .wbal { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
2478
2561
  font-size: 12px; color: #ececec; line-height: 1.7; word-break: break-word; }
@@ -2501,8 +2584,7 @@ const APP_HTML = `<!doctype html>
2501
2584
  .scmd b { color: #b8f240; font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
2502
2585
  .scmd i { color: #6f7080; font-style: normal; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
2503
2586
  .scmd span { color: #999aa8; margin-left: auto; text-align: right; }
2504
- #hudBtn { margin-left: 10px; }
2505
- #chatHeaderId { display: flex; align-items: center; gap: 10px; }
2587
+ #hudBtn { margin-left: 2px; }
2506
2588
  #hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
2507
2589
  border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
2508
2590
  display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
@@ -2520,7 +2602,9 @@ const APP_HTML = `<!doctype html>
2520
2602
  color: #f0c9a8; font-size: 10.5px; line-height: 1.45; }
2521
2603
  #hud .hhint.show { display: block; }
2522
2604
  #hud .hhint b { color: #f28c4d; font-weight: 600; }
2523
- #sidebar, #main { -webkit-app-region: no-drag; }
2605
+ #sidebar, #main, #walletOverlay, #assetsOverlay, #composeOverlay,
2606
+ #inp, #search, #composeInp, .wpass, .bubble, .md-pre, .runoutput, .runcmd {
2607
+ -webkit-app-region: no-drag; }
2524
2608
  #log { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 20px 24px 12px;
2525
2609
  display: flex; flex-direction: column; gap: 6px;
2526
2610
  -webkit-user-select: text; user-select: text; }
@@ -2573,9 +2657,10 @@ const APP_HTML = `<!doctype html>
2573
2657
  padding: 3px 8px; border: 1px solid #3a3a3d; border-radius: 8px;
2574
2658
  background: #1c1c1e; color: #b9b9c0;
2575
2659
  font: 500 10.5px/1.4 ui-sans-serif, -apple-system, system-ui, sans-serif;
2576
- letter-spacing: .02em; cursor: pointer;
2660
+ letter-spacing: .02em; cursor: pointer; user-select: none;
2577
2661
  transition: opacity .12s ease, background .12s ease, color .12s ease;
2578
2662
  }
2663
+ .md-pre, .runoutput, .runcmd { -webkit-user-select: text; user-select: text; }
2579
2664
  .row.bot .copybtn { right: -2px; }
2580
2665
  .row.user .copybtn { left: -2px; }
2581
2666
  .row:hover .copybtn, .row:focus-within .copybtn { opacity: 1; pointer-events: auto; }
@@ -2710,14 +2795,15 @@ const APP_HTML = `<!doctype html>
2710
2795
  </div>
2711
2796
  <div id="walletOverlay" data-component="wallet-modal">
2712
2797
  <div id="walletBox">
2713
- <h3>This box's wallet</h3>
2714
- <div class="wsub">Every model call is paid from here, per call. No account, no API key.</div>
2798
+ <h3>Your wallet</h3>
2799
+ <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>
2715
2800
  <div id="walletBody">loading…</div>
2716
2801
  </div>
2717
2802
  </div>
2718
2803
  <div id="main">
2719
2804
  <div id="chatHeader">
2720
2805
  <div id="chatHeaderId"></div>
2806
+ <div id="headerDials">
2721
2807
  <div id="modeToggle" data-component="run-mode-toggle" role="group" aria-label="Shell command mode">
2722
2808
  <button class="modebtn ask on" id="modeAsk" data-mode="ask"
2723
2809
  title="Shell commands pause and wait for your approval">ask</button>
@@ -2748,9 +2834,10 @@ const APP_HTML = `<!doctype html>
2748
2834
  <button class="dial" id="assetsBtn" data-component="assets-unlock"
2749
2835
  title="Unlock the encrypted assets baked into this box's image">assets</button>
2750
2836
  <button class="dial" id="walletBtn" data-component="wallet-open"
2751
- title="This box's own wallet — deposit addresses and live balances">wallet</button>
2837
+ title="Your local burner wallet — deposit addresses and live balances">wallet</button>
2752
2838
  <button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">&#8635;</button>
2753
2839
  <button class="icon-btn" id="hudBtn">◎</button>
2840
+ </div>
2754
2841
  </div>
2755
2842
  <div id="hud">
2756
2843
  <div class="htitle">YOUR WALLET · THIS SESSION</div>
@@ -3060,7 +3147,7 @@ const APP_HTML = `<!doctype html>
3060
3147
  // did not say, and guessing "empty" there would send someone to top up a
3061
3148
  // wallet that is fine.
3062
3149
  note.textContent = w.funded === false
3063
- ? 'This wallet is EMPTY — calls will fail with HTTP 402 until it is funded. ' + (w.funding || '')
3150
+ ? 'This wallet is EMPTY — calls will fail with HTTP 402 until you fund the addresses above. ' + (w.funding || '')
3064
3151
  : (w.funding || '');
3065
3152
  if (w.funded === false) note.classList.add('wempty');
3066
3153
  if (note.textContent.trim()) walletBody.appendChild(note);
@@ -3113,6 +3200,27 @@ const APP_HTML = `<!doctype html>
3113
3200
  });
3114
3201
 
3115
3202
  document.getElementById('walletBtn').addEventListener('click', openWallet);
3203
+ // First launch: if the burner is empty, open the wallet once so they see
3204
+ // addresses they can copy — and whose wallet this is. localStorage so a
3205
+ // funded session or a dismiss does not keep popping it.
3206
+ (async function maybeOpenWalletOnce() {
3207
+ if (localStorage.getItem('openzoo.wallet.seen')) return;
3208
+ for (let i = 0; i < 8; i++) {
3209
+ try {
3210
+ const r = await fetch(API + '/wallet');
3211
+ const w = r.ok ? await r.json() : null;
3212
+ if (!w || (!w.solana && !w.evm)) {
3213
+ await new Promise((res) => setTimeout(res, 400));
3214
+ continue;
3215
+ }
3216
+ localStorage.setItem('openzoo.wallet.seen', '1');
3217
+ if (w.funded === false) await openWallet();
3218
+ return;
3219
+ } catch (e) {
3220
+ await new Promise((res) => setTimeout(res, 400));
3221
+ }
3222
+ }
3223
+ })();
3116
3224
  // Click-outside and Escape both close it — a modal you can only dismiss one
3117
3225
  // way is a modal people get stuck in.
3118
3226
  walletOverlay.addEventListener('click', (e) => {
@@ -3175,15 +3283,22 @@ const APP_HTML = `<!doctype html>
3175
3283
  instead of silently doing nothing, which is the worst outcome for a button
3176
3284
  whose entire job is invisible. */
3177
3285
  async function copyText(text) {
3286
+ const value = String(text ?? '');
3287
+ try {
3288
+ if (window.electronAPI && typeof window.electronAPI.copyText === 'function') {
3289
+ const ok = await window.electronAPI.copyText(value);
3290
+ if (ok) return true;
3291
+ }
3292
+ } catch (e) { /* fall through */ }
3178
3293
  try {
3179
3294
  if (navigator.clipboard && window.isSecureContext) {
3180
- await navigator.clipboard.writeText(text);
3295
+ await navigator.clipboard.writeText(value);
3181
3296
  return true;
3182
3297
  }
3183
3298
  } catch (e) { /* fall through to the legacy path */ }
3184
3299
  try {
3185
3300
  const ta = document.createElement('textarea');
3186
- ta.value = text;
3301
+ ta.value = value;
3187
3302
  // Off-screen but focusable: display:none or visibility:hidden make
3188
3303
  // execCommand('copy') a no-op in several browsers.
3189
3304
  ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.82",
3
+ "version": "0.48.83",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — 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",