openzoo 0.48.18 → 0.48.22

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
@@ -5,13 +5,13 @@
5
5
  // own independent agent (and that agent can spawn further threads too) —
6
6
  // reusing the same SPAWN/SEND pattern podagent.mjs built for shell delegation,
7
7
  // adapted here for plain chat.
8
- import { exec } from 'node:child_process';
8
+ import { exec, execFile } from 'node:child_process';
9
9
  import http from 'node:http';
10
10
  import { randomUUID } from 'node:crypto';
11
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
11
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
12
  import { cpus, homedir } from 'node:os';
13
13
  import path from 'node:path';
14
- import { brain, brainStream, MODEL, PROXY } from './podagent.mjs';
14
+ import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES } from './podagent.mjs';
15
15
 
16
16
  const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
17
17
  // BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
@@ -122,6 +122,68 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
122
122
  text — web search only gives you short
123
123
  snippets; use FETCH when asked to
124
124
  "read" or quote something specific
125
+
126
+ HOW YOUR SITE ACTUALLY GETS A URL — read this before writing web files.
127
+
128
+ The working directory is served as a STATIC FILE SERVER at a public URL. It does not run
129
+ a bundler, a dev server, or a build step. Nothing watches your files and compiles them.
130
+ MEASURED failure: a crew wrote tetris-metagame/index.html containing
131
+ <script type="module" src="/src/main.jsx">
132
+ an unbuilt Vite scaffold. Browsers CANNOT execute JSX, so that page returned 200 and
133
+ rendered a blank screen — for hours, while the crew reported the site as built.
134
+
135
+ 1. BUILD BEFORE YOU CLAIM A SITE EXISTS. For a Vite/React app: install, build, and put the
136
+ OUTPUT where it is served. Then RUN a command that prints the built index.html and check
137
+ the script tag points at a real .js file, not a .jsx/.ts/.tsx source.
138
+ 2. PREFER PLAIN HTML. One self-contained index.html — inline CSS, vanilla JS — needs no
139
+ build and works the instant you write it. For most things asked of you that is entirely
140
+ sufficient, and it gets a live URL now instead of after a toolchain fight.
141
+ 3. PUT index.html AT THE WORKSPACE ROOT unless you have a reason not to. The SHALLOWEST
142
+ index.html is the one the preview shows.
143
+ 4. RELATIVE ASSET PATHS: src="assets/app.js", never src="/assets/app.js". The site is
144
+ served under a path prefix, so a leading slash resolves off the site entirely.
145
+ 5. CHECK IT YOURSELF before reporting done — RUN: curl -s localhost:8080/site/ | head -20
146
+ and READ the output. A .jsx reference, or <div id="root"></div> with no working script,
147
+ means it is broken and you are not finished.
148
+
149
+ Never report a site as finished until you have curled it and seen real markup.
150
+
151
+
152
+ SHIP IT IN ONE PASS. This is the single most important instruction here.
153
+
154
+ You are not a planning assistant. The user wants a WORKING THING at a URL, and they want
155
+ it from the first reply — the way v0 or lovable answers: you describe it, it exists.
156
+
157
+ So on any "build me X" request:
158
+
159
+ WRITE THE WHOLE THING NOW. One self-contained index.html at the workspace root, with
160
+ inline CSS and vanilla JS, complete enough to open and use. Not a skeleton, not a
161
+ scaffold, not "here is the structure and I will fill it in" — a real, playable,
162
+ clickable artifact on the FIRST reply. Ugly and working beats elegant and absent.
163
+
164
+ NO BUILD STEP unless the user asked for one. Do not npm init, do not scaffold Vite or
165
+ React or Next, do not create package.json, do not make a src/ tree. The directory is a
166
+ static file server: a plain index.html works the instant you write it, and a framework
167
+ scaffold is a blank page until someone runs a build nobody asked for.
168
+
169
+ ITERATE ON THE FILE, NOT ON A PLAN. When something needs changing, EDIT the file and
170
+ say what changed in one line. Never restate the roadmap, never re-list the features,
171
+ never ask which part to do first.
172
+
173
+ DO NOT SPLIT ONE ARTIFACT ACROSS AGENTS. If it is one page, one game, one dashboard —
174
+ build it yourself. Spawning a subagent per feature produces five halves of a thing and
175
+ no thing. Spawn only for work that is genuinely separate and parallel, and only when
176
+ told to.
177
+
178
+ DO NOT WRITE DOCUMENTS INSTEAD OF CODE. SPEC.md, ARCHITECTURE.md, a design doc, a
179
+ checklist — none of these are what was asked for and none of them render at a URL.
180
+ Write the artifact. If you truly need to think first, think in the reply, not in a file.
181
+
182
+ A STACK IS NEVER YOURS TO PICK. If the brief names one — a chain, an engine, an SDK, a
183
+ token — use exactly that, even where your training pulls somewhere else. "Smart
184
+ contract" does not mean Solidity. Anything you were told about the stack outranks
185
+ anything you assume.
186
+
125
187
  MCP: <url> list the tools an MCP server exposes
126
188
  MCP: <url> | <tool> | {"arg": "value"} CALL one of them, for real
127
189
  An MCP endpoint speaks JSON-RPC over
@@ -132,7 +194,12 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
132
194
  own client. Use this directive; it
133
195
  does the initialize handshake, holds
134
196
  the session, and calls the tool.
135
- LS: <path, or blank for the root> list a directory
197
+ LS: [path] list a directory. The argument is
198
+ OPTIONAL — a bare LS: on its own
199
+ line lists the working directory.
200
+ Never write a placeholder like
201
+ <blank> or <path>; either give a
202
+ real path or give nothing.
136
203
  GLOB: <pattern> find files — *.js, **/*.test.ts, src/**
137
204
  GREP: <regex> | <optional path or glob> search file CONTENTS, with line numbers
138
205
  EDIT: <path> | <exact old text> ||| <new text> change PART of a file. Prefer this over
@@ -246,13 +313,23 @@ function newThread(name, parent, members) {
246
313
  //
247
314
  // dir matters just as much — a child that defaults elsewhere cannot see the
248
315
  // files the parent was sent to work on.
316
+ //
317
+ // tier/race/raceMode for the same reason, and one more: they are the SPEND
318
+ // dial. Setting a project to the expensive tier and then having every
319
+ // subagent it spawns silently drop back to medium means the setting applies
320
+ // to the one bot you happened to be looking at and nothing that does the
321
+ // actual work. The opposite is worse — dropping to cheap should not be
322
+ // quietly undone by a fan-out into four frontier models.
249
323
  const p = parent ? threads.get(parent) : null;
250
324
  const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
251
325
  messages: members ? null : [{ role: 'system', content: SYSTEM }],
252
326
  members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now(),
253
327
  ...(p?.runMode ? { runMode: p.runMode } : {}),
254
328
  ...(p?.dir ? { dir: p.dir } : {}),
255
- ...(p?.model ? { model: p.model } : {}) };
329
+ ...(p?.model ? { model: p.model } : {}),
330
+ ...(p?.tier ? { tier: p.tier } : {}),
331
+ ...(p?.race ? { race: p.race } : {}),
332
+ ...(p?.raceNeed ? { raceNeed: p.raceNeed } : {}) };
256
333
  threads.set(id, t);
257
334
  saveThreads();
258
335
  return t;
@@ -296,6 +373,68 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
296
373
  RUN: <shell command> run a REAL shell command in this
297
374
  group's shared directory — pauses the
298
375
  WHOLE round for the user's approval
376
+
377
+ SHIP IT IN ONE PASS. This is the single most important instruction here.
378
+
379
+ You are not a planning assistant. The user wants a WORKING THING at a URL, and they want
380
+ it from the first reply — the way v0 or lovable answers: you describe it, it exists.
381
+
382
+ So on any "build me X" request:
383
+
384
+ WRITE THE WHOLE THING NOW. One self-contained index.html at the workspace root, with
385
+ inline CSS and vanilla JS, complete enough to open and use. Not a skeleton, not a
386
+ scaffold, not "here is the structure and I will fill it in" — a real, playable,
387
+ clickable artifact on the FIRST reply. Ugly and working beats elegant and absent.
388
+
389
+ NO BUILD STEP unless the user asked for one. Do not npm init, do not scaffold Vite or
390
+ React or Next, do not create package.json, do not make a src/ tree. The directory is a
391
+ static file server: a plain index.html works the instant you write it, and a framework
392
+ scaffold is a blank page until someone runs a build nobody asked for.
393
+
394
+ ITERATE ON THE FILE, NOT ON A PLAN. When something needs changing, EDIT the file and
395
+ say what changed in one line. Never restate the roadmap, never re-list the features,
396
+ never ask which part to do first.
397
+
398
+ DO NOT SPLIT ONE ARTIFACT ACROSS AGENTS. If it is one page, one game, one dashboard —
399
+ build it yourself. Spawning a subagent per feature produces five halves of a thing and
400
+ no thing. Spawn only for work that is genuinely separate and parallel, and only when
401
+ told to.
402
+
403
+ DO NOT WRITE DOCUMENTS INSTEAD OF CODE. SPEC.md, ARCHITECTURE.md, a design doc, a
404
+ checklist — none of these are what was asked for and none of them render at a URL.
405
+ Write the artifact. If you truly need to think first, think in the reply, not in a file.
406
+
407
+ A STACK IS NEVER YOURS TO PICK. If the brief names one — a chain, an engine, an SDK, a
408
+ token — use exactly that, even where your training pulls somewhere else. "Smart
409
+ contract" does not mean Solidity. Anything you were told about the stack outranks
410
+ anything you assume.
411
+
412
+
413
+ HOW YOUR SITE ACTUALLY GETS A URL — read this before writing web files.
414
+
415
+ The working directory is served as a STATIC FILE SERVER at a public URL. It does not run
416
+ a bundler, a dev server, or a build step. Nothing watches your files and compiles them.
417
+ MEASURED failure: a crew wrote tetris-metagame/index.html containing
418
+ <script type="module" src="/src/main.jsx">
419
+ an unbuilt Vite scaffold. Browsers CANNOT execute JSX, so that page returned 200 and
420
+ rendered a blank screen — for hours, while the crew reported the site as built.
421
+
422
+ 1. BUILD BEFORE YOU CLAIM A SITE EXISTS. For a Vite/React app: install, build, and put the
423
+ OUTPUT where it is served. Then RUN a command that prints the built index.html and check
424
+ the script tag points at a real .js file, not a .jsx/.ts/.tsx source.
425
+ 2. PREFER PLAIN HTML. One self-contained index.html — inline CSS, vanilla JS — needs no
426
+ build and works the instant you write it. For most things asked of you that is entirely
427
+ sufficient, and it gets a live URL now instead of after a toolchain fight.
428
+ 3. PUT index.html AT THE WORKSPACE ROOT unless you have a reason not to. The SHALLOWEST
429
+ index.html is the one the preview shows.
430
+ 4. RELATIVE ASSET PATHS: src="assets/app.js", never src="/assets/app.js". The site is
431
+ served under a path prefix, so a leading slash resolves off the site entirely.
432
+ 5. CHECK IT YOURSELF before reporting done — RUN: curl -s localhost:8080/site/ | head -20
433
+ and READ the output. A .jsx reference, or <div id="root"></div> with no working script,
434
+ means it is broken and you are not finished.
435
+
436
+ Never report a site as finished until you have curled it and seen real markup.
437
+
299
438
  before anything executes ("/mode auto"
300
439
  in chat skips that wait). Use this
301
440
  instead of guessing or saying you
@@ -418,10 +557,21 @@ async function bindThread(t) {
418
557
  const from = t.boundHistoryCount || 0;
419
558
  const delta = t.history.slice(from);
420
559
  if (!delta.length) return;
421
- const corpus = delta.map((h) => (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
560
+ // Stamp every line with WHICH BOT said it. The context is shared across a
561
+ // project now, so an unlabelled line is worse than useless — recall would
562
+ // hand one agent another's words with no way to tell them apart.
563
+ const corpus = delta.map((h) => '[' + t.name + '] ' + (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
422
564
  if (!corpus.trim()) { t.boundHistoryCount = t.history.length; return; }
423
565
  try {
424
- let ctx = t.contextId;
566
+ // ONE CONTEXT PER PROJECT, not per thread. Every thread used to bind to
567
+ // its own private context, so sibling agents spawned for the same job were
568
+ // memory-isolated: arc-tetris-engine could not recall a thing
569
+ // arc-token-bets had established, and the user paid to re-explain shared
570
+ // facts to each one. They are a team; the memory should be too.
571
+ // The context lives on the ROOT and every descendant binds into it, while
572
+ // each thread keeps its OWN boundHistoryCount so nothing is re-sent.
573
+ const root = threads.get(rootOf(t).rootId) || t;
574
+ let ctx = root.contextId || t.contextId;
425
575
  for (let i = 0; i < corpus.length; i += BIND_CHUNK_BYTES) {
426
576
  const part = corpus.slice(i, i + BIND_CHUNK_BYTES);
427
577
  const body = ctx ? { corpus: part, context_id: ctx } : { corpus: part };
@@ -432,9 +582,15 @@ async function bindThread(t) {
432
582
  });
433
583
  const j = await r.json().catch(() => ({}));
434
584
  if (j?.context_id) ctx = j.context_id;
585
+ // How many chunks the project's holobrain now holds. This is the number
586
+ // adaptive top_k scales on — without it we would be guessing, which is
587
+ // exactly how top_k ended up pinned at 8 in the first place.
588
+ if (Number(j?.bound)) root.boundItems = (root.boundItems || 0) + Number(j.bound);
435
589
  else break; // this chunk failed — stop, keep whatever bound so far rather than lose it all
436
590
  }
437
- if (ctx) { t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
591
+ // Write to the ROOT so later siblings inherit it, and to this thread so
592
+ // the per-call header is readable without walking the tree again.
593
+ if (ctx) { root.contextId = ctx; t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
438
594
  } catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
439
595
  }
440
596
 
@@ -499,13 +655,40 @@ function parseRun(reply) {
499
655
  const dsml = new RegExp(`<${SEP}DSML[^>]*\\bparameter\\b[^>]*\\bname="${NAME}"[^>]*>([\\s\\S]*?)<\\/${SEP}DSML`, 'i').exec(reply);
500
656
  if (dsml) return sanitizeRunCommand(dsml[1]);
501
657
 
502
- const m = /^[ \t>*-]*RUN:[ \t]*([\s\S]+)/m.exec(reply);
503
- if (!m) return null;
504
- let cmd = m[1];
505
- const fenced = /^```[\w-]*\n([\s\S]*?)```/.exec(cmd.trim());
506
- if (fenced) cmd = fenced[1];
507
- else cmd = cmd.replace(/\n```[\s\S]*$/, ''); // trailing fence + any posttext
508
- return sanitizeRunCommand(cmd);
658
+ // SEVERAL "RUN:" LINES IN ONE REPLY RUN BACK TO BACK.
659
+ //
660
+ // This capture is `[\s\S]+` on purpose — a command can legitimately span
661
+ // lines (heredocs, trailing backslashes, fenced blocks). But it is GREEDY,
662
+ // so when a model emitted three RUN: lines the FIRST match swallowed the
663
+ // other two and handed bash:
664
+ // cd .oz-parts && cat ... > ../full.zip
665
+ // RUN: cd .oz-parts && ls -lh full.zip
666
+ // RUN: cd .oz-parts && unzip -q full.zip
667
+ // which is exactly the MEASURED failure:
668
+ // /bin/bash: line 3: RUN:: command not found (lines 3, 5, 7 — exit 127)
669
+ // The first command ran, the rest died as literal text, and the model then
670
+ // concluded the RUN: prefix itself was the problem and started emitting bare
671
+ // commands — which do nothing at all. One greedy quantifier, and the agent
672
+ // reasons its way out of using the only tool it has.
673
+ //
674
+ // Split on the RUN: lines and join with newlines: sequential, one shell, in
675
+ // order. NOT `&&` — that stops the batch at the first non-zero exit, and a
676
+ // model batching three steps means "do these three", not "abort quietly if
677
+ // the first one greps nothing".
678
+ const heads = [...reply.matchAll(/^[ \t>*-]*RUN:[ \t]*/gm)];
679
+ if (!heads.length) return null;
680
+ const cmds = [];
681
+ for (let i = 0; i < heads.length; i++) {
682
+ const from = heads[i].index + heads[i][0].length;
683
+ const to = i + 1 < heads.length ? heads[i + 1].index : reply.length;
684
+ let cmd = reply.slice(from, to);
685
+ const fenced = /^```[\w-]*\n([\s\S]*?)```/.exec(cmd.trim());
686
+ if (fenced) cmd = fenced[1];
687
+ else cmd = cmd.replace(/\n```[\s\S]*$/, ''); // trailing fence + any posttext
688
+ cmd = sanitizeRunCommand(cmd);
689
+ if (cmd) cmds.push(cmd);
690
+ }
691
+ return cmds.length ? cmds.join('\n') : null;
509
692
  }
510
693
 
511
694
  // RUN through BASH, not /bin/sh. node's exec() defaults to /bin/sh, which on
@@ -556,11 +739,15 @@ const SLASH_COMMANDS = [
556
739
  { name: '/tokens', args: '', help: 'tokens and calls this session' },
557
740
  { name: '/model', args: '[id]', help: 'show or switch this thread’s model' },
558
741
  { name: '/models', args: '[filter]', help: 'search the ~435 served models' },
742
+ { name: '/tier', args: 'cheap|medium|expensive', help: 'how much to spend per turn when no model is pinned' },
743
+ { name: '/race', args: '<n> | <k> <n>', help: 'launch n models; judge the first k back (k=1 = fastest wins)' },
559
744
  { name: '/compact', args: '', help: 'summarise history to shrink context' },
560
745
  { name: '/clear', args: '', help: 'wipe this thread’s history' },
561
746
  { name: '/undo', args: '', help: 'drop the last exchange' },
562
747
  { name: '/memory', args: '[text|clear]', help: 'facts injected into every turn' },
563
748
  { name: '/sessions', args: '', help: 'list all threads' },
749
+ { name: '/all', args: '<message>', help: 'send a message to every bot in this project' },
750
+ { name: '/ping', args: '', help: 'status of every bot in this project' },
564
751
  { name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
565
752
  { name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
566
753
  { name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
@@ -698,6 +885,70 @@ async function handleSlash(task, t) {
698
885
  saveThreads();
699
886
  return `This thread now uses ${arg}.\nNote: while images are in play the vision model still wins, or the call would just fail.`;
700
887
  }
888
+ // /tier — the spend dial for "auto". A pinned /model outranks it, because a
889
+ // tier silently overriding an explicit id would make /model a suggestion.
890
+ if (cmd === 'tier') {
891
+ if (!arg) {
892
+ const picks = await tierModels(t.tier || 'medium', 3);
893
+ return `This thread: ${t.tier || 'medium'}${t.tier ? '' : ' (default)'}\n`
894
+ + `Tiers: ${TIER_NAMES.join(' · ')}\n`
895
+ + `Top of ${t.tier || 'medium'} right now: ${picks.join(', ')}\n`
896
+ + (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so the tier is ignored until you /model default.\n` : '')
897
+ + 'Switch with /tier <name> · /race <n> to ask several at once.';
898
+ }
899
+ const want = arg.trim().toLowerCase();
900
+ if (!TIER_NAMES.includes(want)) return `Unknown tier "${arg}". One of: ${TIER_NAMES.join(', ')}.`;
901
+ t.tier = want; saveThreads();
902
+ const picks = await tierModels(want, 3);
903
+ return `This thread now runs on the ${want} tier — ${picks.join(', ')}…`
904
+ + (t.model ? `\nBut /model ${t.model} is still pinned and wins. Run /model default to let the tier take over.` : '');
905
+ }
906
+
907
+ // /race — spend more to wait less, and to survive one provider having a bad
908
+ // minute. Every entrant is PAID FOR; say so plainly, because the cost is not
909
+ // visible anywhere else until the bill.
910
+ if (cmd === 'race') {
911
+ const showNow = () => {
912
+ const n = Number(t.race) || 0, k = Math.min(Number(t.raceNeed) || 1, n || 1);
913
+ if (n < 2) return 'not racing';
914
+ return k > 1 ? `best of the first ${k} back, out of ${n} launched` : `${n} launched, first one back wins`;
915
+ };
916
+ if (!arg) {
917
+ return `This thread: ${showNow()}\n`
918
+ + 'Set with /race <n> launch n (2-4), FIRST real answer wins.\n'
919
+ + ' /race <k> <n> launch n, and the moment k of them are back, judge those k\n'
920
+ + ' and ship the winner. The stragglers are abandoned mid-flight.\n\n'
921
+ + '/race 2 3 is the useful one. k=1 optimises latency only — on a hard question it rewards\n'
922
+ + 'whichever model thought LEAST. k=n buys quality with the slowest entrant\'s latency, so one\n'
923
+ + 'wedged provider stalls the whole turn. Taking the first k bounds the wait at the k-th\n'
924
+ + 'fastest and still gives the judge something to compare.\n'
925
+ + 'Judging is blind (A/B/C/D) and done by a cheap model. Empty replies do not count toward k.\n'
926
+ + 'You pay for every entrant, including the abandoned ones, so n=4 costs about 4x a turn.';
927
+ }
928
+ const nums = arg.trim().split(/[^0-9]+/).filter(Boolean).map(Number);
929
+ if (!nums.length) return `"${arg}" is not a number. Use /race 0 to turn it off, /race 3, or /race 2 3.`;
930
+ // One number is n (judge nothing). Two is "k of n" — and accept them in
931
+ // either order, because "best 2 of 3" and "3, judge 2" are the same wish
932
+ // and guessing wrong silently changes what the user pays for.
933
+ let n = nums.length === 1 ? nums[0] : Math.max(nums[0], nums[1]);
934
+ let k = nums.length === 1 ? 1 : Math.min(nums[0], nums[1]);
935
+ n = Math.max(0, Math.min(4, Math.round(n)));
936
+ k = Math.max(1, Math.min(k, n || 1));
937
+ if (n < 2) { delete t.race; delete t.raceNeed; saveThreads(); return 'Racing off — one model per turn.'; }
938
+ t.race = n;
939
+ if (k > 1) t.raceNeed = k; else delete t.raceNeed;
940
+ saveThreads();
941
+ const pool = await tierModels(t.tier || 'medium', 99);
942
+ return (k > 1
943
+ ? `Launching ${n} models per turn from the ${t.tier || 'medium'} tier (${pool.length} in the pool, drawn at random).\n`
944
+ + `As soon as ${k} of them are back, a cheap model reads those ${k} blind and picks the best. `
945
+ + `The other ${n - k} are abandoned.`
946
+ : `Launching ${n} models per turn from the ${t.tier || 'medium'} tier (${pool.length} in the pool, drawn at random).\n`
947
+ + 'First real answer wins; the rest are discarded.')
948
+ + `\nCosts about ${n}x a normal turn — you pay for the abandoned ones too.`
949
+ + (t.model ? `\nNOTE: /model ${t.model} is pinned, which disables racing. /model default to race.` : '');
950
+ }
951
+
701
952
  if (cmd === 'models') {
702
953
  try {
703
954
  const list = await (await fetch(`${PROXY}/models`)).json();
@@ -762,6 +1013,39 @@ async function handleSlash(task, t) {
762
1013
  saveThreads();
763
1014
  return `Remembered. This is injected into every turn of this thread.\n ${t.memory.length}. ${arg}`;
764
1015
  }
1016
+ // Talk to the WHOLE project at once. PING: * exists for bots, but there was
1017
+ // no way for a person to do it — you had to open each thread and retype the
1018
+ // same message, which is exactly the chore that makes a 12-agent project
1019
+ // unusable.
1020
+ if (cmd === 'all') {
1021
+ if (!arg) {
1022
+ return 'Usage: /all <message> — sends it to everyone BELOW you: your subagents, '
1023
+ + 'their subagents, all the way down.\n'
1024
+ + 'It is scoped to your own branch, not the whole project — run it from the top '
1025
+ + 'thread to reach everybody.';
1026
+ }
1027
+ const crew = subtreeOf(t.id);
1028
+ if (!crew.length) return 'You have no subagents to send to.';
1029
+ for (const x of crew) runTurn(x.id, arg).catch(() => {});
1030
+ return `Sent down your branch to ${crew.length} bot(s): ${crew.map((x) => x.name).join(', ')}`;
1031
+ }
1032
+
1033
+ // Read the room without spending anything: who is working, who is blocked on
1034
+ // an approval, what each said last.
1035
+ if (cmd === 'ping') {
1036
+ // Same scoping as /all: your branch, not the whole project.
1037
+ const crew = subtreeOf(t.id, true);
1038
+ if (crew.length < 2) return 'You have no subagents yet.';
1039
+ return crew.map((x) => {
1040
+ const mark = x.id === t.id ? ' (here)' : '';
1041
+ const last = x.history[x.history.length - 1];
1042
+ return x.pendingRun ? ` ${x.name}${mark}: BLOCKED — waiting for your approval`
1043
+ : x.status === 'thinking' ? ` ${x.name}${mark}: working`
1044
+ : last ? ` ${x.name}${mark}: ${String(last.text).replace(/\s+/g, ' ').slice(0, 90)}`
1045
+ : ` ${x.name}${mark}: nothing yet`;
1046
+ }).join('\n');
1047
+ }
1048
+
765
1049
  if (cmd === 'sessions') {
766
1050
  const all = [...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
767
1051
  if (!all.length) return 'No threads.';
@@ -855,7 +1139,302 @@ function globToRe(glob) {
855
1139
  return new RegExp('^' + re + '$');
856
1140
  }
857
1141
 
1142
+ /**
1143
+ * Translate a FOREIGN tool-call envelope into our directive lines.
1144
+ *
1145
+ * Models carry other harnesses' call syntax out of training and emit it here
1146
+ * even when the system prompt spells ours out. MEASURED live on a fresh box:
1147
+ *
1148
+ * [TOOL_CALL]
1149
+ * {tool => "LS", args => {
1150
+ * --path ""
1151
+ * }}
1152
+ * [/TOOL_CALL]
1153
+ *
1154
+ * Nothing matched, so it rendered as chat text, nothing ran, and the user had
1155
+ * to type "continue?" — three prompts to get one spawn. This is the same class
1156
+ * of bug as the DSML envelope in parseRun and the old /^RUN:/ anchor: a
1157
+ * silently discarded directive reads to the model as a tool that does nothing,
1158
+ * and it narrates work it never did rather than reporting a failure.
1159
+ *
1160
+ * Translating beats correcting: a correction costs another paid turn, this
1161
+ * costs nothing and the model never learns it was wrong — which is fine,
1162
+ * because being right about the envelope was never the job.
1163
+ */
1164
+ function translateForeignToolCall(reply) {
1165
+ const src = String(reply);
1166
+ // THREE envelope dialects seen in production, all from models carrying some
1167
+ // other harness's format out of training:
1168
+ // [TOOL_CALL]{tool => "LS", args => { --path "" }}[/TOOL_CALL]
1169
+ // <tool_call>RUN<arg_key>command</arg_key><arg_value>ls -la</arg_value></tool_call>
1170
+ // <DSML|invoke name="RUN">… (handled separately, inside parseRun)
1171
+ // Each one shipped as chat text and did nothing until it was taught here, so
1172
+ // the shape of the fix is: normalise ANY of them into {tool, args} and share
1173
+ // one mapping. A fourth dialect should be a few lines, not another outage.
1174
+ const out = [];
1175
+ // DELIMITERS DO NOT HAVE TO MATCH. Models MIX the dialects — measured live:
1176
+ // [TOOL_CALL]
1177
+ // RUN
1178
+ // <arg_key>command</arg_key><arg_value>pwd; ls -la</arg_value>
1179
+ // </tool_call>
1180
+ // opens with the bracket form and closes with the XML one. Matching PAIRS
1181
+ // ([TOOL_CALL]…[/TOOL_CALL] or <tool_call>…</tool_call>) misses that
1182
+ // entirely, so it rendered as chat text and nothing ran. Scan from ANY
1183
+ // opener to the NEXT closer of EITHER kind.
1184
+ const blocks = [...src.matchAll(
1185
+ /(?:\[TOOL_CALL\]|<tool_call>)([\s\S]*?)(?:\[\/TOOL_CALL\]|<\/tool_call>|$)/gi,
1186
+ )].map((m) => ({ body: m[1] }));
1187
+
1188
+ // DEEPSEEK'S NATIVE SPECIAL-TOKEN FORM — a fifth dialect, and the costliest.
1189
+ // <|tool_call_begin|>functions.WRITE:0<|tool_call_argument_begin|>
1190
+ // {"path": "/workspace/x/anti-cheat.js", "content": "…"}
1191
+ // <|tool_call_end|>
1192
+ // MEASURED: a complete anti-cheat implementation, several hundred lines,
1193
+ // rendered as chat text and written nowhere. The name carries a
1194
+ // "functions." prefix and a ":0" call index; the argument is plain JSON, so
1195
+ // this one maps straight onto our directives once the wrapper is peeled.
1196
+ const dsBlocks = [...src.matchAll(
1197
+ /<\|tool_call_begin\|>([\s\S]*?)<\|tool_call_argument_begin\|>([\s\S]*?)(?:<\|tool_call_end\|>|$)/g,
1198
+ )];
1199
+ for (const m of dsBlocks) {
1200
+ const rawName = m[1].trim().replace(/^functions?\./i, '').replace(/[:.]\d+$/, '');
1201
+ let a = {};
1202
+ try { a = JSON.parse(m[2].trim()); } catch { continue; }
1203
+ const t = rawName.toUpperCase();
1204
+ const pick = (...ks) => ks.map((k) => a[k]).find((v) => v !== undefined && v !== '');
1205
+ if (t === 'WRITE' || t === 'WRITE_FILE') out.push('WRITE: ' + (pick('path', 'file') || '') + ' | ' + (pick('content', 'text', 'body') || ''));
1206
+ else if (t === 'RUN' || t === 'BASH' || t === 'SHELL' || t === 'EXEC') out.push('RUN: ' + (pick('command', 'cmd', 'script') || ''));
1207
+ else if (t === 'READ' || t === 'READ_FILE' || t === 'CAT') out.push('READ: ' + (pick('path', 'file') || ''));
1208
+ else if (t === 'LS' || t === 'LIST') out.push('LS: ' + (pick('path', 'dir') || ''));
1209
+ else if (t === 'GLOB' || t === 'FIND') out.push('GLOB: ' + (pick('pattern', 'glob', 'query') || '*'));
1210
+ else if (t === 'FETCH') out.push('FETCH: ' + (pick('url', 'uri') || ''));
1211
+ else if (t === 'SPAWN') out.push('SPAWN: ' + (pick('name', 'agent') || 'helper') + ' | ' + (pick('task', 'prompt', 'goal') || ''));
1212
+ else if (t === 'SEND') out.push('SEND: ' + (pick('name', 'agent', 'to') || '') + ' | ' + (pick('message', 'msg', 'task') || ''));
1213
+ else if (t === 'MCP') {
1214
+ const tool2 = pick('tool', 'name', 'method') || '';
1215
+ const argj = pick('arg', 'args', 'arguments', 'params');
1216
+ out.push('MCP: ' + (pick('url', 'server') || '')
1217
+ + (tool2 ? ' | ' + tool2 + (argj !== undefined ? ' | ' + (typeof argj === 'string' ? argj : JSON.stringify(argj)) : '') : ''));
1218
+ }
1219
+ }
1220
+ for (const b of blocks) {
1221
+ const body = b.body;
1222
+ // The name is either quoted ({tool => "LS"}) or bare on its own first line
1223
+ // (<tool_call>RUN). Which delimiter opened the block tells us nothing once
1224
+ // models mix them, so try both spellings every time.
1225
+ const tool = (/tool\s*(?:=>|:)\s*"([^"]+)"/i.exec(body) || [])[1]
1226
+ || (/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/m.exec(body) || [])[1];
1227
+ if (!tool) continue;
1228
+ // Arguments come in three spellings across harnesses: --flag "v",
1229
+ // "key" => "v", and key: "v". Accept all of them rather than betting on one.
1230
+ const args = {};
1231
+ for (const m of body.matchAll(/--([a-z_]+)\s+"([^"]*)"/gi)) args[m[1].toLowerCase()] = m[2];
1232
+ for (const m of body.matchAll(/"?([a-z_]+)"?\s*(?:=>|:)\s*"([^"]*)"/gi)) {
1233
+ if (m[1].toLowerCase() !== 'tool') args[m[1].toLowerCase()] = m[2];
1234
+ }
1235
+ // <arg_key>name</arg_key><arg_value>value</arg_value>, repeated. Values are
1236
+ // NOT quoted in this dialect and may contain anything, so the value regex
1237
+ // has to be lazy to its own closing tag rather than stop at a quote.
1238
+ for (const m of body.matchAll(/<arg_key>\s*([^<]+?)\s*<\/arg_key>\s*<arg_value>([\s\S]*?)<\/arg_value>/gi)) {
1239
+ args[m[1].toLowerCase()] = m[2];
1240
+ }
1241
+ const a = (...names) => names.map((n) => args[n]).find((v) => v !== undefined && v !== '');
1242
+ const t = tool.toUpperCase();
1243
+ if (t === 'LS' || t === 'LIST') out.push('GLOB: ' + ((a('path', 'dir', 'directory') || '.').replace(/\/$/, '') + '/*'));
1244
+ else if (t === 'GLOB' || t === 'FIND' || t === 'SEARCH_FILES') out.push('GLOB: ' + (a('pattern', 'glob', 'query') || '*'));
1245
+ else if (t === 'READ' || t === 'READ_FILE' || t === 'CAT') out.push('READ: ' + (a('path', 'file', 'filename') || ''));
1246
+ else if (t === 'WRITE' || t === 'WRITE_FILE') out.push('WRITE: ' + (a('path', 'file') || '') + ' | ' + (a('content', 'text', 'body') || ''));
1247
+ else if (t === 'FETCH' || t === 'HTTP' || t === 'BROWSE') out.push('FETCH: ' + (a('url', 'uri') || ''));
1248
+ else if (t === 'RUN' || t === 'BASH' || t === 'SHELL' || t === 'EXEC') out.push('RUN: ' + (a('command', 'cmd', 'script') || ''));
1249
+ else if (t === 'SPAWN') out.push('SPAWN: ' + (a('name', 'agent') || 'helper') + ' | ' + (a('task', 'prompt', 'goal') || ''));
1250
+ else if (t === 'MCP') {
1251
+ // MCP takes url [| tool | {json}] — rebuild whichever form was meant.
1252
+ const url = a('url', 'server', 'endpoint') || '';
1253
+ const tool2 = a('tool', 'name', 'method') || '';
1254
+ const argj = a('arg', 'args', 'arguments', 'params') || '';
1255
+ out.push('MCP: ' + url + (tool2 ? ' | ' + tool2 + (argj ? ' | ' + argj : '') : ''));
1256
+ }
1257
+ else if (t === 'SEND') out.push('SEND: ' + (a('name', 'agent', 'to') || '') + ' | ' + (a('message', 'msg', 'task') || ''));
1258
+ else if (t === 'PING') out.push('PING: ' + (a('name', 'agent') || ''));
1259
+ else if (t === 'SERVE') out.push('SERVE: ' + (a('path', 'file') || ''));
1260
+ else if (t === 'GREP') out.push('GREP: ' + (a('pattern', 'query', 'text') || ''));
1261
+ }
1262
+ // Only claim a translation when a directive actually came out of it — an
1263
+ // unrecognised tool must fall through to the no-directive nudge, not vanish
1264
+ // into an empty string that looks like a successful parse.
1265
+ if (!blocks.length && !out.length) return null;
1266
+ const text = out.filter((l) => !/(:|\|)\s*$/.test(l)).join('\n');
1267
+ return text || null;
1268
+ }
1269
+
1270
+ /**
1271
+ * Cut a directive's argument at the START OF THE NEXT DIRECTIVE.
1272
+ *
1273
+ * Directive bodies are captured with a greedy [\s\S]+ so a task or a command
1274
+ * may span lines. Greedy means the FIRST directive in a reply swallows every
1275
+ * one after it — measured on RUN (three commands became one broken script) and
1276
+ * again on SPAWN, where an orchestrator emitting five SPAWN lines would have
1277
+ * created one agent whose "task" was the other four.
1278
+ */
1279
+ function sliceToNextDirective(text) {
1280
+ const m = /\n[ \t>*-]*(?:RUN|SPAWN|SEND|PING|PEEK|READ|WRITE|EDIT|GLOB|LS|LIST|DIR|FIND|GREP|SERVE|FETCH|MCP|DONE|TODO):/.exec(text);
1281
+ let out = m ? text.slice(0, m.index) : text;
1282
+ // Models fence their directive lists, so the LAST one in a block otherwise
1283
+ // ends up owning the closing ``` and every word of prose after it — a
1284
+ // subagent whose task was "Design anti-cheat ``` These subagents will work
1285
+ // independently." Cut at the closing fence, same as parseRun does.
1286
+ out = out.replace(/\n[ \t]*```[\s\S]*$/, '');
1287
+ return out.trim();
1288
+ }
1289
+
1290
+ /** Every head of one directive in a reply, in order, each with its argument
1291
+ * already cut at the next head. Tolerates preamble, list markers and quotes —
1292
+ * models put "1. " and "> " in front of directives constantly. */
1293
+ function directiveLines(reply, keyword) {
1294
+ const heads = [...String(reply).matchAll(new RegExp('^[ \\t>*-]*(?:\\d+[.)]\\s*)?' + keyword + ':[ \\t]*', 'gm'))];
1295
+ return heads.map((h, i) => {
1296
+ const from = h.index + h[0].length;
1297
+ const to = i + 1 < heads.length ? heads[i + 1].index : reply.length;
1298
+ return sliceToNextDirective(reply.slice(from, to));
1299
+ }).filter(Boolean);
1300
+ }
1301
+
1302
+ /**
1303
+ * Undo the two things models do to a directive line that make it unrecognisable.
1304
+ *
1305
+ * MEASURED live, repeatedly: <LS: <blank>>
1306
+ *
1307
+ * Both halves come from our own prompt. It documented the argument as
1308
+ * "<path, or blank for the root>", so the model copied the angle brackets AND
1309
+ * the word blank, producing a placeholder where a path goes and wrapping the
1310
+ * whole line for good measure. The prompt no longer teaches that, but models
1311
+ * carry <ARG> notation out of training regardless, and a directive we refuse
1312
+ * over its punctuation costs a paid turn every time.
1313
+ *
1314
+ * Only the directive HEAD is touched — never a body. WRITE content legitimately
1315
+ * contains angle brackets (it is usually HTML), and rewriting that would corrupt
1316
+ * files to fix a cosmetic problem.
1317
+ */
1318
+ function unwrapDirectiveLine(reply) {
1319
+ const KW = 'RUN|SPAWN|SEND|PING|PEEK|READ|WRITE|EDIT|MULTIEDIT|NOTEBOOK|GLOB|LS|LIST|DIR|FIND|GREP|SERVE|FETCH|MCP|DONE|TODO';
1320
+ return String(reply).split('\n').map((line) => {
1321
+ // <LS: ...> -> LS: ... (a whole directive wrapped in angle brackets)
1322
+ let out = line.replace(new RegExp('^([ \\t>*-]*)<\\s*((?:' + KW + '):[\\s\\S]*?)\\s*>\\s*$'), '$1$2');
1323
+ // LS: <blank> -> LS: (a placeholder standing in for "no argument")
1324
+ out = out.replace(new RegExp('^([ \\t>*-]*(?:' + KW + '):)[ \\t]*<(?:blank|empty|none|nothing|path|dir|directory|optional)>[ \\t]*$', 'i'), '$1');
1325
+ return out;
1326
+ }).join('\n');
1327
+ }
1328
+
1329
+ /**
1330
+ * The brief a subagent is missing.
1331
+ *
1332
+ * A SPAWN task is the ORCHESTRATOR'S PARAPHRASE, and it is the whole world the
1333
+ * child ever sees. MEASURED: the user told the orchestrator "remember always:
1334
+ * proofnetwork can do it all, from rng to rpc/wallet management", and the
1335
+ * orchestrator emitted
1336
+ * SPAWN: meta_game_dev | Develop a meta-game where non-player users can wager
1337
+ * with ProofNetwork nowhere in it. The child heard "smart contract for
1338
+ * wagering", and wrote TetrisMetaGame.SOL — Solidity, on a Solana project —
1339
+ * because that is what "smart contract" means in the training distribution
1340
+ * when nothing says otherwise. The stack WAS specified. It just never reached
1341
+ * the agent doing the work.
1342
+ *
1343
+ * So a child now starts with the message that caused its own existence. Not a
1344
+ * summary of it, not the parent's whole transcript — the actual words the user
1345
+ * last wrote to the parent, which is where constraints like "this is
1346
+ * ProofNetwork" and "use token22 <mint>" always live.
1347
+ */
1348
+ function spawnBrief(parent) {
1349
+ 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';
1359
+ }
1360
+
1361
+ /**
1362
+ * WHERE YOU SIT IN THE TREE — the thing that stops pointless re-fanning.
1363
+ *
1364
+ * There is deliberately NO depth cap on SPAWN. A cap is the wrong tool: real
1365
+ * work is n-tiered and the right depth is whatever the job is. What actually
1366
+ * went wrong is that a child had no idea the split had ALREADY HAPPENED —
1367
+ * MEASURED, tetris-engine-builder was handed "build the Tetris game", could
1368
+ * not see that four siblings already owned the other quarters, and spawned
1369
+ * agent-1..agent-4 to re-split its own slice. Sixteen threads, generic names,
1370
+ * nobody building anything.
1371
+ *
1372
+ * So tell it: who you are, how many of you there are, what each sibling holds,
1373
+ * and how deep you already are. An agent that can see the decomposition does
1374
+ * the work instead of re-performing the decomposition.
1375
+ */
1376
+ function spawnPosition(parent, childName) {
1377
+ if (!parent) return '';
1378
+ const siblings = [...threads.values()].filter((x) => x.parent === parent.id).map((x) => x.name);
1379
+ const depth = rootOf(parent).depth + 1;
1380
+ const others = siblings.filter((n) => n !== childName);
1381
+ return '\n\n--- your place in the team ---\n'
1382
+ + `You are "${childName}", spawned by "${parent.name}". You are at tier ${depth} of this project.\n`
1383
+ + (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'
1386
+ : '')
1387
+ + 'Do NOT re-split your own slice into more agents just because it has several parts — '
1388
+ + 'that is how a team becomes sixteen bots and zero artifacts. SPAWN only if your slice '
1389
+ + 'contains genuinely independent work that NO existing sibling covers, and name any agent '
1390
+ + 'you do spawn after what it owns, never "agent-1".\n'
1391
+ + 'Work in your own directory so you cannot collide with a sibling: '
1392
+ + `type /dir <path> if you need one. Everything else — build it yourself, now.\n`;
1393
+ }
1394
+
1395
+ /**
1396
+ * "AGENT-NAME: do the thing" -> "SEND: AGENT-NAME | do the thing"
1397
+ *
1398
+ * Gated on the name resolving to a real thread, so prose like "Note:" or
1399
+ * "Warning:" cannot be mistaken for an address. Names are compared with
1400
+ * separators and case stripped, because a model writes INSTALL-ANALYZE-MCP for
1401
+ * a thread called install-analyze-mcp and means the same bot.
1402
+ */
1403
+ function nameAddressedToSend(reply, originId) {
1404
+ const norm = (x) => String(x).toLowerCase().replace(/[^a-z0-9]/g, '');
1405
+ const byNorm = new Map();
1406
+ for (const x of threads.values()) if (x.id !== originId) byNorm.set(norm(x.name), x.name);
1407
+ if (!byNorm.size) return reply;
1408
+ return String(reply).split('\n').map((line) => {
1409
+ const m = /^([ \t>*-]*)([A-Za-z][A-Za-z0-9 _-]{1,48}):[ \t]+(\S[\s\S]*)$/.exec(line);
1410
+ if (!m) return line;
1411
+ const real = byNorm.get(norm(m[2]));
1412
+ // A directive keyword that happens to match a thread name stays a directive.
1413
+ if (!real || /^(RUN|SPAWN|SEND|PING|PEEK|READ|WRITE|EDIT|GLOB|LS|LIST|DIR|FIND|GREP|SERVE|FETCH|MCP|DONE|TODO)$/i.test(m[2].trim())) return line;
1414
+ return `${m[1]}SEND: ${real} | ${m[3]}`;
1415
+ }).join('\n');
1416
+ }
1417
+
858
1418
  async function tryDirective(reply, originId) {
1419
+ // Foreign envelope in, our directives out — before any matching runs, so
1420
+ // every branch below sees the shape it was written for.
1421
+ const translated = translateForeignToolCall(reply);
1422
+ if (translated) reply = translated;
1423
+ // Then strip angle-bracket wrapping and placeholder arguments.
1424
+ reply = unwrapDirectiveLine(reply);
1425
+ // ADDRESSING A BOT BY BARE NAME IS A SEND.
1426
+ //
1427
+ // MEASURED: an orchestrator wrote a block of
1428
+ // INSTALL-ANALYZE-MCP: Please proceed with installing the MCP…
1429
+ // GAME-ENGINE: Please proceed with designing the prototype…
1430
+ // one line per subagent, and the user had to ask "sorry did you forget
1431
+ // 'SEND: '". It is a perfectly clear instruction in every sense except the
1432
+ // one the parser cares about.
1433
+ //
1434
+ // Only rewritten when the prefix MATCHES AN EXISTING THREAD, case- and
1435
+ // separator-insensitive. That is what makes this safe: "Note:" or "Step 2:"
1436
+ // never matches a live agent, so ordinary prose is untouched.
1437
+ reply = nameAddressedToSend(reply, originId);
859
1438
  // FAN OUT FIRST. Each line is re-entered on its own, so every branch below
860
1439
  // stays single-directive and none of them had to learn about batching.
861
1440
  const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
@@ -867,7 +1446,57 @@ async function tryDirective(reply, originId) {
867
1446
  return results.filter(Boolean).join('\n\n');
868
1447
  }
869
1448
 
870
- const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
1449
+ // EVERY SPAWN LINE, NOT JUST THE FIRST — AND NOT ONLY AT THE START.
1450
+ //
1451
+ // This was /^SPAWN:...([\s\S]+)/ with NO `m` flag, which is two bugs at once:
1452
+ //
1453
+ // `^` without `m` anchors to the start of the WHOLE REPLY. An orchestrator
1454
+ // that writes "To address your requirements, I will spawn subagents:" and
1455
+ // then lists its directives never matched AT ALL — measured live, five
1456
+ // SPAWN lines, five SEND lines and five PING lines across three turns, and
1457
+ // the sidebar still held exactly one bot. Identical to the /^RUN:/ anchor
1458
+ // bug this file already documents, never applied to SPAWN.
1459
+ //
1460
+ // And [\s\S]+ is greedy, so even once it matched, the FIRST spawn would
1461
+ // swallow the other four into its own task.
1462
+ //
1463
+ // Fan out over every head, cutting each task at the next directive.
1464
+ const spawnAll = directiveLines(reply, 'SPAWN');
1465
+ if (spawnAll.length > 1) {
1466
+ // CREATE THE WHOLE COHORT BEFORE ANY OF THEM STARTS THINKING.
1467
+ //
1468
+ // Re-entering tryDirective per line spawns them one at a time, and
1469
+ // spawnPosition reads the sibling set AT SPAWN TIME — so the first child
1470
+ // was told it had no siblings, the second one, the fifth four. MEASURED:
1471
+ // tetris-game, spawned second of five, was told "Your siblings under
1472
+ // openzoo are: mcp-analyzer" while three more were seconds behind it. The
1473
+ // whole point of telling a child the split already happened is defeated if
1474
+ // it cannot see most of the split.
1475
+ //
1476
+ // The full cohort is knowable up front — it is right there in the reply.
1477
+ // So: parse every line, create every thread, THEN start their turns.
1478
+ const parent = threads.get(originId);
1479
+ const parsed = spawnAll.map((line) => /^([^|]+)\|([\s\S]+)/.exec(line))
1480
+ .filter(Boolean).map((m) => ({ name: m[1].trim(), task: m[2].trim() }));
1481
+ const made = [];
1482
+ const notes = [];
1483
+ for (const { name, task } of parsed) {
1484
+ const existing = findByName(name);
1485
+ if (existing) { notes.push(`${name} already exists — sending it the task.`); made.push({ t: existing, task, fresh: false }); continue; }
1486
+ const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
1487
+ if (siblings >= SPAWN_MAX_CHILDREN) { notes.push(`Not spawning "${name}": already at ${SPAWN_MAX_CHILDREN} subagents.`); continue; }
1488
+ made.push({ t: newThread(name, originId), task, fresh: true });
1489
+ }
1490
+ // Every thread now exists, so spawnPosition sees the COMPLETE cohort.
1491
+ for (const { t: sub, task, fresh } of made) {
1492
+ runTurn(sub.id, (fresh ? spawnBrief(parent) : '') + task
1493
+ + (fresh ? spawnPosition(parent, sub.name) : '')).catch(() => {});
1494
+ }
1495
+ const fresh = made.filter((m) => m.fresh).map((m) => m.t.name);
1496
+ return [fresh.length ? `Spawned ${fresh.length} together (they can each see the full crew): ${fresh.join(', ')}` : '', ...notes]
1497
+ .filter(Boolean).join('\n');
1498
+ }
1499
+ const spawn = spawnAll.length === 1 ? /^([^|]+)\|([\s\S]+)/.exec(spawnAll[0]) : null;
871
1500
  if (spawn) {
872
1501
  const name = spawn[1].trim();
873
1502
  const task = spawn[2].trim();
@@ -892,27 +1521,85 @@ async function tryDirective(reply, originId) {
892
1521
  + `every live subagent costs paid calls.`;
893
1522
  }
894
1523
  const sub = newThread(name, originId);
895
- runTurn(sub.id, task).catch(() => {}); // fire and forgetruns independently
1524
+ // 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
896
1526
  return `Spawned ${name} — working on it.`;
897
1527
  }
898
- const sendM = /^SEND:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
1528
+ // SEND TO A NAME THAT DOES NOT EXIST YET *SPAWNS* IT.
1529
+ //
1530
+ // SPAWN already degrades to SEND when the name is taken ("already exists —
1531
+ // sent it the task instead of spawning a duplicate"). This is that rule's
1532
+ // missing half, and without it the pair was asymmetric in the direction that
1533
+ // costs money: an orchestrator planning a crew names all of them up front and
1534
+ // then SENDs, so every not-yet-spawned member returned
1535
+ // No thread named "Solana-Betting-Metagame" to message.
1536
+ // — MEASURED live, twice in one chain. Each one is a dead turn the model then
1537
+ // has to notice, diagnose and recover from, at auto-mode prices.
1538
+ //
1539
+ // Creating it is what was meant. The message IS the task; that is exactly the
1540
+ // argument SPAWN takes, so there is nothing to invent.
1541
+ // Same two bugs as SPAWN above: no `m`, and a greedy body.
1542
+ const sendAll = directiveLines(reply, 'SEND');
1543
+ if (sendAll.length > 1) {
1544
+ const out = [];
1545
+ for (const line of sendAll) out.push(await tryDirective('SEND: ' + line, originId));
1546
+ return out.filter(Boolean).join('\n');
1547
+ }
1548
+ const sendM = sendAll.length === 1 ? /^([^|]+)\|([\s\S]+)/.exec(sendAll[0]) : null;
899
1549
  if (sendM) {
900
1550
  const name = sendM[1].trim();
901
1551
  const msg = sendM[2].trim();
902
1552
  const target = findByName(name);
903
- if (target) runTurn(target.id, msg).catch(() => {});
904
- return target ? `Messaged ${name}.` : `No thread named "${name}" to message.`;
1553
+ if (target) {
1554
+ runTurn(target.id, msg).catch(() => {});
1555
+ return `Messaged ${name}.`;
1556
+ }
1557
+ // The SAME storm guard SPAWN uses — promoting a SEND must not be a way
1558
+ // around the subagent ceiling, or a chatty orchestrator fans out for free
1559
+ // just by spelling its directive differently.
1560
+ const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
1561
+ if (siblings >= SPAWN_MAX_CHILDREN) {
1562
+ return `Cannot create "${name}": this thread already has ${siblings} subagents `
1563
+ + `(limit ${SPAWN_MAX_CHILDREN}). Reuse one with SEND: <existing name> | <task>.`;
1564
+ }
1565
+ const sub = newThread(name, originId);
1566
+ runTurn(sub.id, spawnBrief(threads.get(originId)) + msg + spawnPosition(threads.get(originId), name)).catch(() => {});
1567
+ return `${name} did not exist — spawned it with that message as its task.`;
905
1568
  }
906
- const ping = /^PING:\s*(.+)/.exec(reply);
1569
+ // PING had the same anchor bug — no `m`, so a PING after any preamble (or
1570
+ // inside a fence, which is where models put lists of them) never matched.
1571
+ const pingAll = directiveLines(reply, 'PING');
1572
+ if (pingAll.length > 1) {
1573
+ const out = [];
1574
+ for (const line of pingAll) out.push(await tryDirective('PING: ' + line, originId));
1575
+ return out.filter(Boolean).join('\n');
1576
+ }
1577
+ const ping = pingAll.length === 1 ? [null, pingAll[0]] : null;
907
1578
  if (ping) {
908
1579
  const name = ping[1].trim();
1580
+ // PING: * (or 'all' / 'project') reaches EVERY bot in this project.
1581
+ // Coordinating a spawn tree by naming siblings one at a time is a chore
1582
+ // the parent should not have to do, and it cannot know who else exists.
1583
+ if (/^(\*|all|project|everyone)$/i.test(name)) {
1584
+ const me = threads.get(originId);
1585
+ const root = me ? rootOf(me).rootId : null;
1586
+ const crew = [...threads.values()].filter((x) => x.id !== originId && rootOf(x).rootId === root);
1587
+ if (!crew.length) return 'No other bots in this project yet.';
1588
+ return crew.map((x) => {
1589
+ const last = x.history[x.history.length - 1];
1590
+ return x.pendingRun ? x.name + ': BLOCKED — waiting for approval'
1591
+ : x.status === 'thinking' ? x.name + ': still working'
1592
+ : last ? x.name + ': ' + String(last.text).slice(0, 200)
1593
+ : x.name + ': no reply yet';
1594
+ }).join('\n');
1595
+ }
909
1596
  const target = findByName(name);
910
1597
  const last = target?.history[target.history.length - 1];
911
1598
  return !target ? `No thread named "${name}".`
912
1599
  : target.status === 'thinking' ? `${name} is still working.`
913
1600
  : last ? `${name}: ${last.text}` : `${name} hasn't replied yet.`;
914
1601
  }
915
- const peek = /^PEEK:\s*(.+)/.exec(reply);
1602
+ const peek = /^[ \t>*-]*PEEK:\s*(.+)/m.exec(reply);
916
1603
  if (peek) {
917
1604
  const name = peek[1].trim();
918
1605
  const target = findByName(name);
@@ -921,7 +1608,7 @@ async function tryDirective(reply, originId) {
921
1608
  .map((h) => (h.who === 'user' ? 'you' : (h.name || target.name)) + ': ' + h.text).join('\n');
922
1609
  return `${name} (${target.status}):\n${recent || '(nothing yet)'}`;
923
1610
  }
924
- const write = /^WRITE:\s*([^|]+)\|([\s\S]+)/.exec(reply);
1611
+ const write = /^[ \t>*-]*WRITE:\s*([^|]+)\|([\s\S]+)/m.exec(reply);
925
1612
  if (write) {
926
1613
  const rel = write[1].trim();
927
1614
  const content = write[2].replace(/^\n/, '');
@@ -932,7 +1619,7 @@ async function tryDirective(reply, originId) {
932
1619
  return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.`;
933
1620
  } catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
934
1621
  }
935
- const readD = /^READ:\s*(.+)/.exec(reply);
1622
+ const readD = /^[ \t>*-]*READ:\s*(.+)/m.exec(reply);
936
1623
  if (readD) {
937
1624
  const rel = readD[1].trim();
938
1625
  try {
@@ -943,7 +1630,7 @@ async function tryDirective(reply, originId) {
943
1630
  // EDIT beats WRITE for changing part of a file: WRITE overwrites the whole
944
1631
  // thing, so a model that wants a one-line change has to reproduce the entire
945
1632
  // file from memory and silently drops whatever it forgot.
946
- const edit = /^EDIT:\s*([^|]+)\|([\s\S]*?)\|\|\|([\s\S]*)$/.exec(reply);
1633
+ const edit = /^[ \t>*-]*EDIT:\s*([^|]+)\|([\s\S]*?)\|\|\|([\s\S]*)$/m.exec(reply);
947
1634
  if (edit) {
948
1635
  const rel = edit[1].trim();
949
1636
  const oldStr = edit[2].replace(/^\n/, '').replace(/\n$/, '');
@@ -962,7 +1649,7 @@ async function tryDirective(reply, originId) {
962
1649
  // Several edits to ONE file, applied all-or-nothing. Sequential EDITs are a
963
1650
  // trap: the third can fail after the first two already landed, leaving the
964
1651
  // file in a state neither the model nor the user expected.
965
- const multi = /^MULTIEDIT:\s*([^|]+)\|([\s\S]+)$/.exec(reply);
1652
+ const multi = /^[ \t>*-]*MULTIEDIT:\s*([^|]+)\|([\s\S]+)$/m.exec(reply);
966
1653
  if (multi) {
967
1654
  const rel = multi[1].trim();
968
1655
  const pairs = multi[2].split(';;').map((p) => p.split('|||')).filter((p) => p.length === 2);
@@ -986,7 +1673,7 @@ async function tryDirective(reply, originId) {
986
1673
  }
987
1674
 
988
1675
  // Jupyter: replace one cell's source by index, keeping the notebook valid.
989
- const nb = /^NOTEBOOK:\s*([^|]+)\|\s*(\d+)\s*\|([\s\S]+)$/.exec(reply);
1676
+ const nb = /^[ \t>*-]*NOTEBOOK:\s*([^|]+)\|\s*(\d+)\s*\|([\s\S]+)$/m.exec(reply);
990
1677
  if (nb) {
991
1678
  const rel = nb[1].trim();
992
1679
  const idx = Number(nb[2]);
@@ -1005,7 +1692,10 @@ async function tryDirective(reply, originId) {
1005
1692
  } catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
1006
1693
  }
1007
1694
 
1008
- const ls = /^LS:\s*(.*)$/.exec(reply);
1695
+ // The real directory listing. It has always existed and never once fired:
1696
+ // no `m` flag, so a bare LS: after any preamble was invisible, which is why
1697
+ // it looked like LS had no handler at all.
1698
+ const ls = /^[ \t>*-]*(?:LS|LIST|DIR):[ \t]*(.*)$/m.exec(reply);
1009
1699
  if (ls) {
1010
1700
  const rel = ls[1].trim() || '.';
1011
1701
  try {
@@ -1022,9 +1712,20 @@ async function tryDirective(reply, originId) {
1022
1712
  } catch (e) { return `Couldn't list ${rel}: ${e.message}`; }
1023
1713
  }
1024
1714
 
1025
- const glob = /^GLOB:\s*(.+)$/.exec(reply);
1715
+ // LS:/LIST:/DIR:/FIND: are ALIASES for GLOB, and the argument is optional.
1716
+ //
1717
+ // Models reach for "LS:" constantly — it is the obvious name for the thing
1718
+ // and it is all over their training. We only ever documented GLOB, so a bot
1719
+ // emitted a bare `LS:` (MEASURED live, right after announcing "let me first
1720
+ // check the current directory"), nothing matched, the line rendered as chat
1721
+ // text, and the user typed "continue…". Refusing a directive over its
1722
+ // spelling is not a rule, it is a bug that costs a paid turn every time.
1723
+ //
1724
+ // A bare LS: means "what is here" — the empty pattern that GLOB would reject
1725
+ // becomes `*`, which is what was meant.
1726
+ const glob = /^(?:GLOB|LS|LIST|DIR|FIND):[ \t]*(.*)$/m.exec(reply);
1026
1727
  if (glob) {
1027
- const pattern = glob[1].trim();
1728
+ const pattern = glob[1].trim() || '*';
1028
1729
  try {
1029
1730
  const base = dirFor(originId);
1030
1731
  const re = globToRe(pattern.startsWith('./') ? pattern.slice(2) : pattern);
@@ -1035,7 +1736,7 @@ async function tryDirective(reply, originId) {
1035
1736
  } catch (e) { return `GLOB ${pattern}: ${e.message}`; }
1036
1737
  }
1037
1738
 
1038
- const grep = /^GREP:\s*([^|]+?)(?:\s*\|\s*(.+))?$/.exec(reply);
1739
+ const grep = /^[ \t>*-]*GREP:\s*([^|]+?)(?:\s*\|\s*(.+))?$/m.exec(reply);
1039
1740
  if (grep) {
1040
1741
  const pattern = grep[1].trim();
1041
1742
  const scope = (grep[2] || '').trim();
@@ -1063,7 +1764,7 @@ async function tryDirective(reply, originId) {
1063
1764
 
1064
1765
  // A real, persisted checklist. Bots were already narrating plans; this makes
1065
1766
  // the plan a thing the user can see and the model can be held to.
1066
- const todo = /^TODO:\s*([\s\S]*)$/.exec(reply);
1767
+ const todo = /^[ \t>*-]*TODO:\s*([\s\S]*)$/m.exec(reply);
1067
1768
  if (todo) {
1068
1769
  const t = threads.get(originId);
1069
1770
  if (!t) return 'TODO: no such thread.';
@@ -1073,13 +1774,37 @@ async function tryDirective(reply, originId) {
1073
1774
  if (!t.todos.length) return 'TODO: (empty)';
1074
1775
  return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n');
1075
1776
  }
1076
- const done = /^done\s+(\d+)/i.exec(body);
1777
+ // TICKING SOMETHING OFF TELLS THE CREW.
1778
+ //
1779
+ // A goal completed in silence is a goal the rest of the project cannot
1780
+ // build on. Every agent was working blind: five bots writing five halves
1781
+ // of the same thing, nobody able to see what already existed, and the
1782
+ // orchestrator reduced to PINGing for status one bot at a time. The
1783
+ // information was there the whole time — it just never left the thread.
1784
+ //
1785
+ // "done <n> | <summary>" broadcasts a short peek to the project. The
1786
+ // summary is OPTIONAL; without one the item's own text is the peek, which
1787
+ // is usually enough and costs nothing to write.
1788
+ const done = /^done\s+(\d+)\s*(?:\|\s*([\s\S]+))?$/i.exec(body);
1077
1789
  if (done) {
1078
1790
  const idx = Number(done[1]) - 1;
1079
1791
  if (!t.todos[idx]) return `TODO: no item ${done[1]}.`;
1080
1792
  t.todos[idx].done = true;
1793
+ const peek = (done[2] || '').trim() || t.todos[idx].text;
1081
1794
  saveThreads();
1082
- return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n');
1795
+ const left = t.todos.filter((x) => !x.done).length;
1796
+ // Everyone in the project EXCEPT the sender, and only when there is a
1797
+ // project — a lone bot broadcasting to nobody is just a wasted turn.
1798
+ const root = rootOf(t).rootId;
1799
+ const crew = [...threads.values()].filter((x) => x.id !== t.id && rootOf(x).rootId === root);
1800
+ for (const x of crew) {
1801
+ runTurn(x.id, `[${t.name} finished] ${peek}\n`
1802
+ + `(${t.todos.length - left}/${t.todos.length} of its goals done. `
1803
+ + `This is a status peek — do NOT redo this work, and do not reply unless it changes yours.)`)
1804
+ .catch(() => {});
1805
+ }
1806
+ return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')
1807
+ + (crew.length ? `\n\nTold ${crew.length} bot(s) in this project: ${peek.slice(0, 90)}` : '');
1083
1808
  }
1084
1809
  if (/^clear$/i.test(body)) { t.todos = []; saveThreads(); return 'TODO: cleared.'; }
1085
1810
  // Otherwise: replace the list with the lines given.
@@ -1089,13 +1814,13 @@ async function tryDirective(reply, originId) {
1089
1814
  return 'TODO:\n' + t.todos.map((x, i) => ` ${i + 1}. [ ] ${x.text}`).join('\n');
1090
1815
  }
1091
1816
 
1092
- const serve = /^SERVE:\s*(.*)$/.exec(reply);
1817
+ const serve = /^[ \t>*-]*SERVE:\s*(.*)$/m.exec(reply);
1093
1818
  if (serve) {
1094
1819
  const rel = serve[1].trim();
1095
1820
  if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
1096
1821
  return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
1097
1822
  }
1098
- const fetchD = /^FETCH:\s*(\S+)/.exec(reply);
1823
+ const fetchD = /^[ \t>*-]*FETCH:\s*(\S+)/m.exec(reply);
1099
1824
  if (fetchD) {
1100
1825
  const url = fetchD[1].trim();
1101
1826
  try {
@@ -1286,7 +2011,14 @@ async function runTurn(threadId, userText, onEvent, images) {
1286
2011
  bindThread(t).catch(() => {});
1287
2012
  return;
1288
2013
  }
1289
- if (!/^\(command output\)/.test(userText)) t.autoSteps = 0;
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) {
2019
+ t.autoSteps = 0;
2020
+ delete t.autoNudged;
2021
+ }
1290
2022
  t.messages.push({ role: 'user', content: contentFor(userText, images) });
1291
2023
  t.status = 'thinking';
1292
2024
  let reply = '';
@@ -1307,9 +2039,34 @@ async function runTurn(threadId, userText, onEvent, images) {
1307
2039
  }
1308
2040
  if (t.runMode === 'auto') extras.push({ role: 'system', content: AUTO_DIRECTIVE });
1309
2041
  const callMsgs = extras.length ? [...t.messages, ...extras] : t.messages;
1310
- const ask = async () => (onEvent
1311
- ? (await brainStream(callMsgs, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId, t.model)).trim()
1312
- : (await brain(callMsgs, t.contextId, t.model)).trim());
2042
+ // WHICH MODEL SERVES THIS TURN.
2043
+ // /model <id> pins one explicitly and always wins an explicit choice is
2044
+ // not something a tier gets to override.
2045
+ // /race <n> fires n models from the tier at once, first real answer wins.
2046
+ // /tier otherwise picks the tier's best model.
2047
+ // `attempt` exists because a retry must be allowed to land somewhere else:
2048
+ // see the empty-completion loop below.
2049
+ const ask = async (attempt = 0) => {
2050
+ const emit = (delta) => onEvent && onEvent({ type: 'delta', name: t.name, color: t.color, delta });
2051
+ // Retrieval breadth scales with the PROJECT's corpus, not this thread's —
2052
+ // the holobrain is shared at the root, so that is the pool being searched.
2053
+ const topK = adaptiveTopK((threads.get(rootOf(t).rootId) || t).boundItems);
2054
+ const race = Math.min(Number(t.race) || 0, 4);
2055
+ if (!t.model && race >= 2) {
2056
+ const models = await tierModels(t.tier || 'medium', race, true);
2057
+ // need = how many must come BACK before judging. need 1 is a plain
2058
+ // first-past-the-post race; need N waits for all of them. The point of
2059
+ // the middle (2 of 3) is a judged answer without the slowest entrant
2060
+ // setting the latency.
2061
+ const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
2062
+ return (await brainRace(callMsgs, emit, t.contextId, models, need)).trim();
2063
+ }
2064
+ // A retry draws a DIFFERENT model from the tier rather than the same one.
2065
+ const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
2066
+ return (onEvent
2067
+ ? (await brainStream(callMsgs, emit, t.contextId, model)).trim()
2068
+ : (await brain(callMsgs, t.contextId, model, topK)).trim());
2069
+ };
1313
2070
  try {
1314
2071
  reply = await ask();
1315
2072
  // An EMPTY completion is transient far more often than it is meaningful —
@@ -1320,12 +2077,21 @@ async function runTurn(threadId, userText, onEvent, images) {
1320
2077
  // Retry in place rather than parking the thread behind a note the user has
1321
2078
  // to answer. In auto especially: a transient blip must not become a manual
1322
2079
  // step, which is the whole point of auto.
2080
+ // Each retry goes to a DIFFERENT model in the tier. Asking the same model
2081
+ // a fourth time after three empty completions is the definition of doing
2082
+ // the same thing and expecting a different result — and it is what produced
2083
+ // the "(the model returned nothing 4 times)" bubbles: four attempts, one
2084
+ // sick provider. Empties are per-model and uncorrelated, so moving is the
2085
+ // fix. A thread pinned with /model stays pinned; that was an explicit
2086
+ // choice and silently answering as something else would be worse.
1323
2087
  for (let i = 0; !reply && i < AUTO_EMPTY_RETRIES; i++) {
1324
2088
  await new Promise((r) => setTimeout(r, 400 * (i + 1)));
1325
- reply = await ask();
2089
+ reply = await ask(t.model ? 0 : i + 1);
1326
2090
  }
1327
2091
  if (!reply) {
1328
- reply = `(the model returned nothing ${AUTO_EMPTY_RETRIES + 1} times — that is a provider problem, not your input. Try /model to switch, or send anything to retry.)`;
2092
+ reply = t.model
2093
+ ? `(${t.model} returned nothing ${AUTO_EMPTY_RETRIES + 1} times. It is pinned on this thread, so nothing else was tried — /model default frees it to fall back, or /model <id> to switch.)`
2094
+ : `(${AUTO_EMPTY_RETRIES + 1} different models in the ${t.tier || 'medium'} tier each returned nothing — that is upstream, not your input. Try /tier expensive, /race 3, or send anything to retry.)`;
1329
2095
  }
1330
2096
  } catch (e) {
1331
2097
  reply = `error: ${e.message}`;
@@ -1410,15 +2176,180 @@ async function runTurn(threadId, userText, onEvent, images) {
1410
2176
  onEvent?.({ type: 'final', name: t.name, color: t.color, text: note });
1411
2177
  saveThreads();
1412
2178
  }
2179
+
2180
+ // ANNOUNCING IS NOT DOING — so do not let it end the turn.
2181
+ //
2182
+ // In auto, a reply with NO directive stopped the chain dead. The common one
2183
+ // is not a finished answer, it is an announcement: "I'll break this down and
2184
+ // spawn subagents immediately. First, let me check the uploaded files." The
2185
+ // harness posted that as the final word and waited, so the user typed
2186
+ // "continue?" — MEASURED live, three prompts to get one SPAWN.
2187
+ //
2188
+ // AUTO_DIRECTIVE already forbids this in the prompt, and models do it anyway.
2189
+ // A prompt rule with no enforcement is a suggestion. Re-ask once, in-band.
2190
+ //
2191
+ // NARROW ON PURPOSE. It fires only on announcement language, never on a plain
2192
+ // answer — auto is also how you hold an ordinary conversation, and nudging a
2193
+ // finished reply would spend a turn arguing with a bot that already
2194
+ // succeeded. Once per turn (autoNudged), so a model that announces twice
2195
+ // 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)) {
2198
+ t.autoNudged = true;
2199
+ t.autoSteps = (t.autoSteps || 0) + 1;
2200
+ saveThreads();
2201
+ bindThread(t).catch(() => {});
2202
+ runTurn(threadId, NUDGE, onEvent).catch(() => {});
2203
+ return;
2204
+ }
1413
2205
  bindThread(t).catch(() => {});
1414
2206
  }
1415
2207
 
2208
+ // Said it would, without a directive line. "Spawned X" and "working on it" are
2209
+ // in here because they are FALSE without a SPAWN: in the same reply — the bot
2210
+ // reports success for something the harness never saw.
2211
+ 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.';
2218
+
2219
+ /**
2220
+ * The PROJECT a thread belongs to = the root of its spawn tree, and how deep
2221
+ * it sits. Every thread already carried the parent id; nothing ever walked it, so
2222
+ * fifteen agents rendered as one flat list with no hint that twelve of them
2223
+ * were spawned by one root.
2224
+ *
2225
+ * Depth is capped and visited-guarded: SPAWN sets parent from whoever emitted
2226
+ * the directive, and a bot messaging its own ancestor could otherwise close a
2227
+ * cycle and hang the render loop.
2228
+ */
2229
+ function rootOf(t) {
2230
+ const seen = new Set();
2231
+ let cur = t;
2232
+ let depth = 0;
2233
+ while (cur?.parent && depth < 32 && !seen.has(cur.id)) {
2234
+ seen.add(cur.id);
2235
+ const next = threads.get(cur.parent);
2236
+ if (!next) break;
2237
+ cur = next;
2238
+ depth += 1;
2239
+ }
2240
+ return { rootId: cur?.id || t.id, depth };
2241
+ }
2242
+
2243
+ /**
2244
+ * Order threads as a TREE, not by recency.
2245
+ *
2246
+ * The sidebar sorted purely by lastActivityAt, which scrambled the hierarchy —
2247
+ * a child that just spoke jumped ABOVE its own parent, so the indentation drew
2248
+ * a structure the order contradicted. A tree you cannot read is worse than a
2249
+ * flat list, because it looks like it means something.
2250
+ *
2251
+ * Projects are ordered by their most recent activity (an active project stays
2252
+ * near the top, which is what recency was for), but WITHIN a project the order
2253
+ * is depth-first from the root, so a child is always directly under its parent
2254
+ * and indentation matches position. Siblings are ordered by recency.
2255
+ *
2256
+ * Cycle-guarded: SPAWN sets parent from whoever emitted the directive, and a
2257
+ * bot spawning toward its own ancestor could otherwise loop forever here.
2258
+ */
2259
+ function orderedThreads() {
2260
+ const all = [...threads.values()];
2261
+ const byParent = new Map();
2262
+ for (const t of all) {
2263
+ const key = t.parent || '';
2264
+ if (!byParent.has(key)) byParent.set(key, []);
2265
+ byParent.get(key).push(t);
2266
+ }
2267
+ for (const list of byParent.values()) list.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
2268
+
2269
+ // Newest-active project first.
2270
+ const roots = all.filter((t) => !t.parent || !threads.has(t.parent));
2271
+ const freshest = (t) => {
2272
+ let best = t.lastActivityAt || 0;
2273
+ const stack = [t.id];
2274
+ const seen = new Set();
2275
+ while (stack.length) {
2276
+ const id = stack.pop();
2277
+ if (seen.has(id)) continue;
2278
+ seen.add(id);
2279
+ for (const c of byParent.get(id) || []) {
2280
+ best = Math.max(best, c.lastActivityAt || 0);
2281
+ stack.push(c.id);
2282
+ }
2283
+ }
2284
+ return best;
2285
+ };
2286
+ roots.sort((a, b) => freshest(b) - freshest(a));
2287
+
2288
+ const out = [];
2289
+ const seen = new Set();
2290
+ const walk = (t) => {
2291
+ if (seen.has(t.id)) return;
2292
+ seen.add(t.id);
2293
+ out.push(t);
2294
+ for (const c of byParent.get(t.id) || []) walk(c);
2295
+ };
2296
+ for (const r of roots) walk(r);
2297
+ // Anything unreachable (orphaned parent id) still has to appear — a thread
2298
+ // you cannot see is a thread you cannot stop, and it bills.
2299
+ for (const t of all) if (!seen.has(t.id)) out.push(t);
2300
+ return out;
2301
+ }
2302
+
2303
+ /**
2304
+ * Everyone at or below a thread. Ping-all lives HERE, not on the project root.
2305
+ *
2306
+ * "/all" used to mean "every bot sharing my rootId" — the whole project,
2307
+ * regardless of who you were talking to. That is the wrong unit once a tree has
2308
+ * real tiers: a mid-level owner wants to reach ITS OWN crew, not the eleven
2309
+ * cousins under a sibling. Addressing the whole project from a leaf is how one
2310
+ * message costs twenty paid turns.
2311
+ *
2312
+ * Cycle-guarded, because SPAWN sets parent from whoever emitted the directive
2313
+ * and a bot spawning toward its own ancestor would otherwise loop here.
2314
+ */
2315
+ function subtreeOf(id, includeSelf = false) {
2316
+ const out = [];
2317
+ const seen = new Set([id]);
2318
+ const stack = [id];
2319
+ while (stack.length) {
2320
+ const cur = stack.pop();
2321
+ for (const x of threads.values()) {
2322
+ if (x.parent === cur && !seen.has(x.id)) {
2323
+ seen.add(x.id);
2324
+ out.push(x);
2325
+ stack.push(x.id);
2326
+ }
2327
+ }
2328
+ }
2329
+ const self = threads.get(id);
2330
+ return includeSelf && self ? [self, ...out] : out;
2331
+ }
2332
+
1416
2333
  function threadSummary(t) {
1417
2334
  const last = t.history[t.history.length - 1];
1418
2335
  return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
1419
2336
  preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
1420
2337
  createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
1421
- dir: t.dir || WORKSPACE_DIR, runMode: t.runMode || 'ask' };
2338
+ dir: t.dir || WORKSPACE_DIR, runMode: t.runMode || 'ask',
2339
+ // BLOCKED ON YOU. A thread with a pending RUN is stopped dead until
2340
+ // someone approves or denies it, and nothing in the sidebar said so — it
2341
+ // looked identical to an idle thread, so a subagent could sit waiting for
2342
+ // an approval nobody knew it wanted. The blue dot means "working"; this
2343
+ // means "your move".
2344
+ awaitingUser: Boolean(t.pendingRun),
2345
+ rootId: rootOf(t).rootId, depth: rootOf(t).depth,
2346
+ rootName: (threads.get(rootOf(t).rootId) || t).name,
2347
+ // The spend dial, so the header can show it without a round trip per
2348
+ // thread. `model` pinned means tier/race are inert — the UI says so.
2349
+ tier: t.tier || 'medium', race: Number(t.race) || 0, raceNeed: Number(t.raceNeed) || 1, model: t.model || '',
2350
+ // How many bots sit BELOW this one. The ping-all affordance belongs on
2351
+ // anyone with a crew, not only on a project root.
2352
+ kids: subtreeOf(t.id).length };
1422
2353
  }
1423
2354
 
1424
2355
  const APP_HTML = `<!doctype html>
@@ -1442,6 +2373,28 @@ const APP_HTML = `<!doctype html>
1442
2373
  #threads { flex: 1; overflow-y: auto; }
1443
2374
  .trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
1444
2375
  margin: 0 6px 2px; }
2376
+ /* PROJECT HEADER. The tree indentation shows who spawned whom, but there was
2377
+ no handle on a project as a WHOLE — no way to see where one ends and the
2378
+ next begins, and no way to talk to all of it at once without opening each
2379
+ bot and retyping. This row is that handle. It only appears for a root that
2380
+ actually has children; a lone bot is not a project and does not need a
2381
+ label above it. */
2382
+ .prow { display: flex; align-items: center; gap: 8px; padding: 10px 12px 4px; margin: 0 6px;
2383
+ font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: #6f7080; }
2384
+ .prow .pname { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2385
+ .pcount { color: #4c4d5a; letter-spacing: 0; text-transform: none; }
2386
+ .pingall { border: 1px solid #2c2c2e; background: transparent; color: #8e8e93; font: inherit; font-size: 10px;
2387
+ letter-spacing: .04em; text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
2388
+ cursor: pointer; opacity: 0; transition: opacity .12s ease, color .12s ease, border-color .12s ease; }
2389
+ .prow:hover .pingall, .pingall:focus-visible { opacity: 1; }
2390
+ /* On a THREAD row the button sits beside the close X, so it follows the same
2391
+ reveal-on-hover rule — a permanently visible control on every parent row
2392
+ would turn the sidebar into a wall of buttons. */
2393
+ .trow .pingall { flex: 0 0 auto; margin-left: 2px; }
2394
+ .trow:hover .pingall, .trow.active .pingall { opacity: 1; }
2395
+ .pingall:hover { color: #b8f240; border-color: #b8f240; }
2396
+ .pingall:focus-visible { outline: 2px solid #6ab0ff; outline-offset: 2px; }
2397
+ .pingall[disabled] { opacity: 1; color: #4c4d5a; border-color: #1c1c1e; cursor: default; }
1445
2398
  .trow:hover { background: #17171a; }
1446
2399
  .trow.active { background: #1c1c1e; }
1447
2400
  .tclose { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 50%; border: none; background: transparent;
@@ -1455,6 +2408,17 @@ const APP_HTML = `<!doctype html>
1455
2408
  .tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1456
2409
  .tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1457
2410
  .tdot { width: 8px; height: 8px; border-radius: 50%; background: #0a84ff; flex: 0 0 8px; }
2411
+ /* "Your move" — a CSS triangle, so it reads as a different SHAPE and not
2412
+ just a different colour. Colour alone would be invisible to a red/green
2413
+ colour-blind user, and this is the one state that needs acting on. */
2414
+ .twarn {
2415
+ width: 0; height: 0; flex: 0 0 auto;
2416
+ border-left: 5px solid transparent; border-right: 5px solid transparent;
2417
+ border-bottom: 9px solid #ffcc00;
2418
+ animation: twarnpulse 1.6s ease-in-out infinite;
2419
+ }
2420
+ @keyframes twarnpulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
2421
+ @media (prefers-reduced-motion: reduce) { .twarn { animation: none; } }
1458
2422
  #main { flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
1459
2423
  #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
1460
2424
  font-weight: 600; }
@@ -1476,6 +2440,54 @@ const APP_HTML = `<!doctype html>
1476
2440
  .modebtn.ask.on { background: #b8f240; }
1477
2441
  .modebtn.auto.on { background: #f28c4d; }
1478
2442
  .modebtn:focus-visible { outline: 2px solid #6ab0ff; outline-offset: 2px; }
2443
+ /* SPEND DIAL. Two selects rather than more pill toggles: ask/auto is a safety
2444
+ switch you flip constantly, these are set once and forgotten, and giving
2445
+ them the same visual weight would say they matter equally. Muted until
2446
+ they are off default, then they colour — an expensive tier or a live race
2447
+ should be visible at a glance, because both are spending your wallet. */
2448
+ .dial { border: 1px solid #2c2c2e; background: #131315; color: #8e8e93; font: inherit; font-size: 11px;
2449
+ border-radius: 999px; padding: 4px 8px; cursor: pointer; -webkit-appearance: none; appearance: none; }
2450
+ .dial:hover { color: #ececec; border-color: #3a3a3c; }
2451
+ .dial:focus-visible { outline: 2px solid #6ab0ff; outline-offset: 2px; }
2452
+ .dial.hot { color: #f28c4d; border-color: #f28c4d; }
2453
+ .dial.pinned { color: #4c4d5a; border-color: #1c1c1e; }
2454
+ /* WALLET. GET /wallet shipped in 1.5.22 with nothing pointing at it, so the
2455
+ 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. */
2459
+ #walletOverlay, #assetsOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.66); z-index: 1200;
2460
+ display: none; align-items: center; justify-content: center; padding: 24px; }
2461
+ #walletOverlay.show, #assetsOverlay.show { display: flex; }
2462
+ #walletBox { width: 100%; max-width: 560px; max-height: 82vh; overflow-y: auto; background: #111113;
2463
+ border: 1px solid #2c2c2e; border-radius: 16px; padding: 20px 22px; }
2464
+ #walletBox h3 { margin: 0 0 2px; font-size: 15px; font-weight: 600; }
2465
+ .wsub { color: #8e8e93; font-size: 12px; margin-bottom: 16px; }
2466
+ .wrow { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
2467
+ display: flex; align-items: center; gap: 10px; cursor: pointer; }
2468
+ .wrow:hover { border-color: #3a3a3c; background: #151517; }
2469
+ .wrow .wlab { flex: 0 0 74px; color: #6f7080; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
2470
+ /* min-width:0 or the address (one unbreakable token) widens the row and
2471
+ pushes the copy affordance out of the box — the same flexbox trap that
2472
+ once pushed the cost button off-screen. */
2473
+ .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; }
2476
+ .wrow:hover .wcopy { color: #b8f240; }
2477
+ .wbal { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
2478
+ font-size: 12px; color: #ececec; line-height: 1.7; word-break: break-word; }
2479
+ .wnote { color: #6f7080; font-size: 11px; line-height: 1.6; margin-top: 12px; }
2480
+ .wempty { color: #f28c4d; }
2481
+ /* The passphrase field. type=password so it never renders in clear, and
2482
+ autocomplete off so a browser is not tempted to remember a key that
2483
+ unlocks a public blob. */
2484
+ .wpass { width: 100%; margin-top: 4px; padding: 9px 12px; background: #0d0d0f; color: #ececec;
2485
+ border: 1px solid #2c2c2e; border-radius: 10px; font: inherit; font-size: 13px;
2486
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .12em; }
2487
+ .wpass:focus { outline: none; border-color: #6ab0ff; }
2488
+ .wgo { margin-top: 10px; width: 100%; padding: 9px; border: 0; border-radius: 10px;
2489
+ background: #b8f240; color: #000; font: inherit; font-weight: 600; cursor: pointer; }
2490
+ .wgo[disabled] { background: #2c2c2e; color: #6f7080; cursor: default; }
1479
2491
  /* Slash autocomplete. Anchored above the composer because the composer sits
1480
2492
  at the bottom of the viewport — a dropdown BELOW it would render off
1481
2493
  screen. */
@@ -1549,6 +2561,37 @@ const APP_HTML = `<!doctype html>
1549
2561
  .md-table th { background: #1c1c1e; font-weight: 600; }
1550
2562
  .bubble-images { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
1551
2563
  .bubble-images img { max-width: 160px; max-height: 160px; border-radius: 12px; display: block; }
2564
+
2565
+ /* COPY AFFORDANCES.
2566
+ Hidden until the row is hovered so they never compete with the text, but
2567
+ always in the DOM — a button that only exists on hover is unreachable by
2568
+ keyboard, so :focus-within reveals them too and each one is tabbable. */
2569
+ .row { position: relative; }
2570
+ .copybtn {
2571
+ position: absolute; top: 2px; opacity: 0; pointer-events: none;
2572
+ display: inline-flex; align-items: center; gap: 4px;
2573
+ padding: 3px 8px; border: 1px solid #3a3a3d; border-radius: 8px;
2574
+ background: #1c1c1e; color: #b9b9c0;
2575
+ font: 500 10.5px/1.4 ui-sans-serif, -apple-system, system-ui, sans-serif;
2576
+ letter-spacing: .02em; cursor: pointer;
2577
+ transition: opacity .12s ease, background .12s ease, color .12s ease;
2578
+ }
2579
+ .row.bot .copybtn { right: -2px; }
2580
+ .row.user .copybtn { left: -2px; }
2581
+ .row:hover .copybtn, .row:focus-within .copybtn { opacity: 1; pointer-events: auto; }
2582
+ .copybtn:hover { background: #2a2a2d; color: #f0f0eb; }
2583
+ .copybtn:focus-visible { opacity: 1; pointer-events: auto; outline: 2px solid #b8f240; outline-offset: 2px; }
2584
+ .copybtn.ok { background: #b8f240; border-color: #b8f240; color: #0b0b0d; }
2585
+
2586
+ /* Code blocks and RUN output get their own button, pinned inside the block —
2587
+ copying one command out of a long reply is the common case, and selecting
2588
+ it by hand in a scrolling <pre> is exactly what people fail at. */
2589
+ .md-pre, .runoutput, .runcmd { position: relative; }
2590
+ .md-pre .copybtn, .runoutput .copybtn, .runcmd .copybtn { top: 6px; right: 6px; left: auto; }
2591
+ .md-pre:hover .copybtn, .runoutput:hover .copybtn, .runcmd:hover .copybtn,
2592
+ .md-pre:focus-within .copybtn, .runoutput:focus-within .copybtn, .runcmd:focus-within .copybtn {
2593
+ opacity: 1; pointer-events: auto;
2594
+ }
1552
2595
  .runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px;
1553
2596
  max-width: 100%; min-width: 0; overflow: hidden; }
1554
2597
  .runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
@@ -1655,6 +2698,23 @@ const APP_HTML = `<!doctype html>
1655
2698
  <div id="composeFoot"><span><kbd>Tab</kbd> add</span><span><kbd>Enter</kbd> open</span></div>
1656
2699
  </div>
1657
2700
  </div>
2701
+ <div id="assetsOverlay" data-component="assets-modal">
2702
+ <div id="walletBox">
2703
+ <h3>Unlock baked assets</h3>
2704
+ <div class="wsub">ProofFront ships encrypted inside this box's image. Your passphrase decrypts it here; it is never stored.</div>
2705
+ <input class="wpass" id="assetsPass" type="password" autocomplete="off" spellcheck="false" placeholder="passphrase">
2706
+ <button class="wgo" id="assetsGo" data-testid="assets-decrypt">decrypt</button>
2707
+ <div class="wnote" id="assetsMsg"></div>
2708
+ <div class="wnote">Once unlocked the files are served like anything else in the workspace, and this box's :8080 has no authentication — the lock protects the public image, not a running box whose id someone knows.</div>
2709
+ </div>
2710
+ </div>
2711
+ <div id="walletOverlay" data-component="wallet-modal">
2712
+ <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>
2715
+ <div id="walletBody">loading…</div>
2716
+ </div>
2717
+ </div>
1658
2718
  <div id="main">
1659
2719
  <div id="chatHeader">
1660
2720
  <div id="chatHeaderId"></div>
@@ -1664,6 +2724,32 @@ const APP_HTML = `<!doctype html>
1664
2724
  <button class="modebtn auto" id="modeAuto" data-mode="auto"
1665
2725
  title="Shell commands run immediately, with no approval prompt">auto</button>
1666
2726
  </div>
2727
+ <select class="dial" id="tierSel" data-component="model-tier" aria-label="Model tier"
2728
+ title="How much to spend per turn when no model is pinned">
2729
+ <option value="cheap">cheap</option>
2730
+ <option value="medium" selected>medium</option>
2731
+ <option value="expensive">expensive</option>
2732
+ </select>
2733
+ <select class="dial" id="raceSel" data-component="model-race" aria-label="Race models"
2734
+ title="Ask N models from the tier at once, drawn at random — fastest real answer wins. You pay for every entrant.">
2735
+ <option value="0" selected>1 model</option>
2736
+ <optgroup label="first back wins">
2737
+ <option value="2">race 2</option>
2738
+ <option value="3">race 3</option>
2739
+ <option value="4">race 4</option>
2740
+ </optgroup>
2741
+ <optgroup label="judge the first k back">
2742
+ <option value="2 3">best 2 of 3</option>
2743
+ <option value="2 4">best 2 of 4</option>
2744
+ <option value="3 4">best 3 of 4</option>
2745
+ <option value="4 4">best 4 of 4</option>
2746
+ </optgroup>
2747
+ </select>
2748
+ <button class="dial" id="assetsBtn" data-component="assets-unlock"
2749
+ title="Unlock the encrypted assets baked into this box's image">assets</button>
2750
+ <button class="dial" id="walletBtn" data-component="wallet-open"
2751
+ title="This box's own wallet — deposit addresses and live balances">wallet</button>
2752
+ <button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">&#8635;</button>
1667
2753
  <button class="icon-btn" id="hudBtn">◎</button>
1668
2754
  </div>
1669
2755
  <div id="hud">
@@ -1709,28 +2795,153 @@ const APP_HTML = `<!doctype html>
1709
2795
  const log = document.getElementById('log');
1710
2796
  const inp = document.getElementById('inp');
1711
2797
  const send = document.getElementById('send');
2798
+ // WHERE OUR OWN API LIVES.
2799
+ //
2800
+ // This UI is served from TWO places: the box root (the RunPod proxy,
2801
+ // https://<pod>-8080.proxy.runpod.net/) and behind a path prefix on the site
2802
+ // (openzoo.fun/api/box/go/<pod>/). Every fetch here was root-absolute —
2803
+ // fetch(API + '/threads') — which is correct at the root and wrong behind a prefix,
2804
+ // where it resolves to openzoo.fun/threads and 404s. MEASURED in a clean
2805
+ // browser: the page loads, and the sidebar is empty forever.
2806
+ //
2807
+ // A <base href> does NOT fix this. base only affects RELATIVE urls; a leading
2808
+ // slash is root-absolute and ignores it entirely. So derive the prefix from
2809
+ // the path we were actually served under and put it in front of every call.
2810
+ // NO REGEX HERE. Backslash escapes inside the APP_HTML template literal are
2811
+ // consumed before the browser ever sees them, so /^\/api\// arrives as
2812
+ // /^/api// — "Invalid regular expression flags", and the whole script dies.
2813
+ // That is the same class of bug that shipped a dead UI in v1.5.22. Plain
2814
+ // string ops cannot be mangled that way.
2815
+ const API = location.pathname.startsWith('/api/box/go/')
2816
+ ? location.pathname.split('/').slice(0, 5).join('/')
2817
+ : '';
1712
2818
  let activeId = null;
1713
2819
  let knownThreads = [];
1714
2820
 
1715
2821
  function initials(name) { return name.slice(0, 2).toUpperCase(); }
1716
2822
 
2823
+ // SEARCH. The input existed with no handler at all — typing in it did
2824
+ // nothing, which is worse than not shipping it. Debounced because every
2825
+ // keystroke otherwise walks every message of every thread server-side.
2826
+ let searchHits = null; // null = not searching; [] = searched, no hits
2827
+ let searchTimer = null;
2828
+ const searchEl = document.getElementById('search');
2829
+ if (searchEl) {
2830
+ searchEl.addEventListener('input', () => {
2831
+ clearTimeout(searchTimer);
2832
+ const q = searchEl.value.trim();
2833
+ if (!q) { searchHits = null; loadThreads(); return; }
2834
+ searchTimer = setTimeout(async () => {
2835
+ try {
2836
+ searchHits = await (await fetch(API + '/search?q=' + encodeURIComponent(q))).json();
2837
+ } catch (e) { searchHits = []; }
2838
+ loadThreads();
2839
+ }, 180);
2840
+ });
2841
+ searchEl.addEventListener('keydown', (e) => {
2842
+ if (e.key === 'Escape') { searchEl.value = ''; searchHits = null; loadThreads(); }
2843
+ });
2844
+ }
2845
+
1717
2846
  async function loadThreads() {
1718
- const list = await (await fetch('/threads')).json();
2847
+ const list = await (await fetch(API + '/threads')).json();
2848
+ // When a search is active, show ONLY matches, ordered by hit count, and
2849
+ // replace the preview with the matching line — the point of a search is
2850
+ // seeing WHY something matched, not just that it did.
2851
+ const hitById = searchHits ? new Map(searchHits.map((h) => [h.id, h])) : null;
1719
2852
  knownThreads = list;
1720
2853
  if (!activeId && list.length) activeId = list[0].id;
1721
2854
  threadsEl.innerHTML = '';
1722
- for (const t of list) {
2855
+ const shown = hitById
2856
+ ? list.filter((t) => hitById.has(t.id))
2857
+ .sort((a, b) => (hitById.get(b.id).hits || 0) - (hitById.get(a.id).hits || 0))
2858
+ : list;
2859
+ if (hitById && !shown.length) {
2860
+ const empty = document.createElement('div');
2861
+ empty.className = 'tprev';
2862
+ empty.style.cssText = 'padding:14px 12px;color:#6f7080';
2863
+ empty.textContent = 'no messages match';
2864
+ threadsEl.appendChild(empty);
2865
+ }
2866
+ // How many bots share each root, so a header can be drawn only where there
2867
+ // is actually a project. Counted over the FULL list, not the filtered one —
2868
+ // a search that matches two of a project's nine bots should still say nine.
2869
+ const crewSize = new Map();
2870
+ for (const t of list) crewSize.set(t.rootId, (crewSize.get(t.rootId) || 0) + 1);
2871
+
2872
+ let lastRoot = null;
2873
+ for (const t of shown) {
2874
+ // PROJECT HEADER + PING ALL. /all and /ping existed but only as typed
2875
+ // commands, which meant the feature was invisible: you had to know it was
2876
+ // there. This is the button.
2877
+ if (!hitById && t.depth === 0 && (crewSize.get(t.rootId) || 1) > 1 && t.rootId !== lastRoot) {
2878
+ const n = crewSize.get(t.rootId);
2879
+ const head = document.createElement('div');
2880
+ head.className = 'prow';
2881
+ head.innerHTML = '<span class="pname">' + escapeHtml(t.name) + '</span>' +
2882
+ '<span class="pcount">' + n + '</span>';
2883
+ threadsEl.appendChild(head);
2884
+ lastRoot = t.rootId;
2885
+ }
1723
2886
  const row = document.createElement('div');
1724
2887
  row.className = 'trow' + (t.id === activeId ? ' active' : '');
2888
+ // SPAWN HIERARCHY. the parent id was always on every thread and nothing ever
2889
+ // rendered it, so fifteen agents looked like fifteen unrelated bots when
2890
+ // twelve of them were one project. Indent by depth; a subagent is visibly
2891
+ // a subagent. Capped at 4 so a deep tree cannot squeeze the name column
2892
+ // to nothing.
2893
+ if (t.depth) row.style.paddingLeft = (10 + Math.min(t.depth, 4) * 12) + 'px';
2894
+ if (t.depth) row.title = 'spawned under ' + (t.rootName || 'a parent');
1725
2895
  row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
1726
2896
  '<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
1727
- (t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
1728
- (t.status === 'thinking' ? '<div class="tdot"></div>' : '') +
2897
+ (hitById && hitById.get(t.id) && hitById.get(t.id).snippet
2898
+ ? hitById.get(t.id).snippet
2899
+ : t.awaitingUser ? 'waiting for you' : t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
2900
+ // awaitingUser WINS over thinking: a thread blocked on an approval is
2901
+ // NOT working, and showing a working indicator there is a lie that
2902
+ // quietly costs you a subagent nobody knows is stuck.
2903
+ (t.awaitingUser ? '<div class="twarn" title="Waiting for your approval"></div>'
2904
+ : t.status === 'thinking' ? '<div class="tdot"></div>' : '') +
2905
+ // PING ALL BELONGS TO WHOEVER HAS A CREW. It used to sit only on the
2906
+ // project header, so a mid-level owner could not reach its own
2907
+ // subagents without retyping — and pressing it addressed the WHOLE
2908
+ // project, cousins included. Now every thread with descendants gets
2909
+ // one, scoped to its own branch.
2910
+ (t.kids ? '<button class="pingall trow-ping" data-testid="ping-all" title="Message all '
2911
+ + t.kids + ' bot(s) below ' + escapeHtml(t.name) + '">\u21f2 ' + t.kids + '</button>' : '') +
1729
2912
  '<button class="tclose" title="Remove">✕</button>';
1730
- row.addEventListener('click', () => { activeId = t.id; render(); });
2913
+ row.addEventListener('click', () => {
2914
+ activeId = t.id;
2915
+ render();
2916
+ // Selecting a bot means you intend to talk to it. Landing focus in the
2917
+ // composer saves a second click every single time, and on mobile it is
2918
+ // what raises the keyboard at all.
2919
+ requestAnimationFrame(() => { try { inp.focus(); } catch (e) {} });
2920
+ });
2921
+ const pingBtn = row.querySelector('.trow-ping');
2922
+ if (pingBtn) {
2923
+ pingBtn.addEventListener('click', async (e) => {
2924
+ e.stopPropagation();
2925
+ const msg = prompt('Send to all ' + t.kids + ' bot(s) below ' + t.name + ':');
2926
+ // Empty is a cancel — sending "" would spend a paid turn on every
2927
+ // bot in the branch for nothing.
2928
+ if (msg === null || !msg.trim()) return;
2929
+ pingBtn.disabled = true;
2930
+ const was = pingBtn.textContent;
2931
+ pingBtn.textContent = '…';
2932
+ try {
2933
+ // Routed through THIS thread, so /all scopes to its own subtree.
2934
+ await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
2935
+ body: JSON.stringify({ threadId: t.id, task: '/all ' + msg.trim() }) });
2936
+ pingBtn.textContent = 'sent';
2937
+ } catch (err) { pingBtn.textContent = 'failed'; }
2938
+ setTimeout(() => { pingBtn.disabled = false; pingBtn.textContent = was; }, 1500);
2939
+ await loadThreads();
2940
+ });
2941
+ }
1731
2942
  row.querySelector('.tclose').addEventListener('click', async (e) => {
1732
2943
  e.stopPropagation();
1733
- await fetch('/threads/' + t.id, { method: 'DELETE' });
2944
+ await fetch(API + '/threads/' + t.id, { method: 'DELETE' });
1734
2945
  if (activeId === t.id) activeId = null;
1735
2946
  await loadThreads();
1736
2947
  if (activeId) render();
@@ -1741,7 +2952,7 @@ const APP_HTML = `<!doctype html>
1741
2952
 
1742
2953
  async function loadActiveMessages() {
1743
2954
  if (!activeId) return null;
1744
- return await (await fetch('/threads/' + activeId)).json();
2955
+ return await (await fetch(API + '/threads/' + activeId)).json();
1745
2956
  }
1746
2957
 
1747
2958
  function renderHeader(t) {
@@ -1750,8 +2961,171 @@ const APP_HTML = `<!doctype html>
1750
2961
  '<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
1751
2962
  '">' + escapeHtml(t.dir || '') + ' · type /dir &lt;path&gt; to change</div></div>';
1752
2963
  setModeButtons(t.runMode || 'ask');
2964
+ setDials(t);
1753
2965
  }
1754
2966
 
2967
+ // Same rule as the mode toggle: reflect the SERVER's value, never track it
2968
+ // client-side. Both dials are also settable by typing /tier and /race, so a
2969
+ // local copy would drift the moment anyone used the chat path.
2970
+ function setDials(t) {
2971
+ const tierSel = document.getElementById('tierSel');
2972
+ const raceSel = document.getElementById('raceSel');
2973
+ if (!tierSel || !raceSel) return;
2974
+ tierSel.value = t.tier || 'medium';
2975
+ raceSel.value = (t.race || 0) < 2 ? '0'
2976
+ : ((t.raceNeed || 1) > 1 ? t.raceNeed + ' ' + t.race : String(t.race));
2977
+ // A pinned /model makes BOTH dials inert. Showing them live while they do
2978
+ // nothing is the kind of lie that costs an hour — grey them out and say why
2979
+ // on hover, rather than letting someone set "expensive" and wonder why the
2980
+ // answers never changed.
2981
+ const pinned = Boolean(t.model);
2982
+ for (const el of [tierSel, raceSel]) {
2983
+ el.disabled = pinned;
2984
+ el.className = 'dial' + (pinned ? ' pinned' : '');
2985
+ el.title = pinned
2986
+ ? t.model + ' is pinned on this thread with /model, so the tier and race are ignored. Run /model default to free them.'
2987
+ : el === tierSel
2988
+ ? 'How much to spend per turn when no model is pinned'
2989
+ : 'Ask N models from the tier at once, drawn at random — fastest real answer wins. You pay for every entrant.';
2990
+ }
2991
+ if (!pinned && (t.tier === 'expensive' || (t.race || 0) >= 2)) {
2992
+ if (t.tier === 'expensive') tierSel.className = 'dial hot';
2993
+ if ((t.race || 0) >= 2) raceSel.className = 'dial hot';
2994
+ }
2995
+ }
2996
+
2997
+ async function setDial(cmd, value) {
2998
+ if (!activeId) return;
2999
+ // Reuses the SAME slash-command path, so there is one implementation of the
3000
+ // rule rather than a second that can disagree with it.
3001
+ await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
3002
+ body: JSON.stringify({ threadId: activeId, task: '/' + cmd + ' ' + value }) });
3003
+ await loadThreads();
3004
+ await render();
3005
+ }
3006
+ // WALLET MODAL.
3007
+ //
3008
+ // Public addresses and balances only — /wallet proxies the proxy's own
3009
+ // endpoint, which never exposes a key. Nothing here can move funds, and it
3010
+ // must stay that way: this UI is served on a box reachable from a public
3011
+ // *.proxy.runpod.net URL.
3012
+ const walletOverlay = document.getElementById('walletOverlay');
3013
+ const walletBody = document.getElementById('walletBody');
3014
+ function walletRow(label, addr) {
3015
+ const row = document.createElement('div');
3016
+ row.className = 'wrow';
3017
+ row.title = 'Click to copy';
3018
+ const l = document.createElement('div'); l.className = 'wlab'; l.textContent = label;
3019
+ const a = document.createElement('div'); a.className = 'waddr'; a.textContent = addr;
3020
+ const c = document.createElement('div'); c.className = 'wcopy'; c.textContent = 'copy';
3021
+ row.append(l, a, c);
3022
+ // The whole row, not a 20px target. Copying a deposit address by hand is
3023
+ // how funds go to the wrong chain.
3024
+ row.addEventListener('click', async () => {
3025
+ const ok = await copyText(addr);
3026
+ c.textContent = ok ? 'copied' : 'select it';
3027
+ setTimeout(() => { c.textContent = 'copy'; }, 1400);
3028
+ });
3029
+ return row;
3030
+ }
3031
+ async function openWallet() {
3032
+ walletOverlay.classList.add('show');
3033
+ walletBody.textContent = 'loading…';
3034
+ let w = null;
3035
+ try {
3036
+ const r = await fetch(API + '/wallet');
3037
+ // fetch does NOT reject on 4xx/5xx, and an older proxy returns an error
3038
+ // body that parses fine into undefined fields — check ok AND the fields.
3039
+ w = r.ok ? await r.json() : null;
3040
+ } catch (e) { w = null; }
3041
+ walletBody.innerHTML = '';
3042
+ if (!w || (!w.solana && !w.evm)) {
3043
+ const p = document.createElement('div');
3044
+ p.className = 'wnote wempty';
3045
+ p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds.';
3046
+ walletBody.appendChild(p);
3047
+ return;
3048
+ }
3049
+ if (w.solana) walletBody.appendChild(walletRow('Solana', w.solana));
3050
+ if (w.evm) walletBody.appendChild(walletRow('Base / RH', w.evm));
3051
+ if (w.balances) {
3052
+ const b = document.createElement('div');
3053
+ b.className = 'wbal';
3054
+ b.textContent = w.balances;
3055
+ walletBody.appendChild(b);
3056
+ }
3057
+ const note = document.createElement('div');
3058
+ note.className = 'wnote';
3059
+ // funded === false is the genuinely-empty case. Undefined means the proxy
3060
+ // did not say, and guessing "empty" there would send someone to top up a
3061
+ // wallet that is fine.
3062
+ note.textContent = w.funded === false
3063
+ ? 'This wallet is EMPTY — calls will fail with HTTP 402 until it is funded. ' + (w.funding || '')
3064
+ : (w.funding || '');
3065
+ if (w.funded === false) note.classList.add('wempty');
3066
+ if (note.textContent.trim()) walletBody.appendChild(note);
3067
+ }
3068
+ // ASSETS UNLOCK. The passphrase lives in the input and in one fetch body and
3069
+ // nowhere else — not in a variable that outlives the call, not in
3070
+ // localStorage, and the field is cleared the moment it succeeds.
3071
+ const assetsOverlay = document.getElementById('assetsOverlay');
3072
+ const assetsPass = document.getElementById('assetsPass');
3073
+ const assetsGo = document.getElementById('assetsGo');
3074
+ const assetsMsg = document.getElementById('assetsMsg');
3075
+ async function unlockAssets() {
3076
+ const pass = assetsPass.value;
3077
+ if (!pass) { assetsMsg.textContent = 'enter the passphrase'; return; }
3078
+ assetsGo.disabled = true;
3079
+ assetsGo.textContent = 'decrypting…';
3080
+ assetsMsg.textContent = 'this takes a minute — 800MB';
3081
+ try {
3082
+ const r = await fetch(API + '/decrypt-assets', {
3083
+ method: 'POST', headers: { 'content-type': 'application/json' },
3084
+ body: JSON.stringify({ pass }),
3085
+ });
3086
+ const j = await r.json().catch(() => ({}));
3087
+ if (j.ok) {
3088
+ assetsPass.value = ''; // do not leave it sitting in the DOM
3089
+ assetsMsg.textContent = j.already ? 'already unlocked' : ('unlocked — ' + j.files + ' files in /workspace/prooffront');
3090
+ assetsMsg.className = 'wnote';
3091
+ } else {
3092
+ assetsMsg.textContent = j.error || ('failed (HTTP ' + r.status + ')');
3093
+ assetsMsg.className = 'wnote wempty';
3094
+ }
3095
+ } catch (e) {
3096
+ assetsMsg.textContent = 'could not reach the box';
3097
+ assetsMsg.className = 'wnote wempty';
3098
+ }
3099
+ assetsGo.disabled = false;
3100
+ assetsGo.textContent = 'decrypt';
3101
+ }
3102
+ assetsGo.addEventListener('click', unlockAssets);
3103
+ assetsPass.addEventListener('keydown', (e) => { if (e.key === 'Enter') unlockAssets(); });
3104
+ document.getElementById('assetsBtn').addEventListener('click', () => {
3105
+ assetsOverlay.classList.add('show');
3106
+ assetsMsg.textContent = '';
3107
+ assetsMsg.className = 'wnote';
3108
+ requestAnimationFrame(() => { try { assetsPass.focus(); } catch (e) {} });
3109
+ });
3110
+ assetsOverlay.addEventListener('click', (e) => {
3111
+ // Clear on dismiss so a passphrase never lingers behind a closed modal.
3112
+ if (e.target === assetsOverlay) { assetsPass.value = ''; assetsOverlay.classList.remove('show'); }
3113
+ });
3114
+
3115
+ document.getElementById('walletBtn').addEventListener('click', openWallet);
3116
+ // Click-outside and Escape both close it — a modal you can only dismiss one
3117
+ // way is a modal people get stuck in.
3118
+ walletOverlay.addEventListener('click', (e) => {
3119
+ if (e.target === walletOverlay) walletOverlay.classList.remove('show');
3120
+ });
3121
+ document.addEventListener('keydown', (e) => {
3122
+ if (e.key === 'Escape' && walletOverlay.classList.contains('show')) walletOverlay.classList.remove('show');
3123
+ if (e.key === 'Escape' && assetsOverlay.classList.contains('show')) { assetsPass.value = ''; assetsOverlay.classList.remove('show'); }
3124
+ });
3125
+
3126
+ document.getElementById('tierSel').addEventListener('change', (e) => setDial('tier', e.target.value));
3127
+ document.getElementById('raceSel').addEventListener('change', (e) => setDial('race', e.target.value));
3128
+
1755
3129
  // The toggle reflects the SERVER's value rather than local state — the mode
1756
3130
  // is per-thread and also settable by typing "/mode auto", so anything that
1757
3131
  // tracked it client-side would drift the moment either path was used.
@@ -1765,7 +3139,7 @@ const APP_HTML = `<!doctype html>
1765
3139
  setModeButtons(mode); // optimistic: the click should feel instant
1766
3140
  // Reuses the SAME "/mode" path the chat command takes, so there is one
1767
3141
  // implementation of the rule rather than a second one that can disagree.
1768
- await fetch('/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
3142
+ await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
1769
3143
  body: JSON.stringify({ threadId: activeId, task: '/mode ' + mode }) });
1770
3144
  await loadThreads(); // refresh runMode + the confirmation line /mode appends
1771
3145
  await render();
@@ -1792,6 +3166,63 @@ const APP_HTML = `<!doctype html>
1792
3166
  // Block-level markdown: fenced code, tables, headings, lists. Models answer
1793
3167
  // in markdown by default, and rendering it as literal "## " and "| --- |"
1794
3168
  // made every structured answer unreadable.
3169
+ /* Copy the given text, and say so. Returns true on success.
3170
+
3171
+ navigator.clipboard is NOT always available: it requires a secure context,
3172
+ and grokui binds 0.0.0.0 inside a box, so reaching it by LAN IP (rather
3173
+ than localhost or the RunPod https proxy) is plain http — where the API is
3174
+ simply undefined. Falling back to execCommand keeps copy working there
3175
+ instead of silently doing nothing, which is the worst outcome for a button
3176
+ whose entire job is invisible. */
3177
+ async function copyText(text) {
3178
+ try {
3179
+ if (navigator.clipboard && window.isSecureContext) {
3180
+ await navigator.clipboard.writeText(text);
3181
+ return true;
3182
+ }
3183
+ } catch (e) { /* fall through to the legacy path */ }
3184
+ try {
3185
+ const ta = document.createElement('textarea');
3186
+ ta.value = text;
3187
+ // Off-screen but focusable: display:none or visibility:hidden make
3188
+ // execCommand('copy') a no-op in several browsers.
3189
+ ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0';
3190
+ ta.setAttribute('readonly', '');
3191
+ document.body.appendChild(ta);
3192
+ ta.select();
3193
+ ta.setSelectionRange(0, ta.value.length); // iOS needs the explicit range
3194
+ const ok = document.execCommand('copy');
3195
+ document.body.removeChild(ta);
3196
+ return ok;
3197
+ } catch (e) { return false; }
3198
+ }
3199
+
3200
+ /* A copy button bound to a getter, not a value — RUN output and streaming
3201
+ replies grow after the button is created, and a captured string would copy
3202
+ a stale prefix. */
3203
+ function copyBtn(getText, label) {
3204
+ const b = document.createElement('button');
3205
+ b.className = 'copybtn';
3206
+ b.type = 'button';
3207
+ b.textContent = label || 'copy';
3208
+ b.title = 'Copy to clipboard';
3209
+ b.addEventListener('click', async (e) => {
3210
+ e.stopPropagation();
3211
+ const ok = await copyText(String(getText() ?? ''));
3212
+ b.textContent = ok ? 'copied' : 'press ⌘C';
3213
+ b.classList.toggle('ok', ok);
3214
+ if (!ok) {
3215
+ // Last resort: select it so the keyboard shortcut works.
3216
+ const sel = window.getSelection();
3217
+ const r = document.createRange();
3218
+ r.selectNodeContents(b.closest('.bubble, .runcard, .md-pre') || b);
3219
+ sel.removeAllRanges(); sel.addRange(r);
3220
+ }
3221
+ setTimeout(() => { b.textContent = label || 'copy'; b.classList.remove('ok'); }, 1400);
3222
+ });
3223
+ return b;
3224
+ }
3225
+
1795
3226
  function renderMentions(text) {
1796
3227
  const fences = [];
1797
3228
  const src = String(text).replace(/\`\`\`([\\w-]*)\\n?([\\s\\S]*?)\`\`\`/g, (m, lang, code) => {
@@ -1866,6 +3297,9 @@ const APP_HTML = `<!doctype html>
1866
3297
  const cmdEl = document.createElement('div');
1867
3298
  cmdEl.className = 'runcmd';
1868
3299
  cmdEl.textContent = '$ ' + text;
3300
+ // Copy WITHOUT the '$ ' prompt — pasting that into a shell is a syntax
3301
+ // error, and this is the single most re-run thing in the UI.
3302
+ cmdEl.appendChild(copyBtn(() => text, 'copy'));
1869
3303
  card.appendChild(cmdEl);
1870
3304
  if (run.status === 'pending') {
1871
3305
  const actions = document.createElement('div');
@@ -1878,12 +3312,12 @@ const APP_HTML = `<!doctype html>
1878
3312
  deny.textContent = 'Deny';
1879
3313
  approve.addEventListener('click', async () => {
1880
3314
  approve.disabled = true; deny.disabled = true;
1881
- await fetch('/threads/' + activeId + '/run/' + run.id + '/approve', { method: 'POST' });
3315
+ await fetch(API + '/threads/' + activeId + '/run/' + run.id + '/approve', { method: 'POST' });
1882
3316
  render();
1883
3317
  });
1884
3318
  deny.addEventListener('click', async () => {
1885
3319
  approve.disabled = true; deny.disabled = true;
1886
- await fetch('/threads/' + activeId + '/run/' + run.id + '/deny', { method: 'POST' });
3320
+ await fetch(API + '/threads/' + activeId + '/run/' + run.id + '/deny', { method: 'POST' });
1887
3321
  render();
1888
3322
  });
1889
3323
  actions.appendChild(approve);
@@ -1898,6 +3332,7 @@ const APP_HTML = `<!doctype html>
1898
3332
  const out = document.createElement('pre');
1899
3333
  out.className = 'runoutput';
1900
3334
  out.textContent = run.output;
3335
+ out.appendChild(copyBtn(() => run.output, 'copy'));
1901
3336
  card.appendChild(out);
1902
3337
  }
1903
3338
  }
@@ -1919,6 +3354,20 @@ const APP_HTML = `<!doctype html>
1919
3354
  textEl.innerHTML = renderMentions(text);
1920
3355
  bubble.appendChild(textEl);
1921
3356
  row.appendChild(bubble);
3357
+ // Copy the message SOURCE, not rendered HTML — markdown, code fences and
3358
+ // directive lines are what people want back; innerText drops fences and
3359
+ // mangles indentation.
3360
+ row.appendChild(copyBtn(() => text, 'copy'));
3361
+ for (const pre of textEl.querySelectorAll('.md-pre, pre')) {
3362
+ pre.appendChild(copyBtn(() => {
3363
+ // No regex here on purpose: this string lives inside a template
3364
+ // literal, so an escape sequence is eaten before the browser sees
3365
+ // it. A literal newline inside /.../ is a SyntaxError that kills
3366
+ // the entire script — the sidebar renders empty and nothing works.
3367
+ const t = pre.innerText;
3368
+ return t.endsWith('copy') ? t.slice(0, -4).replace(/\\s+$/, '') : t;
3369
+ }, 'copy'));
3370
+ }
1922
3371
  }
1923
3372
  log.appendChild(row);
1924
3373
  }
@@ -2046,7 +3495,7 @@ const APP_HTML = `<!doctype html>
2046
3495
  pendingFiles = [];
2047
3496
  pendingImages = [];
2048
3497
  renderAttachChips();
2049
- await fetch('/drive', {
3498
+ await fetch(API + '/drive', {
2050
3499
  method: 'POST', headers: { 'content-type': 'application/json' },
2051
3500
  body: JSON.stringify({ threadId: activeId, task: full, images }),
2052
3501
  });
@@ -2062,7 +3511,7 @@ const APP_HTML = `<!doctype html>
2062
3511
  let slashCmds = [];
2063
3512
  let slashHits = [];
2064
3513
  let slashSel = 0;
2065
- fetch('/slash-commands').then((r) => r.json()).then((c) => { slashCmds = c; }).catch(() => {});
3514
+ fetch(API + '/slash-commands').then((r) => r.json()).then((c) => { slashCmds = c; }).catch(() => {});
2066
3515
 
2067
3516
  function slashOpen() { return slashMenu.classList.contains('show'); }
2068
3517
  function renderSlash() {
@@ -2175,7 +3624,7 @@ const APP_HTML = `<!doctype html>
2175
3624
  createRow.addEventListener('click', async () => {
2176
3625
  const name = composeInp.value.trim() || prompt('Bot name?');
2177
3626
  if (!name) return;
2178
- const t = await (await fetch('/threads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })).json();
3627
+ const t = await (await fetch(API + '/threads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })).json();
2179
3628
  activeId = t.id;
2180
3629
  closeCompose();
2181
3630
  await loadThreads(); await render();
@@ -2197,7 +3646,7 @@ const APP_HTML = `<!doctype html>
2197
3646
  if (t) { activeId = t.id; closeCompose(); await loadThreads(); await render(); return; }
2198
3647
  }
2199
3648
  if (composeSel.length > 1) {
2200
- const t = await (await fetch('/threads/group', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names: composeSel.map((c) => c.name) }) })).json();
3649
+ const t = await (await fetch(API + '/threads/group', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names: composeSel.map((c) => c.name) }) })).json();
2201
3650
  activeId = t.id;
2202
3651
  closeCompose();
2203
3652
  await loadThreads(); await render();
@@ -2225,6 +3674,48 @@ const APP_HTML = `<!doctype html>
2225
3674
  setInterval(tick, 1200);
2226
3675
 
2227
3676
  // --- cost HUD (ported from the Hammerspoon menu-bar widget, same source) ---
3677
+ // RESTART GROKUI. box-boot supervises this process, so exiting IS the
3678
+ // restart. Useful when a box is wedged or has a newer grokui on disk: it
3679
+ // re-execs in seconds instead of costing a box respawn and a 349MB pull.
3680
+ // It cannot change the version baked into the IMAGE — only the site's spawn
3681
+ // path can — so it says restart, not update. Promising an upgrade it cannot
3682
+ // deliver is how a UI teaches people to distrust it.
3683
+ // Cmd/Ctrl+K -> search, the shortcut every chat app trains you to expect.
3684
+ // Escape returns focus to the composer instead of leaving you stranded in
3685
+ // a box you just cleared. Bound on keydown at the document so it works no
3686
+ // matter which pane has focus.
3687
+ document.addEventListener('keydown', (e) => {
3688
+ const k = (e.key || '').toLowerCase();
3689
+ if ((e.metaKey || e.ctrlKey) && k === 'k') {
3690
+ e.preventDefault();
3691
+ const el = document.getElementById('search');
3692
+ if (el) { el.focus(); el.select(); }
3693
+ return;
3694
+ }
3695
+ // Cmd/Ctrl+Enter sends from anywhere — useful when focus drifted into a
3696
+ // RUN card or a copy button mid-thought.
3697
+ if ((e.metaKey || e.ctrlKey) && k === 'enter') {
3698
+ e.preventDefault();
3699
+ try { submit(); } catch (err) { /* not ready */ }
3700
+ }
3701
+ });
3702
+
3703
+ const reloadBtn = document.getElementById('reloadBtn');
3704
+ if (reloadBtn) {
3705
+ reloadBtn.addEventListener('click', async () => {
3706
+ reloadBtn.disabled = true;
3707
+ reloadBtn.textContent = '\u2026';
3708
+ try { await fetch(API + '/restart', { method: 'POST' }); } catch (e) { /* exit races the reply */ }
3709
+ // Poll until it answers again — a fixed timeout either reloads into a
3710
+ // dead port or waits long after it is already back.
3711
+ for (let i = 0; i < 40; i++) {
3712
+ await new Promise((r) => setTimeout(r, 500));
3713
+ try { const r = await fetch(API + '/threads', { cache: 'no-store' }); if (r.ok) break; } catch (e) { /* still down */ }
3714
+ }
3715
+ location.reload();
3716
+ });
3717
+ }
3718
+
2228
3719
  const hudBtn = document.getElementById('hudBtn');
2229
3720
  const hud = document.getElementById('hud');
2230
3721
  function usd(n) {
@@ -2238,7 +3729,7 @@ const APP_HTML = `<!doctype html>
2238
3729
  // fetched server-side by US (see /hud-summary below) — a renderer fetch
2239
3730
  // straight to localhost:8402 would work fine, but routing it through
2240
3731
  // our own backend keeps one fetch path if that ever needs to change.
2241
- const you = await (await fetch('/hud-summary')).json();
3732
+ const you = await (await fetch(API + '/hud-summary')).json();
2242
3733
  const spent = Number(you.spentUsd) || 0;
2243
3734
  const cogs = Number(you.cogsUsd) || 0;
2244
3735
  const direct = Number(you.directUsd) || 0;
@@ -2293,6 +3784,111 @@ const APP_HTML = `<!doctype html>
2293
3784
  </body></html>`;
2294
3785
 
2295
3786
  const server = http.createServer((req, res) => {
3787
+ // Deposit addresses for the wallet THIS box pays from. Server-side fetch for
3788
+ // the same reason /hud-summary is: the browser cannot reach 127.0.0.1:8402
3789
+ // inside the box. The proxy only ever returns PUBLIC addresses — the key
3790
+ // never leaves /root/.openzoo/wallet.json.
3791
+ if (req.method === 'GET' && req.url === '/wallet') {
3792
+ (async () => {
3793
+ let w = { error: 'proxy unreachable' };
3794
+ try { w = await (await fetch(`${PROXY}/wallet`)).json(); }
3795
+ catch { /* proxy not up yet — say so rather than render an empty modal */ }
3796
+ res.writeHead(200, { 'content-type': 'application/json' });
3797
+ res.end(JSON.stringify(w));
3798
+ })();
3799
+ return;
3800
+ }
3801
+
3802
+ // UNLOCK THE BAKED ASSETS. The image ships ProofFront as ciphertext; this is
3803
+ // where the operator supplies the passphrase that opens it.
3804
+ //
3805
+ // The passphrase is used and dropped: passed to openssl via an ENV VAR (never
3806
+ // argv — a command line is world-readable in /proc), never written to disk,
3807
+ // never logged, never stored on the thread. A wrong one is reported as wrong
3808
+ // and nothing else happens.
3809
+ //
3810
+ // SAY WHAT THIS DOES: once decrypted the files are served by /api/files and
3811
+ // /site like anything else in the workspace, and :8080 has no authentication.
3812
+ // The lock protects the IMAGE, which is public. It does not protect a running
3813
+ // box from anyone who knows its pod id.
3814
+ if (req.method === 'POST' && req.url === '/decrypt-assets') {
3815
+ const chunks = [];
3816
+ req.on('data', (d) => chunks.push(d));
3817
+ req.on('end', () => {
3818
+ let pass = '';
3819
+ try { pass = String(JSON.parse(Buffer.concat(chunks).toString('utf8')).pass || ''); } catch { /* ignore */ }
3820
+ const done = (code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
3821
+ if (!pass) return done(400, { ok: false, error: 'no passphrase' });
3822
+ if (!existsSync('/opt/prooffront.enc')) return done(404, { ok: false, error: 'this image has no encrypted assets baked in' });
3823
+ if (existsSync('/workspace/prooffront')) return done(200, { ok: true, already: true, note: 'already unlocked' });
3824
+ // Same shape as box-boot: decrypt to temp, verify it is really a zip,
3825
+ // then unpack and swap. Never unzip unverified plaintext into /workspace.
3826
+ const script = 'set -e; '
3827
+ + 'openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -in /opt/prooffront.enc -out /tmp/pf.zip -pass env:OZ_PF_PASS 2>/dev/null; '
3828
+ + 'head -c 2 /tmp/pf.zip | grep -q PK; '
3829
+ + 'rm -rf /workspace/.pf.tmp; mkdir -p /workspace/.pf.tmp; '
3830
+ + 'unzip -q /tmp/pf.zip -d /workspace/.pf.tmp; '
3831
+ + 'mv /workspace/.pf.tmp /workspace/prooffront; '
3832
+ + 'rm -f /tmp/pf.zip; '
3833
+ + 'find /workspace/prooffront -type f | wc -l';
3834
+ execFile('bash', ['-lc', script], { env: { ...process.env, OZ_PF_PASS: pass }, timeout: 600000, maxBuffer: 1 << 20 },
3835
+ (err, stdout) => {
3836
+ // Wipe the copy we were handed as soon as openssl is done with it.
3837
+ pass = '';
3838
+ if (err) return done(200, { ok: false, error: 'wrong passphrase, or the blob is corrupt' });
3839
+ done(200, { ok: true, files: Number(String(stdout).trim()) || 0 });
3840
+ });
3841
+ });
3842
+ return;
3843
+ }
3844
+
3845
+ // Restart grokui in place — and ACTUALLY PICK UP THE NEW BUILD.
3846
+ //
3847
+ // Exiting is the restart: on a production box, box-server's ensureOz() poll
3848
+ // notices :4173 is closed and relaunches. (box-boot.sh does NOT supervise
3849
+ // grokui when OZ_UI_B64 is set — box-server owns it, and two supervisors is
3850
+ // the EADDRINUSE bug this file was already fighting.)
3851
+ //
3852
+ // But relaunching alone upgrades NOTHING, and that was the real bug. It runs
3853
+ // /workspace/.grokui/grokui.mjs, and box-server's seedFromImage() overwrites
3854
+ // that only when it is MISSING, under 1KB, or a saved 429 page — a perfectly
3855
+ // valid OLD copy is never replaced. /workspace is a persistent network
3856
+ // volume, so the file a box seeded on its very first boot survived every
3857
+ // restart and every fresh image pull, forever. Symptom: a new image lands,
3858
+ // the box restarts fine, and the UI is byte-identical to the day it spawned.
3859
+ //
3860
+ // So copy the baked build over the workspace copy HERE, before exiting.
3861
+ // Doing it in grokui rather than only in box-server is deliberate: box-server
3862
+ // is injected at spawn, so a fix there reaches new boxes only. This reaches
3863
+ // any box whose grokui can still serve one request.
3864
+ if (req.method === 'POST' && req.url === '/restart') {
3865
+ let seeded = null;
3866
+ try {
3867
+ // Only when they actually DIFFER — an unconditional copy would rewrite
3868
+ // the file on every restart and make "did it upgrade?" unanswerable.
3869
+ for (const f of ['grokui.mjs', 'podagent.mjs']) {
3870
+ const baked = `/opt/grokui/${f}`;
3871
+ const live = `/workspace/.grokui/${f}`;
3872
+ if (!existsSync(baked)) continue;
3873
+ const a = statSync(baked);
3874
+ const b = existsSync(live) ? statSync(live) : null;
3875
+ if (!b || a.size !== b.size) {
3876
+ mkdirSync('/workspace/.grokui', { recursive: true });
3877
+ copyFileSync(baked, live);
3878
+ seeded = (seeded || 0) + 1;
3879
+ }
3880
+ }
3881
+ } catch (e) {
3882
+ // A read-only or missing /opt is a plain `docker run`, not a box. Restart
3883
+ // is still worth doing; just do not claim an upgrade that did not happen.
3884
+ seeded = null;
3885
+ }
3886
+ res.writeHead(200, { 'content-type': 'application/json' });
3887
+ res.end(JSON.stringify({ ok: true, restarting: true, upgraded: seeded || 0 }));
3888
+ setTimeout(() => process.exit(0), 150);
3889
+ return;
3890
+ }
3891
+
2296
3892
  if (req.method === 'GET' && req.url === '/hud-summary') {
2297
3893
  (async () => {
2298
3894
  let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0 };
@@ -2338,9 +3934,41 @@ const server = http.createServer((req, res) => {
2338
3934
  res.end(JSON.stringify(SLASH_COMMANDS));
2339
3935
  return;
2340
3936
  }
3937
+ // Search ACROSS MESSAGE BODIES, not just names. The sidebar only carries a
3938
+ // 60-char preview, so a client-side filter can only match what is already on
3939
+ // screen — useless for "which bot did the tetris contract". The server has
3940
+ // every message, so it does the work and returns a hit count plus the
3941
+ // matching line, and the client renders that instead of the preview.
3942
+ if (req.method === 'GET' && req.url.startsWith('/search')) {
3943
+ const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim().toLowerCase();
3944
+ res.writeHead(200, { 'content-type': 'application/json' });
3945
+ if (!q) { res.end('[]'); return; }
3946
+ const out = [];
3947
+ for (const t of threads.values()) {
3948
+ let hits = 0;
3949
+ let snippet = '';
3950
+ for (const h of t.history) {
3951
+ const text = String(h.text || '');
3952
+ if (!text.toLowerCase().includes(q)) continue;
3953
+ hits += 1;
3954
+ if (!snippet) {
3955
+ // Centre the window on the match so the term is visible, rather than
3956
+ // returning the first 60 chars of a message that matched at char 900.
3957
+ const at = text.toLowerCase().indexOf(q);
3958
+ const from = Math.max(0, at - 24);
3959
+ snippet = (from ? '…' : '') + text.slice(from, from + 90).replace(/\s+/g, ' ');
3960
+ }
3961
+ }
3962
+ const nameHit = t.name.toLowerCase().includes(q);
3963
+ if (hits || nameHit) out.push({ id: t.id, name: t.name, color: t.color, hits, snippet });
3964
+ }
3965
+ out.sort((a, b) => b.hits - a.hits);
3966
+ res.end(JSON.stringify(out));
3967
+ return;
3968
+ }
2341
3969
  if (req.method === 'GET' && req.url === '/threads') {
2342
3970
  res.writeHead(200, { 'content-type': 'application/json' });
2343
- res.end(JSON.stringify([...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).map(threadSummary)));
3971
+ res.end(JSON.stringify(orderedThreads().map(threadSummary)));
2344
3972
  return;
2345
3973
  }
2346
3974
  if (req.method === 'GET' && req.url.startsWith('/threads/')) {