openzoo 0.49.8 → 0.49.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/grokui.mjs CHANGED
@@ -12,8 +12,10 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSyn
12
12
  import { cpus, homedir } from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES, normalizeTier } from './podagent.mjs';
15
+ import { routeChatBody } from './modelroute.js';
15
16
  import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS, summarizeRaceFailures, RACE_EVERY_FAILED } from './livestatus.js';
16
17
  import { creditBalance } from './info.js';
18
+ import { guardFindCwd } from './runguard.js';
17
19
  import {
18
20
  SUBSCRIPTIONS_PAGE,
19
21
  saveSubscription, clearSubscription,
@@ -31,6 +33,10 @@ import {
31
33
  extractBashPaths,
32
34
  } from './spill.js';
33
35
  import { BIND_MIN_CHARS } from './hrr.js';
36
+ import { stripThinkTags, takeThink } from './think.js';
37
+ import {
38
+ runClaudeCode, setClaudeRunnerForTest, toolStatusLine, CLAUDE_MISSING,
39
+ } from './claudecode.js';
34
40
 
35
41
  const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
36
42
  // BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
@@ -95,18 +101,11 @@ function stripBasePrefix(base, spec) {
95
101
  }
96
102
 
97
103
  /**
98
- * Reasoning models leak `<think>…</think>` / `<thinking>…` into the visible
99
- * reply. MEASURED live on thread tetris: the user-visible bubble contained
100
- * the raw tags, and the next turn sent them back to the model. Strip complete
101
- * blocks, an unclosed opener (live stream), and stray closers.
104
+ * Reasoning used to leak `<think>…</think>` into the visible bubble and then
105
+ * get sent back to the model. Visible text is still stripped (see takeThink);
106
+ * the plaintext is kept on the history row as `thinking` and folded in the
107
+ * canvas. Encrypted blobs never become a chip that lives in lib/think.js.
102
108
  */
103
- function stripThinkTags(text) {
104
- let s = String(text ?? '');
105
- s = s.replace(/<think(?:ing)?\b[^>]*>[\s\S]*?<\/think(?:ing)?>/gi, '');
106
- s = s.replace(/<think(?:ing)?\b[^>]*>[\s\S]*$/i, '');
107
- s = s.replace(/<\/think(?:ing)?>/gi, '');
108
- return s.replace(/^\n+|\n+$/g, '').trim();
109
- }
110
109
  const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
111
110
  mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
112
111
  jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
@@ -211,6 +210,23 @@ function colorFor(name) {
211
210
  return PALETTE[h % PALETTE.length];
212
211
  }
213
212
 
213
+ // Frozen SYSTEM is baked into old threads at creation. This string is also
214
+ // injected every turn (extras + AUTO_DIRECTIVE) so live Auto cannot "shell
215
+ // the proxy" just because the thread was born with the old "you CAN curl
216
+ // :8402" paragraph. Site-check curls of localhost:8080 stay allowed.
217
+ const CHAT_NOT_PROXY = `You already ARE the chat. Never RUN curl, wget, or fetch against localhost:8402 or
218
+ /v1/chat/completions — that dumps another model's JSON into the canvas and pays twice.
219
+ Orange Auto = WRITE / READ / RUN / GLOB for real work, not "shell the proxy."
220
+ Never mkdir empty trees and declare DONE — WRITE the files.`;
221
+ const PROXY_SHELL_REFUSE = 'refused: you already ARE the chat. Never curl/wget/fetch localhost:8402 or /v1/chat/completions — Orange Auto is WRITE/READ/RUN for real work, not shelling the proxy.';
222
+ function looksLikeProxyShell(cmd) {
223
+ const s = String(cmd || '');
224
+ if (!/\b(curl|wget|fetch)\b/i.test(s)) return false;
225
+ if (/(?:localhost|127\.0\.0\.1|\[::1\])/i.test(s) && /:8402\b/.test(s)) return true;
226
+ if (/(?:localhost|127\.0\.0\.1|\[::1\])[^\s'"]*\/v1\/chat\/completions/i.test(s)) return true;
227
+ return false;
228
+ }
229
+
214
230
  const SYSTEM = `You are a helpful assistant served over openzoo (pay-per-call access to ~435
215
231
  models, no API key, no account). Reply normally in plain text, concisely.
216
232
 
@@ -361,7 +377,7 @@ call. Ask for everything you know you need at once instead of discovering it one
361
377
  time. Mutating directives (RUN, WRITE, EDIT, SPAWN, SEND) stay sequential on purpose — racing
362
378
  them against each other corrupts the tree.
363
379
  RUN: <shell command> run a REAL shell command in this
364
- thread's directory — by default this
380
+ thread's directory. Stay in that directory. Never find / use GLOB: or find . -maxdepth N. By default this
365
381
  pauses and waits for the user to
366
382
  approve or deny it before anything
367
383
  executes ("/mode auto" in chat skips
@@ -385,17 +401,7 @@ output and exit code back; that, not silence, is what failure looks like.
385
401
  Never fabricate command output, file contents, or payment receipts. If you did not run it,
386
402
  say so and then actually run it.
387
403
 
388
- Via RUN you can also make YOUR OWN paid openzoo calls — POST to
389
- http://localhost:8402/v1/chat/completions (or /v1/hrr/bind) with curl/python/etc. Auth is
390
- "Authorization: Bearer sk-openzoo" — any string works, x402 pays per call, not the key. Do
391
- NOT tell the user you "can't fire the paid calls" or need "their client's bearer key" —
392
- that's wrong, you can make these calls yourself via RUN. When you do, set max_tokens
393
- generously (1000+, not 50) — a reasoning model can burn its ENTIRE budget on internal
394
- reasoning before writing any visible answer, especially against a large bound corpus, and
395
- comes back with content:null and finish_reason:"length" (confirmed live) if you starve it.
396
- /v1/hrr/bind also caps around ~8MB per request after JSON-escaping — chunk large corpora
397
- (e.g. 512KB raw per request) and pass the PREVIOUS chunk's context_id on each next request
398
- to append to the same bound context, rather than one giant request that silently fails partway.
404
+ ${CHAT_NOT_PROXY}
399
405
 
400
406
  COST ACCOUNTING — do NOT compute this yourself from token counts. Every response carries an
401
407
  "x402" object; read the numbers off it: x402.billedUsd (what the user paid — OpenRouter price, plus 33% of savings vs
@@ -443,7 +449,12 @@ function loadThreads() {
443
449
  }
444
450
  if (Array.isArray(t.history)) {
445
451
  for (const h of t.history) {
446
- if (h && h.who === 'bot' && typeof h.text === 'string') h.text = stripThinkTags(h.text);
452
+ if (h && h.who === 'bot' && typeof h.text === 'string') {
453
+ const parts = takeThink(h.text, h.thinking);
454
+ h.text = parts.text;
455
+ if (parts.thinking) h.thinking = parts.thinking;
456
+ else delete h.thinking;
457
+ }
447
458
  }
448
459
  }
449
460
  if (Array.isArray(t.messages)) {
@@ -507,6 +518,10 @@ function newThread(name, parent, members, spec) {
507
518
  ...(p?.race ? { race: p.race } : {}),
508
519
  ...(p?.raceNeed ? { raceNeed: p.raceNeed } : {}) };
509
520
  if (p) attachChildDir(t, p, spec);
521
+ // Brand-new chat (no parent) starts empty: own holobrain, no contextId,
522
+ // no boundItems. Do not copy the previous thread's bind / corpus.
523
+ // SPAWN kids also start unbound here; bindThread shares the parent root
524
+ // on first bind. Existing threads on disk keep whatever they already have.
510
525
  threads.set(id, t);
511
526
  saveThreads();
512
527
  return t;
@@ -630,6 +645,7 @@ savedUsd, savesVsDirect). Never derive it by summing usage.cost or usage.prompt_
630
645
  provider's list price: on a bound context prompt_tokens counts only the slice leCore
631
646
  recalled, not the corpus it stands in for, so that math prices the discount against itself
632
647
  and wrongly concludes the zoo cost more.
648
+ ${CHAT_NOT_PROXY}
633
649
  For normal replies just answer directly — do not use any of these unless the request
634
650
  actually calls for delegation or file work.` };
635
651
  }
@@ -650,7 +666,10 @@ function contentFor(text, images) {
650
666
  }
651
667
 
652
668
  function buildMemberMessages(t, member) {
653
- const msgs = [{ role: 'system', content: member.systemPrompt || SYSTEM }];
669
+ const msgs = [
670
+ { role: 'system', content: member.systemPrompt || SYSTEM },
671
+ { role: 'system', content: CHAT_NOT_PROXY },
672
+ ];
654
673
  for (const h of t.history) {
655
674
  if (h.who === 'user') msgs.push({ role: 'user', content: contentFor(h.text, h.images) });
656
675
  else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
@@ -693,9 +712,7 @@ const NUDGE = 'That reply announced work instead of doing it — no directive li
693
712
  + 'RUN:, SPAWN:, READ:, WRITE:, GLOB:, FETCH:, MCP: or SERVE:. '
694
713
  + 'Exactly the syntax from your instructions — not [TOOL_CALL], not JSON, not a function-call envelope. '
695
714
  + 'If several steps are needed, emit the FIRST one; you get its real output back and continue from there.';
696
- const AUTO_CONTINUE = 'AUTO is still on do not stop and do not ask the user to type continue. '
697
- + 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
698
- + 'or DONE: if the job is actually finished.';
715
+ const AUTO_CONTINUE = 'Please continue the current job. Do not stop to ask the user.';
699
716
  const AUTO_RACE_RETRY = 'AUTO is still on — the last model call failed (race/empty/error). '
700
717
  + 'Do not stop and do not ask the user to type continue. '
701
718
  + 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
@@ -852,7 +869,22 @@ without a fact only the user has.
852
869
  When the job is actually finished, emit DONE: as the first line. A status
853
870
  sentence is not a stop — the harness keeps this thread working until DONE:,
854
871
  a real blocking question, or the step cap. Empty output, "(no output)", and
855
- GLOB/GREP with no matches are not finished — try a different command or path.`;
872
+ GLOB/GREP with no matches are not finished — try a different command or path.
873
+
874
+ ${CHAT_NOT_PROXY}`;
875
+
876
+ /**
877
+ * Holobrain owner for a thread.
878
+ * SPAWN kids share the project root (current SPAWN semantics).
879
+ * A brand-new chat (no parent) is its own root — never attach another
880
+ * thread's contextId / boundItems.
881
+ */
882
+ function holobrainOf(t) {
883
+ if (!t) return null;
884
+ if (t.parent) return threads.get(rootOf(t).rootId) || t;
885
+ return t;
886
+ }
887
+
856
888
  async function bindThread(t) {
857
889
  // Only bind what's NEW since the last successful bind, continuing the
858
890
  // existing context_id — previously this rebuilt and re-sent the WHOLE
@@ -867,15 +899,13 @@ async function bindThread(t) {
867
899
  const corpus = delta.map((h) => '[' + t.name + '] ' + (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
868
900
  if (!corpus.trim()) { t.boundHistoryCount = t.history.length; return; }
869
901
  try {
870
- // ONE CONTEXT PER PROJECT, not per thread. Every thread used to bind to
871
- // its own private context, so sibling agents spawned for the same job were
872
- // memory-isolated: arc-tetris-engine could not recall a thing
873
- // arc-token-bets had established, and the user paid to re-explain shared
874
- // facts to each one. They are a team; the memory should be too.
875
- // The context lives on the ROOT and every descendant binds into it, while
876
- // each thread keeps its OWN boundHistoryCount so nothing is re-sent.
877
- const root = threads.get(rootOf(t).rootId) || t;
878
- let ctx = root.contextId || t.contextId;
902
+ // SPAWN kids share the project root holobrain so sibling agents can
903
+ // recall what the crew already bound. A brand-new chat (no parent) is
904
+ // its own root — do not attach the previous thread's contextId.
905
+ // Existing threads that already have a bind keep it (we never delete
906
+ // contextId here).
907
+ const brain = holobrainOf(t) || t;
908
+ let ctx = brain.contextId || t.contextId;
879
909
  for (let i = 0; i < corpus.length; i += BIND_CHUNK_BYTES) {
880
910
  const part = corpus.slice(i, i + BIND_CHUNK_BYTES);
881
911
  const body = ctx ? { corpus: part, context_id: ctx } : { corpus: part };
@@ -889,12 +919,12 @@ async function bindThread(t) {
889
919
  // How many chunks the project's holobrain now holds. This is the number
890
920
  // adaptive top_k scales on — without it we would be guessing, which is
891
921
  // exactly how top_k ended up pinned at 8 in the first place.
892
- if (Number(j?.bound)) root.boundItems = (root.boundItems || 0) + Number(j.bound);
922
+ if (Number(j?.bound)) brain.boundItems = (brain.boundItems || 0) + Number(j.bound);
893
923
  else break; // this chunk failed — stop, keep whatever bound so far rather than lose it all
894
924
  }
895
- // Write to the ROOT so later siblings inherit it, and to this thread so
896
- // the per-call header is readable without walking the tree again.
897
- if (ctx) { root.contextId = ctx; t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
925
+ // Write to the holobrain owner (project root for SPAWN kids, this thread
926
+ // for a new chat) and to this thread so the per-call header is local.
927
+ if (ctx) { brain.contextId = ctx; t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
898
928
  } catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
899
929
  }
900
930
 
@@ -1035,8 +1065,10 @@ const RUN_SHELL = process.platform !== 'win32' && existsSync('/bin/bash') ? '/bi
1035
1065
  const RUN_TIMEOUT_MS = Number(process.env.OZ_RUN_TIMEOUT_MS || 600000);
1036
1066
 
1037
1067
  function execCommand(command, cwd) {
1068
+ if (looksLikeProxyShell(command)) return Promise.resolve(PROXY_SHELL_REFUSE);
1069
+ const guarded = guardFindCwd(command, cwd);
1038
1070
  return new Promise((resolve) => {
1039
- exec(command, { cwd, shell: RUN_SHELL, timeout: RUN_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
1071
+ exec(guarded, { cwd, shell: RUN_SHELL, timeout: RUN_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
1040
1072
  let out = (stdout || '') + (stderr ? '\n' + stderr : '');
1041
1073
  if (err) out += `\n(exit ${err.code ?? 1})`;
1042
1074
  resolve(keepWhole(out).trim() || '(no output)');
@@ -1083,11 +1115,28 @@ const SLASH_COMMANDS = [
1083
1115
  { name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
1084
1116
  { name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
1085
1117
  { name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
1086
- { name: '/mode', args: 'auto|ask', help: 'run commands immediately, or ask first' },
1118
+ { name: '/mode', args: 'auto|ask', help: 'Auto = Claude Code via OpenZoo; ask = chat + approve RUN' },
1087
1119
  ];
1088
1120
 
1089
1121
  const usd = (n) => (n >= 0.01 || n === 0 ? '$' + n.toFixed(2) : '$' + n.toFixed(5));
1090
1122
 
1123
+ /**
1124
+ * HUD / sitrep / /cost: prefer spilled-call x when anything bound.
1125
+ * Never an unlabeled Nx — "2.10x spilled" vs "2.10x session".
1126
+ * Rounding: 100+ integer, 10+ 1dp, else 2dp.
1127
+ */
1128
+ function formatSavingLabel(you) {
1129
+ const spent = Number(you && you.spentUsd) || 0;
1130
+ if (spent <= 0) return { text: '—', mult: null, spilled: false };
1131
+ const spillX = Number(you && you.spilled && you.spilled.savingX);
1132
+ const sessionX = (Number(you && you.directUsd) || 0) / spent;
1133
+ const spilled = Number.isFinite(spillX) && spillX > 0;
1134
+ const mult = spilled ? spillX : sessionX;
1135
+ if (!Number.isFinite(mult)) return { text: '—', mult: null, spilled: false };
1136
+ const num = (mult >= 100 ? String(Math.round(mult)) : Number(mult).toFixed(mult >= 10 ? 1 : 2)) + 'x';
1137
+ return { text: num + (spilled ? ' spilled' : ' session'), mult, spilled };
1138
+ }
1139
+
1091
1140
  // BIND, DON'T PASTE — the whole point of the thing this runs on.
1092
1141
  //
1093
1142
  // Directive results get fed back to the model as a user message, verbatim. A
@@ -1215,8 +1264,8 @@ function inFlightChars(t) {
1215
1264
  */
1216
1265
  function scheduleFilesForCorpus(t, collected, opts = {}) {
1217
1266
  if (!collected?.pending?.length) return null;
1218
- const root = t ? (threads.get(rootOf(t).rootId) || t) : null;
1219
- const ctx = opts.contextId || root?.contextId || t?.contextId || null;
1267
+ const brain = t ? holobrainOf(t) : null;
1268
+ const ctx = opts.contextId || brain?.contextId || t?.contextId || null;
1220
1269
  const chars = opts.sentChars ?? inFlightChars(t);
1221
1270
  const background = chars < BIND_MIN_CHARS;
1222
1271
  const fetchImpl = opts.fetchImpl || fetch;
@@ -1233,7 +1282,7 @@ function scheduleFilesForCorpus(t, collected, opts = {}) {
1233
1282
  }).then(async (r) => {
1234
1283
  const j = await r.json().catch(() => ({}));
1235
1284
  if (j?.context_id && t) {
1236
- const live = threads.get(rootOf(t).rootId) || t;
1285
+ const live = holobrainOf(t) || t;
1237
1286
  live.contextId = j.context_id;
1238
1287
  t.contextId = j.context_id;
1239
1288
  if (Number(j.bound)) live.boundItems = (live.boundItems || 0) + Number(j.bound);
@@ -1301,9 +1350,20 @@ function emitToThread(threadId, ev) {
1301
1350
  }
1302
1351
  }
1303
1352
 
1353
+ async function attachSpilled(you) {
1354
+ if (!you || you.spilled != null) return you;
1355
+ try {
1356
+ const info = await (await fetch(`${PROXY}/info`, { signal: AbortSignal.timeout(2000) })).json();
1357
+ if (info && info.spilled) you.spilled = info.spilled;
1358
+ } catch { /* session label stays honest if info is down */ }
1359
+ return you;
1360
+ }
1361
+
1304
1362
  async function sessionStats() {
1305
- try { return await (await fetch(`${PROXY}/session`, { signal: AbortSignal.timeout(2000) })).json(); }
1306
- catch { return null; }
1363
+ try {
1364
+ const s = await (await fetch(`${PROXY}/session`, { signal: AbortSignal.timeout(2000) })).json();
1365
+ return attachSpilled(s);
1366
+ } catch { return null; }
1307
1367
  }
1308
1368
 
1309
1369
  function todoBlock(t) {
@@ -1318,6 +1378,18 @@ async function handleSlash(task, t) {
1318
1378
  const cmd = m[1].toLowerCase();
1319
1379
  const arg = m[2].trim();
1320
1380
 
1381
+ if (cmd === 'pay') {
1382
+ return 'Pay — card checkout or the local wallet/x402 burner.';
1383
+ }
1384
+ if (cmd === 'hud') {
1385
+ const st = await sessionStats();
1386
+ const spent = st ? usd(Number(st.spentUsd) || 0) : '—';
1387
+ const calls = st ? (st.paidCalls || 0) : 0;
1388
+ const mode = t.runMode || 'ask';
1389
+ const tier = t.tier || 'auto';
1390
+ const sav = st ? formatSavingLabel(st) : { text: '—' };
1391
+ return `Sitrep — mode ${mode} · ${tier} · paid ${spent} · ${sav.text} · ${calls} calls.`;
1392
+ }
1321
1393
  if (cmd === 'sitrep') return null; // drawer-only — never a transcript line
1322
1394
  if (cmd === 'help') {
1323
1395
  return 'Commands:\n'
@@ -1328,7 +1400,7 @@ async function handleSlash(task, t) {
1328
1400
  }
1329
1401
  if (cmd === 'tools') {
1330
1402
  return 'Directives:\n'
1331
- + ' RUN: <cmd> real shell, in this thread’s dir\n'
1403
+ + ' RUN: <cmd> real shell, in this thread’s dir (never find /)\n'
1332
1404
  + ' WRITE: <path> | <content> create/overwrite a file\n'
1333
1405
  + ' EDIT: <path> | <old> ||| <new> change part of a file\n'
1334
1406
  + ' MULTIEDIT: <path> | a|||b ;; c|||d several edits, all-or-nothing\n'
@@ -1349,6 +1421,20 @@ async function handleSlash(task, t) {
1349
1421
  + 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
1350
1422
  + 'appear in one reply — four files cost one round trip, not four.';
1351
1423
  }
1424
+ // Header Pay / ◎ echo through the same /drive → history.push path as
1425
+ // /mode and /tier. Short lines only — never wallet JSON or a sitrep dump.
1426
+ if (cmd === 'pay') {
1427
+ return 'Pay — card checkout or the local wallet/x402 burner. Drawer opened.';
1428
+ }
1429
+ if (cmd === 'hud') {
1430
+ const s = await sessionStats();
1431
+ const mode = t.runMode || 'ask';
1432
+ const tier = t.tier || 'auto';
1433
+ if (!s) return `Sitrep — mode ${mode} · ${tier} · proxy unreachable.`;
1434
+ const spent = Number(s.spentUsd) || 0;
1435
+ const sav = formatSavingLabel(s);
1436
+ return `Sitrep — mode ${mode} · ${tier} · paid ${usd(spent)} · ${sav.text} · ${s.paidCalls || 0} calls`;
1437
+ }
1352
1438
  if (cmd === 'cost' || cmd === 'tokens') {
1353
1439
  const s = await sessionStats();
1354
1440
  if (!s) return 'The local openzoo proxy isn’t reachable, so there are no real numbers to show. (Not zero — unknown.)';
@@ -1360,9 +1446,9 @@ async function handleSlash(task, t) {
1360
1446
  ` paid calls ${s.paidCalls || 0}`,
1361
1447
  ];
1362
1448
  if (spent > 0) {
1363
- const mult = direct / spent;
1364
- lines.push(` multiple ${mult >= 100 ? Math.round(mult) : mult.toFixed(2)}x`
1365
- + (mult < 1 ? ' — under 1x: small inputs cost MORE than sending them directly' : ''));
1449
+ const sav = formatSavingLabel(s);
1450
+ lines.push(` multiple ${sav.text}`
1451
+ + (sav.mult != null && sav.mult < 1 ? ' — under 1x: small inputs cost MORE than sending them directly' : ''));
1366
1452
  }
1367
1453
  return 'This session:\n' + lines.join('\n');
1368
1454
  }
@@ -1377,16 +1463,27 @@ async function handleSlash(task, t) {
1377
1463
  // tier silently overriding an explicit id would make /model a suggestion.
1378
1464
  if (cmd === 'tier') {
1379
1465
  if (!arg) {
1380
- const picks = await tierModels(t.tier || 'medium', 3);
1381
- return `This thread: ${t.tier || 'medium'}${t.tier ? '' : ' (default)'}\n`
1382
- + `Tiers: ${TIER_NAMES.join(' · ')}\n`
1383
- + `Top of ${t.tier || 'medium'} right now: ${picks.join(', ')}\n`
1466
+ const cur = t.tier || 'auto';
1467
+ if (cur === 'auto') {
1468
+ return `This thread: auto (classifier openzoo/auto)\n`
1469
+ + `Tiers: auto · ${TIER_NAMES.join(' · ')}\n`
1470
+ + (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so Auto is ignored until you /model default.\n` : '')
1471
+ + 'Switch with /tier <name> · /race <n> to ask several at once.';
1472
+ }
1473
+ const picks = await tierModels(cur, 3);
1474
+ return `This thread: ${cur}\n`
1475
+ + `Tiers: auto · ${TIER_NAMES.join(' · ')}\n`
1476
+ + `Top of ${cur} right now: ${picks.join(', ')}\n`
1384
1477
  + (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so the tier is ignored until you /model default.\n` : '')
1385
1478
  + 'Switch with /tier <name> · /race <n> to ask several at once.';
1386
1479
  }
1387
1480
  const want = normalizeTier(arg);
1388
- if (!want) return `Unknown tier "${arg}". One of: ${TIER_NAMES.join(', ')} (also: grok 4.6).`;
1481
+ if (!want) return `Unknown tier "${arg}". One of: auto, ${TIER_NAMES.join(', ')} (also: grok 4.6).`;
1389
1482
  t.tier = want; saveThreads();
1483
+ if (want === 'auto') {
1484
+ return 'This thread now uses Auto — cheapest model that clears the bar (openzoo/auto).'
1485
+ + (t.model ? `\nBut /model ${t.model} is still pinned and wins. Run /model default to let Auto take over.` : '');
1486
+ }
1390
1487
  const picks = await tierModels(want, 3);
1391
1488
  return `This thread now runs on the ${want} tier — ${picks.join(', ')}…`
1392
1489
  + (t.model ? `\nBut /model ${t.model} is still pinned and wins. Run /model default to let the tier take over.` : '');
@@ -1521,7 +1618,7 @@ async function handleSlash(task, t) {
1521
1618
 
1522
1619
  // Wake the room. Used to be a free last-line dump — idle children stayed
1523
1620
  // idle, and a parent reading "kid: <old reply>" thought they had acted.
1524
- // Empty extra is a nudge (AUTO_CONTINUE), not a cancel. Thinking stays
1621
+ // Empty extra is a continue wake (Claude Code on Auto), not a cancel. Thinking stays
1525
1622
  // thinking; pendingRun stays on the human. Same branch scope as /all.
1526
1623
  if (cmd === 'ping') {
1527
1624
  const crew = subtreeOf(t.id, true);
@@ -1911,7 +2008,7 @@ function spawnBrief(parent, { refresh = false, child } = {}) {
1911
2008
  const cwd = child?.dir || WORKSPACE_DIR;
1912
2009
  const branch = child?.worktree?.branch;
1913
2010
  const mode = parent.runMode || 'ask';
1914
- const tier = parent.tier || 'medium';
2011
+ const tier = parent.tier || 'auto';
1915
2012
  const race = Number(parent.race) || 0;
1916
2013
  const raceNeed = Number(parent.raceNeed) || 1;
1917
2014
  const model = parent.model || '';
@@ -2622,12 +2719,84 @@ async function mcpDirective(url, tool, args) {
2622
2719
  }
2623
2720
  }
2624
2721
 
2722
+ function autoClaudePrompt(t, userText, images) {
2723
+ const bits = [];
2724
+ if (t.memory?.length) bits.push(`Remember, for this thread:\n${t.memory.map((x) => `- ${x}`).join('\n')}`);
2725
+ if (t.todos?.length) {
2726
+ bits.push(`Current checklist:\n${t.todos.map((x, i) => `${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')}`);
2727
+ }
2728
+ if (images?.length) bits.push(`(${images.length} image(s) were attached in the desktop UI; work from the text ask.)`);
2729
+ bits.push(String(userText || ''));
2730
+ return bits.join('\n\n');
2731
+ }
2732
+
2733
+ /**
2734
+ * Orange Auto = Claude Code print loop, paid through OpenZoo. Not the
2735
+ * RUN:/WRITE:/DONE: text harness (that parked on "Done." after empty mkdirs).
2736
+ */
2737
+ async function runAutoClaudeTurn(t, userText, images, paint, stillMine, turnAbort) {
2738
+ paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'Claude Code via OpenZoo…' });
2739
+ let visible = '';
2740
+ let thinking = '';
2741
+ let streamedText = false;
2742
+ const result = await runClaudeCode({
2743
+ prompt: autoClaudePrompt(t, userText, images),
2744
+ cwd: dirFor(t.id),
2745
+ sessionId: t.claudeSessionId,
2746
+ model: t.model || undefined,
2747
+ env: process.env,
2748
+ signal: turnAbort.signal,
2749
+ onEvent(folded) {
2750
+ if (!stillMine() || !folded) return;
2751
+ if (folded.sessionId) t.claudeSessionId = folded.sessionId;
2752
+ if (folded.kind === 'init') {
2753
+ paint({ type: 'status', name: t.name, color: t.color, detail: folded.model || 'Claude Code' });
2754
+ return;
2755
+ }
2756
+ if (folded.kind === 'think' && folded.text) {
2757
+ thinking += folded.text;
2758
+ paint({ type: 'think', name: t.name, color: t.color, delta: folded.text });
2759
+ return;
2760
+ }
2761
+ if (folded.kind === 'text' && folded.text) {
2762
+ streamedText = true;
2763
+ visible += folded.text;
2764
+ paint({ type: 'delta', name: t.name, color: t.color, delta: folded.text });
2765
+ return;
2766
+ }
2767
+ if (folded.kind !== 'assistant') return;
2768
+ if (folded.thinking && !thinking) {
2769
+ thinking = folded.thinking;
2770
+ paint({ type: 'think', name: t.name, color: t.color, delta: folded.thinking });
2771
+ }
2772
+ if (folded.text && !streamedText) {
2773
+ visible = folded.text;
2774
+ paint({ type: 'delta', name: t.name, color: t.color, delta: folded.text, replace: true });
2775
+ }
2776
+ for (const tool of folded.tools || []) {
2777
+ paint({ type: 'status', name: t.name, color: t.color, detail: toolStatusLine(tool.name, tool.input) });
2778
+ }
2779
+ },
2780
+ });
2781
+ if (!stillMine()) return result.paymentFailed || result.text || visible || '';
2782
+ if (result.sessionId) t.claudeSessionId = result.sessionId;
2783
+ const finalText = result.paymentFailed
2784
+ || result.text
2785
+ || visible
2786
+ || (result.missing ? CLAUDE_MISSING : '')
2787
+ || '(no response)';
2788
+ t.history.push({ who: 'bot', text: finalText, thinking: thinking || undefined });
2789
+ paint({ type: 'final', name: t.name, color: t.color, text: finalText, thinking: thinking || undefined });
2790
+ return finalText;
2791
+ }
2792
+
2625
2793
  // onEvent (optional) gets live progress for whoever's actually watching this
2626
2794
  // call: {type:'start',name,color} when a bot begins its turn, {type:'status',
2627
2795
  // detail} while paying / waiting / racing / walking tools, {type:'race',race}
2628
2796
  // for the spectator grid (one cell per launched model + a judging beat),
2629
2797
  // {type:'delta',name,color,delta} per streamed token (replace:true swaps the
2630
- // bubble once), {type:'final',name,color,text} once its full reply (or
2798
+ // bubble once), {type:'think',delta} for folded chain-of-thought (not the
2799
+ // Auto run-mode chip), {type:'final',name,color,text} once its full reply (or
2631
2800
  // directive ack) is settled. Background turns go through kickTurn →
2632
2801
  // emitToThread, which is a no-op if nobody has the thread open.
2633
2802
  async function runTurn(threadId, userText, onEvent, images) {
@@ -2647,7 +2816,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2647
2816
  if (!stillMine()) return;
2648
2817
  if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
2649
2818
  if (ev.type === 'race' && ev.race && t.status === 'thinking') t.liveRace = ev.race;
2650
- if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race') t.lastDeltaAt = Date.now();
2819
+ if (ev.type === 'delta' || ev.type === 'think' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race') t.lastDeltaAt = Date.now();
2651
2820
  onEvent?.(ev);
2652
2821
  };
2653
2822
  t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
@@ -2665,6 +2834,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2665
2834
  t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
2666
2835
  let chained = false;
2667
2836
  let parked = false;
2837
+ let usedClaude = false;
2668
2838
  let lastReply = '';
2669
2839
  try {
2670
2840
  if (t.members) {
@@ -2680,11 +2850,15 @@ async function runTurn(threadId, userText, onEvent, images) {
2680
2850
  const emitStatus = (detail) => paint({ type: 'status', name: m.name, color: m.color, detail });
2681
2851
  try {
2682
2852
  r = onEvent
2683
- ? (await brainStream(msgs, (delta) => paint({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId, undefined, undefined, 0, 0, emitStatus)).trim()
2853
+ ? (await brainStream(msgs, (delta, meta) => {
2854
+ if (meta?.think) paint({ type: 'think', name: m.name, color: m.color, delta });
2855
+ else paint({ type: 'delta', name: m.name, color: m.color, delta });
2856
+ }, t.contextId, undefined, undefined, 0, 0, emitStatus)).trim()
2684
2857
  : (await brain(msgs, t.contextId)).trim();
2685
2858
  } catch (e) { r = `error: ${e.message}`; }
2686
2859
  if (!stillMine()) return;
2687
- r = stripThinkTags(r);
2860
+ const memberThink = takeThink(r);
2861
+ r = memberThink.text;
2688
2862
  memberReply = r;
2689
2863
  const runCmd = parseRun(r);
2690
2864
  if (runCmd) {
@@ -2694,16 +2868,19 @@ async function runTurn(threadId, userText, onEvent, images) {
2694
2868
  const output = await execCommand(command, dirFor(t.id));
2695
2869
  noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
2696
2870
  const shown = `$ ${command}\n${output}`;
2697
- t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
2698
- paint({ type: 'final', name: m.name, color: m.color, text: shown });
2871
+ t.history.push({
2872
+ who: 'bot', text: command, runStatus: 'done', runOutput: output,
2873
+ name: m.name, color: m.color, thinking: memberThink.thinking,
2874
+ });
2875
+ paint({ type: 'final', name: m.name, color: m.color, text: command, thinking: memberThink.thinking });
2699
2876
  memberReply = shown;
2700
2877
  // this member's turn is done; the round continues to the next member
2701
2878
  continue;
2702
2879
  }
2703
2880
  const runId = randomUUID();
2704
2881
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
2705
- t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
2706
- paint({ type: 'run-pending', runId, command, name: m.name, color: m.color });
2882
+ t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color, thinking: memberThink.thinking });
2883
+ paint({ type: 'run-pending', runId, command, name: m.name, color: m.color, thinking: memberThink.thinking });
2707
2884
  // pauses the WHOLE round here — the rest of the group gets their turn
2708
2885
  // on the round that runs after the user approves/denies
2709
2886
  parked = true;
@@ -2711,8 +2888,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2711
2888
  }
2712
2889
  const ack = await tryDirective(r, t.id, paint);
2713
2890
  const finalText = ack ?? (r || '(no response)');
2714
- t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
2715
- paint({ type: 'final', name: m.name, color: m.color, text: finalText });
2891
+ t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color, thinking: memberThink.thinking });
2892
+ paint({ type: 'final', name: m.name, color: m.color, text: finalText, thinking: memberThink.thinking });
2716
2893
  memberReply = r;
2717
2894
  }
2718
2895
  bindThread(t).catch(() => {});
@@ -2722,6 +2899,14 @@ async function runTurn(threadId, userText, onEvent, images) {
2722
2899
  }
2723
2900
  return;
2724
2901
  }
2902
+ // Orange Auto is Claude Code (`openzoo claude` env), not the RUN:/WRITE:
2903
+ // text loop. Ask (and group members above) still use chat/completions.
2904
+ if (t.runMode === 'auto') {
2905
+ usedClaude = true;
2906
+ lastReply = await runAutoClaudeTurn(t, userText, images, paint, stillMine, turnAbort);
2907
+ if (stillMine()) bindThread(t).catch(() => {});
2908
+ return;
2909
+ }
2725
2910
  // A real message from the user resets the auto budget AND the announcement
2726
2911
  // nudge. Harness-injected hops (command output, directive result, nudge,
2727
2912
  // auto-continue) must not re-arm — that would make AUTO_MAX_STEPS a no-op
@@ -2748,6 +2933,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2748
2933
  if (t.todos?.length) {
2749
2934
  extras.push({ role: 'system', content: `Current checklist (TODO: done <n> to tick one off):\n${t.todos.map((x, i) => `${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')}` });
2750
2935
  }
2936
+ extras.push({ role: 'system', content: CHAT_NOT_PROXY });
2751
2937
  if (t.runMode === 'auto') extras.push({ role: 'system', content: AUTO_DIRECTIVE });
2752
2938
  const callMsgs = extras.length ? [...t.messages, ...extras] : t.messages;
2753
2939
  // WHICH MODEL SERVES THIS TURN.
@@ -2763,18 +2949,34 @@ async function runTurn(threadId, userText, onEvent, images) {
2763
2949
  thread: t, attempt, userText, messages: callMsgs,
2764
2950
  }) ?? '').trim();
2765
2951
  }
2766
- const emit = (delta, meta) => paint({
2767
- type: 'delta', name: t.name, color: t.color, delta,
2768
- ...(meta?.replace ? { replace: true } : {}),
2769
- ...(meta?.model ? { model: meta.model } : {}),
2770
- });
2952
+ const emit = (delta, meta) => {
2953
+ if (meta?.think) {
2954
+ paint({ type: 'think', name: t.name, color: t.color, delta });
2955
+ return;
2956
+ }
2957
+ paint({
2958
+ type: 'delta', name: t.name, color: t.color, delta,
2959
+ ...(meta?.replace ? { replace: true } : {}),
2960
+ ...(meta?.model ? { model: meta.model } : {}),
2961
+ });
2962
+ };
2771
2963
  const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
2772
- // Retrieval breadth scales with the PROJECT's corpus, not this thread's
2773
- // the holobrain is shared at the root, so that is the pool being searched.
2774
- const topK = adaptiveTopK((threads.get(rootOf(t).rootId) || t).boundItems);
2964
+ // SPAWN kids search the project root's corpus. A brand-new chat is its
2965
+ // own holobrain do not scale top_k off another thread's boundItems.
2966
+ const topK = adaptiveTopK((holobrainOf(t) || t).boundItems);
2775
2967
  const race = Math.min(Number(t.race) || 0, 4);
2776
2968
  if (!t.model && race >= 2) {
2777
- const models = await tierModels(t.tier || 'medium', race, true);
2969
+ const useAuto = !t.tier || t.tier === 'auto';
2970
+ let models = [];
2971
+ if (useAuto) {
2972
+ try {
2973
+ const routed = routeChatBody({ messages: callMsgs }, { k: race, allow_free: false, bindable: true });
2974
+ models = (routed.shortlist || []).map((s) => s.model).filter(Boolean);
2975
+ if (routed.model && !models.includes(routed.model)) models.unshift(routed.model);
2976
+ models = [...new Set(models)].slice(0, race);
2977
+ } catch { models = []; }
2978
+ }
2979
+ if (!models.length) models = await tierModels(useAuto ? 'medium' : (t.tier || 'medium'), race, true);
2778
2980
  // need = how many must come BACK before judging. need 1 is a plain
2779
2981
  // first-past-the-post race; need N waits for all of them. The point of
2780
2982
  // the middle (2 of 3) is a judged answer without the slowest entrant
@@ -2789,7 +2991,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2789
2991
  })).trim();
2790
2992
  }
2791
2993
  // A retry draws a DIFFERENT model from the tier rather than the same one.
2792
- const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
2994
+ const model = t.model || 'openzoo/auto';
2793
2995
  return (onEvent
2794
2996
  ? (await brainStream(callMsgs, emit, t.contextId, model, undefined, 0, topK, emitStatus)).trim()
2795
2997
  : (await brain(callMsgs, t.contextId, model, topK)).trim());
@@ -2827,7 +3029,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2827
3029
  reply = `error: ${e.message}`;
2828
3030
  }
2829
3031
  if (!stillMine()) return;
2830
- reply = stripThinkTags(reply);
3032
+ const settled = takeThink(reply);
3033
+ reply = settled.text;
2831
3034
  lastReply = reply;
2832
3035
  t.messages.push({ role: 'assistant', content: reply });
2833
3036
  const runCmd = parseRun(reply);
@@ -2839,8 +3042,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2839
3042
  noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
2840
3043
  if (!stillMine()) return;
2841
3044
  const shown = `$ ${command}\n${output}`;
2842
- t.history.push({ who: 'bot', text: shown });
2843
- paint({ type: 'final', name: t.name, color: t.color, text: shown });
3045
+ t.history.push({ who: 'bot', text: command, runStatus: 'done', runOutput: output, thinking: settled.thinking });
3046
+ paint({ type: 'final', name: t.name, color: t.color, text: command, thinking: settled.thinking });
2844
3047
  // FEED THE OUTPUT BACK. The 'ask' path already does this on approve, so
2845
3048
  // auto mode was strictly LESS capable than the gated one: the command
2846
3049
  // ran, the result was shown, and the model never saw it — no diagnosis,
@@ -2865,8 +3068,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2865
3068
  {
2866
3069
  const runId = randomUUID();
2867
3070
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
2868
- t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
2869
- paint({ type: 'run-pending', runId, command, name: t.name, color: t.color });
3071
+ t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', thinking: settled.thinking });
3072
+ paint({ type: 'run-pending', runId, command, name: t.name, color: t.color, thinking: settled.thinking });
2870
3073
  }
2871
3074
  parked = true;
2872
3075
  return;
@@ -2874,8 +3077,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2874
3077
  const ack = await tryDirective(reply, t.id, paint);
2875
3078
  if (!stillMine()) return;
2876
3079
  const finalText = ack ?? (reply || '(no response)');
2877
- t.history.push({ who: 'bot', text: finalText });
2878
- paint({ type: 'final', name: t.name, color: t.color, text: finalText });
3080
+ t.history.push({ who: 'bot', text: finalText, thinking: settled.thinking });
3081
+ paint({ type: 'final', name: t.name, color: t.color, text: finalText, thinking: settled.thinking });
2879
3082
 
2880
3083
  // AUTO CONTINUES AFTER *ANY* DIRECTIVE, not just RUN.
2881
3084
  //
@@ -2934,7 +3137,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2934
3137
  // ask mode, 402/empty-wallet, or the hard cap. Empty /(no output) is not
2935
3138
  // DONE — AUTO_EMPTY_RETRY. Otherwise kick again.
2936
3139
  if (stillMine() && !chained && !parked) {
2937
- if (shouldKeepAuto(t, lastReply, userText)) {
3140
+ if (!usedClaude && shouldKeepAuto(t, lastReply, userText)) {
2938
3141
  enqueueAutoHop(t, threadId, autoHopText(lastReply, userText), onEvent);
2939
3142
  } else if (!t.pendingRun) {
2940
3143
  t.status = 'idle';
@@ -3095,19 +3298,19 @@ const APP_HTML = `<!doctype html>
3095
3298
  <style>
3096
3299
  :root { color-scheme: dark; }
3097
3300
  * { box-sizing: border-box; }
3098
- html, body { margin: 0; height: 100%; background: #000; }
3301
+ html, body { margin: 0; height: 100%; width: 100%; overflow: hidden; background: #000; }
3099
3302
  body { color: #ececec; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
3100
3303
  display: flex; }
3101
3304
  #dragbar { -webkit-app-region: drag; position: fixed; top: 0; left: 0; right: 0; height: 28px; z-index: 1000; }
3102
3305
  #sidebar { width: 280px; flex: 0 0 280px; border-right: 1px solid #1c1c1e; display: flex; flex-direction: column;
3103
- height: 100vh; padding-top: 28px; }
3306
+ height: 100%; padding-top: 28px; min-height: 0; position: relative; }
3104
3307
  #main { padding-top: 28px; }
3105
3308
  #sideTop { display: flex; align-items: center; gap: 4px; padding: 0 8px; }
3106
3309
  #sideTop #search { flex: 1; }
3107
3310
  #search { margin: 12px; padding: 8px 12px; background: #1c1c1e; border-radius: 10px; color: #ececec;
3108
3311
  border: none; font: inherit; }
3109
3312
  #search::placeholder { color: #8e8e93; }
3110
- #threads { flex: 1; overflow-y: auto; }
3313
+ #threads { flex: 1; min-height: 0; overflow-y: auto; }
3111
3314
  .trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
3112
3315
  margin: 0 6px 2px; }
3113
3316
  /* PROJECT HEADER. The tree indentation shows who spawned whom, but there was
@@ -3172,7 +3375,7 @@ const APP_HTML = `<!doctype html>
3172
3375
  @media (prefers-reduced-motion: reduce) {
3173
3376
  .twarn, .bot-pfp .bot-bob, .bot-pfp .bot-eyes { animation: none; }
3174
3377
  }
3175
- #main { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
3378
+ #main { position: relative; flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; height: 100%; }
3176
3379
  #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
3177
3380
  flex-wrap: wrap; gap: 8px 10px; font-weight: 600; }
3178
3381
  #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 50%; font-size: 11px; flex: 0 0 26px; }
@@ -3299,6 +3502,21 @@ const APP_HTML = `<!doctype html>
3299
3502
  #hud { position: absolute; right: 14px; width: 270px; background: rgba(14,14,17,.94);
3300
3503
  border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
3301
3504
  display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
3505
+ /* Always-on strip. Sidebar footer — bottom-left of the window, inside the
3506
+ bot list column. Never a child of #main: position:absolute; left:14px
3507
+ there sat on the transcript and covered the last bubbles. ◎ stays a
3508
+ toggle; this one never hides. pointer-events none so it cannot steal
3509
+ thread-row clicks. */
3510
+ #dockHud { flex: 0 0 auto; position: relative; left: auto; bottom: auto;
3511
+ width: 100%; max-width: 100%; z-index: 1;
3512
+ pointer-events: none; background: rgba(14,14,17,.94);
3513
+ border: 0; border-top: 1px solid #333340; border-radius: 0;
3514
+ padding: 8px 12px 12px; color: #8e8e93;
3515
+ font: 10.5px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
3516
+ display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; }
3517
+ #dockHud .dk { color: #6f7080; }
3518
+ #dockHud .dv { color: #c8c8c2; }
3519
+ #dockHud .dv.hlime { color: #b8f240; }
3302
3520
  #hud.show { display: block; }
3303
3521
  #hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
3304
3522
  #hud .htitle.hsession { margin-top: 12px; padding-top: 10px; border-top: 1px solid #333340; color: #6f7080; }
@@ -3316,8 +3534,8 @@ const APP_HTML = `<!doctype html>
3316
3534
  color: #f0c9a8; font-size: 10.5px; line-height: 1.45; }
3317
3535
  #hud .hhint.show { display: block; }
3318
3536
  #hud .hhint b { color: #f28c4d; font-weight: 600; }
3319
- #sidebar, #main, #walletOverlay, #sitrepOverlay, #composeOverlay,
3320
- #inp, #search, #composeInp, .bubble, .md-pre, .runoutput, .runcmd {
3537
+ #sidebar, #main, #walletOverlay, #sitrepOverlay, #composeOverlay, #findBar,
3538
+ #inp, #search, #composeInp, #findInp, .bubble, .md-pre, .runoutput, .runcmd {
3321
3539
  -webkit-app-region: no-drag; }
3322
3540
  #log { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 20px 24px 12px;
3323
3541
  display: flex; flex-direction: column; gap: 6px;
@@ -3399,6 +3617,18 @@ const APP_HTML = `<!doctype html>
3399
3617
  }
3400
3618
  .runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px;
3401
3619
  max-width: 100%; min-width: 0; overflow: hidden; }
3620
+ /* Done RUNs collapse like thinking… — raw curl JSON must not be the message. */
3621
+ .runcard.folded { background: transparent; border: none; padding: 0; }
3622
+ .runfold { align-self: flex-start; max-width: 100%; min-width: 0; }
3623
+ .runchip {
3624
+ border: 0; background: transparent; color: #8e8e93; padding: 0;
3625
+ font: 12px/1.4 inherit; cursor: pointer; letter-spacing: .01em;
3626
+ -webkit-user-select: none; user-select: none;
3627
+ }
3628
+ .runchip:hover { color: #c8c8d0; }
3629
+ .runchip:focus-visible { outline: 2px solid #ff9500; outline-offset: 2px; border-radius: 4px; }
3630
+ .runbody { display: none; margin: 4px 0 2px; }
3631
+ .runfold.open .runbody { display: block; }
3402
3632
  .runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
3403
3633
  word-break: break-word; margin-bottom: 8px; }
3404
3634
  .runactions { display: flex; gap: 8px; }
@@ -3411,6 +3641,25 @@ const APP_HTML = `<!doctype html>
3411
3641
  word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
3412
3642
  .row.user .bubble { background: #57575c; }
3413
3643
  .row.bot .bubble { background: #262626; color: #ececec; }
3644
+ /* Folded chain-of-thought. Not the Auto run-mode chip — that is /mode auto.
3645
+ Default collapsed; click the label to unfurl, click again to furl.
3646
+ No empty chip: the fold is omitted when the model did not reason. */
3647
+ .msgcol { display: flex; flex-direction: column; gap: 6px; min-width: 0; max-width: 100%; flex: 1; }
3648
+ .thinkfold { align-self: flex-start; max-width: 100%; min-width: 0; }
3649
+ .thinkchip {
3650
+ border: 0; background: transparent; color: #8e8e93; padding: 0;
3651
+ font: 12px/1.4 inherit; cursor: pointer; letter-spacing: .01em;
3652
+ -webkit-user-select: none; user-select: none;
3653
+ }
3654
+ .thinkchip:hover { color: #c8c8d0; }
3655
+ .thinkchip:focus-visible { outline: 2px solid #b8f240; outline-offset: 2px; border-radius: 4px; }
3656
+ .thinkbody {
3657
+ display: none; margin: 4px 0 2px; padding: 8px 12px;
3658
+ color: #8e8e93; font-size: 12.5px; line-height: 1.45;
3659
+ white-space: pre-wrap; word-break: break-word;
3660
+ border-left: 2px solid #3a3a3c; max-height: 240px; overflow-y: auto;
3661
+ }
3662
+ .thinkfold.open .thinkbody { display: block; }
3414
3663
  .row.bot.pending .bubble { color: #8e8e93; }
3415
3664
  .dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
3416
3665
  background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
@@ -3535,6 +3784,23 @@ const APP_HTML = `<!doctype html>
3535
3784
  transition: opacity .14s ease, transform .14s ease;
3536
3785
  }
3537
3786
  #copiedToast.show { opacity: 1; transform: translate(-50%, 0); }
3787
+ /* FIND IN THREAD. Cmd/Ctrl+F — not the sidebar thread search (that's Cmd+K).
3788
+ Sits over #log so matches stay in the conversation, not the thread list. */
3789
+ #findBar { display: none; position: absolute; right: 16px; z-index: 320;
3790
+ align-items: center; gap: 6px; background: rgba(18,18,22,.98);
3791
+ border: 1px solid #3a3a3c; border-radius: 12px; padding: 6px 8px;
3792
+ box-shadow: 0 10px 28px rgba(0,0,0,.45); }
3793
+ #findBar.show { display: flex; }
3794
+ #findInp { width: 180px; background: #0b0b0d; border: 1px solid #2c2c2e; border-radius: 8px;
3795
+ color: #ececec; font: inherit; font-size: 13px; padding: 5px 8px; }
3796
+ #findInp:focus { outline: 2px solid #6ab0ff; outline-offset: 1px; }
3797
+ #findCount { min-width: 48px; color: #8e8e93; font-size: 12px; text-align: right;
3798
+ font-variant-numeric: tabular-nums; }
3799
+ #findBar button { border: 0; background: transparent; color: #ececec; width: 26px; height: 26px;
3800
+ border-radius: 8px; cursor: pointer; font: inherit; line-height: 1; }
3801
+ #findBar button:hover { background: #3a3a3c; }
3802
+ mark.findhit { background: #f2d64b; color: #111; border-radius: 3px; padding: 0 1px; }
3803
+ mark.findhit.cur { background: #b8f240; }
3538
3804
  </style></head>
3539
3805
  <body>
3540
3806
  <div id="copiedToast" role="status" aria-live="polite">copied</div>
@@ -3547,6 +3813,13 @@ const APP_HTML = `<!doctype html>
3547
3813
  <input id="search" placeholder="Search">
3548
3814
  </div>
3549
3815
  <div id="threads"></div>
3816
+ <div id="dockHud" data-component="dock-hud">
3817
+ <span><span class="dk">spill</span> <span id="dockSpill" class="dv">—</span></span>
3818
+ <span><span class="dk">session</span> <span id="dockSession" class="dv">—</span></span>
3819
+ <span><span class="dk">paid</span> <span id="dockPaid" class="dv">—</span></span>
3820
+ <span><span class="dk">bind</span> <span id="dockBind" class="dv">no</span></span>
3821
+ <span><span class="dk">calls</span> <span id="dockCalls" class="dv">0</span></span>
3822
+ </div>
3550
3823
  </div>
3551
3824
  <div id="composeOverlay">
3552
3825
  <div id="composeBox">
@@ -3607,9 +3880,10 @@ const APP_HTML = `<!doctype html>
3607
3880
  title="Shell commands run immediately, with no approval prompt">auto</button>
3608
3881
  </div>
3609
3882
  <select class="dial" id="tierSel" data-component="model-tier" aria-label="Model tier"
3610
- title="How much to spend per turn when no model is pinned">
3883
+ title="Auto = cheapest model that clears the bar. Other tiers only apply to /race.">
3884
+ <option value="auto" selected>auto</option>
3611
3885
  <option value="cheap">cheap</option>
3612
- <option value="medium" selected>medium</option>
3886
+ <option value="medium">medium</option>
3613
3887
  <option value="expensive">expensive</option>
3614
3888
  <option value="grok4.6">grok 4.6</option>
3615
3889
  </select>
@@ -3647,6 +3921,13 @@ const APP_HTML = `<!doctype html>
3647
3921
  <div class="hfoot" id="hFoot">loading…</div>
3648
3922
  </div>
3649
3923
  <div id="log"></div>
3924
+ <div id="findBar" role="search" data-component="find-in-thread">
3925
+ <input id="findInp" type="search" placeholder="Find in conversation" autocomplete="off" spellcheck="false">
3926
+ <span id="findCount" aria-live="polite"></span>
3927
+ <button type="button" id="findPrev" title="Previous" aria-label="Previous match">↑</button>
3928
+ <button type="button" id="findNext" title="Next" aria-label="Next match">↓</button>
3929
+ <button type="button" id="findClose" title="Close" aria-label="Close find">×</button>
3930
+ </div>
3650
3931
  <div id="bar">
3651
3932
  <div id="plusMenu">
3652
3933
  <div class="pop-item" id="attachBtn">
@@ -3678,6 +3959,17 @@ const APP_HTML = `<!doctype html>
3678
3959
  </div>
3679
3960
  </div>
3680
3961
  <script>
3962
+ function formatSavingLabel(you) {
3963
+ const spent = Number(you && you.spentUsd) || 0;
3964
+ if (spent <= 0) return { text: '—', mult: null, spilled: false };
3965
+ const spillX = Number(you && you.spilled && you.spilled.savingX);
3966
+ const sessionX = (Number(you && you.directUsd) || 0) / spent;
3967
+ const spilled = Number.isFinite(spillX) && spillX > 0;
3968
+ const mult = spilled ? spillX : sessionX;
3969
+ if (!Number.isFinite(mult)) return { text: '—', mult: null, spilled: false };
3970
+ const num = (mult >= 100 ? String(Math.round(mult)) : Number(mult).toFixed(mult >= 10 ? 1 : 2)) + 'x';
3971
+ return { text: num + (spilled ? ' spilled' : ' session'), mult, spilled };
3972
+ }
3681
3973
  const threadsEl = document.getElementById('threads');
3682
3974
  const chatHeader = document.getElementById('chatHeader');
3683
3975
  const log = document.getElementById('log');
@@ -4008,7 +4300,7 @@ const APP_HTML = `<!doctype html>
4008
4300
  const tierSel = document.getElementById('tierSel');
4009
4301
  const raceSel = document.getElementById('raceSel');
4010
4302
  if (!tierSel || !raceSel) return;
4011
- tierSel.value = t.tier || 'medium';
4303
+ tierSel.value = t.tier || 'auto';
4012
4304
  raceSel.value = (t.race || 0) < 2 ? '0'
4013
4305
  : ((t.raceNeed || 1) > 1 ? t.raceNeed + ' ' + t.race : String(t.race));
4014
4306
  // A pinned /model makes BOTH dials inert. Showing them live while they do
@@ -4031,15 +4323,18 @@ const APP_HTML = `<!doctype html>
4031
4323
  }
4032
4324
  }
4033
4325
 
4034
- async function setDial(cmd, value) {
4326
+ // Header dials and Pay / ◎ all go through /drive so handleSlash (or the
4327
+ // /mode handler) appends the same short bot line as typing the command.
4328
+ async function echoSlash(task) {
4035
4329
  if (!activeId) return;
4036
- // Reuses the SAME slash-command path, so there is one implementation of the
4037
- // rule rather than a second that can disagree with it.
4038
4330
  await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
4039
- body: JSON.stringify({ threadId: activeId, task: '/' + cmd + ' ' + value }) });
4331
+ body: JSON.stringify({ threadId: activeId, task: task }) });
4040
4332
  await loadThreads();
4041
4333
  await render();
4042
4334
  }
4335
+ async function setDial(cmd, value) {
4336
+ await echoSlash('/' + cmd + ' ' + value);
4337
+ }
4043
4338
  // WALLET MODAL.
4044
4339
  //
4045
4340
  // Public addresses and balances only — /wallet proxies the proxy's own
@@ -4066,7 +4361,13 @@ const APP_HTML = `<!doctype html>
4066
4361
  return row;
4067
4362
  }
4068
4363
  function isEmptyWalletPayment(text) {
4069
- return /\b(?:wallet is empty|empty wallet|wallet underfunded|underfunded)\b/i.test(String(text || ''));
4364
+ // String ops. A word-boundary regex inside APP_HTML is eaten: \b
4365
+ // becomes a literal backspace and the client never matches a real 402.
4366
+ const s = String(text || '').toLowerCase();
4367
+ return s.includes('wallet is empty')
4368
+ || s.includes('empty wallet')
4369
+ || s.includes('wallet underfunded')
4370
+ || s.includes('underfunded');
4070
4371
  }
4071
4372
  var openedPayForEmpty = false;
4072
4373
  function maybeOpenPayForEmptyWallet(text) {
@@ -4287,7 +4588,10 @@ const APP_HTML = `<!doctype html>
4287
4588
  setSubNote('Subscription key removed. Wallet/x402 is the pay method again.');
4288
4589
  await openWallet();
4289
4590
  }
4290
- document.getElementById('walletBtn').addEventListener('click', openWallet);
4591
+ document.getElementById('walletBtn').addEventListener('click', () => {
4592
+ echoSlash('/pay');
4593
+ openWallet();
4594
+ });
4291
4595
  const subKeyBtn = document.getElementById('subKeyBtn');
4292
4596
  if (subKeyBtn) subKeyBtn.addEventListener('click', savePastedSub);
4293
4597
  const subForgetBtn = document.getElementById('subForgetBtn');
@@ -4356,10 +4660,9 @@ const APP_HTML = `<!doctype html>
4356
4660
  const spent = Number(you.spentUsd) || 0;
4357
4661
  const cogs = Number(you.cogsUsd) || 0;
4358
4662
  const direct = Number(you.directUsd) || 0;
4359
- const mult = spent > 0 ? direct / spent : null;
4360
- const saved = mult == null ? '—'
4361
- : ((mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x');
4362
- const savedCls = mult == null ? '' : (mult >= 1 ? 'hlime' : 'hember');
4663
+ const sav = formatSavingLabel(you);
4664
+ const saved = sav.text;
4665
+ const savedCls = sav.mult == null ? '' : (sav.mult >= 1 ? 'hlime' : 'hember');
4363
4666
  const thinking = (full && full.status === 'thinking') || t.status === 'thinking';
4364
4667
  const race = (full && full.liveRace) || null;
4365
4668
  let flight = 'idle';
@@ -4408,22 +4711,33 @@ const APP_HTML = `<!doctype html>
4408
4711
  setModeButtons(mode); // optimistic: the click should feel instant
4409
4712
  // Reuses the SAME "/mode" path the chat command takes, so there is one
4410
4713
  // implementation of the rule rather than a second one that can disagree.
4411
- await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
4412
- body: JSON.stringify({ threadId: activeId, task: '/mode ' + mode }) });
4413
- await loadThreads(); // refresh runMode + the confirmation line /mode appends
4414
- await render();
4714
+ await echoSlash('/mode ' + mode);
4415
4715
  }
4416
4716
  document.getElementById('modeAsk').addEventListener('click', () => setMode('ask'));
4417
4717
  document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
4418
4718
 
4419
4719
  function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
4420
- function stripThinkTags(s) {
4720
+ function splitThinkTags(s) {
4421
4721
  s = String(s == null ? '' : s);
4422
- s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*?<\\/think(?:ing)?>/gi, '');
4423
- s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*$/i, '');
4722
+ var bits = [];
4723
+ s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*?<\\/think(?:ing)?>/gi, function (m) {
4724
+ var a = m.indexOf('>');
4725
+ var b = m.toLowerCase().lastIndexOf('</think');
4726
+ if (a >= 0 && b > a) bits.push(m.slice(a + 1, b));
4727
+ return '';
4728
+ });
4729
+ s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*$/i, function (m) {
4730
+ var a = m.indexOf('>');
4731
+ bits.push(a >= 0 ? m.slice(a + 1) : '');
4732
+ return '';
4733
+ });
4424
4734
  s = s.replace(/<\\/think(?:ing)?>/gi, '');
4425
- return s.replace(/^\\n+|\\n+$/g, '').trim();
4735
+ return {
4736
+ visible: s.replace(/^\\n+|\\n+$/g, '').trim(),
4737
+ thinking: bits.join('\\n').replace(/^\\n+|\\n+$/g, '').trim()
4738
+ };
4426
4739
  }
4740
+ function stripThinkTags(s) { return splitThinkTags(s).visible; }
4427
4741
  function clientWorkspaceUrl(rel) {
4428
4742
  if (!workspacePort || !activeId) return '';
4429
4743
  rel = String(rel || '').replace(/^\\/+/, '');
@@ -4806,8 +5120,98 @@ const APP_HTML = `<!doctype html>
4806
5120
  }
4807
5121
 
4808
5122
  let lastSpeaker = null;
4809
- function addRow(who, text, color, name, run, images) {
4810
- if (who === 'bot') text = stripThinkTags(text);
5123
+ let liveThinkOpen = false;
5124
+ function thinkLabel(live) { return live ? 'thinking...' : 'thought'; }
5125
+ function makeThinkFold(text, live, open) {
5126
+ const fold = document.createElement('div');
5127
+ fold.className = 'thinkfold' + (open ? ' open' : '');
5128
+ const chip = document.createElement('button');
5129
+ chip.type = 'button';
5130
+ chip.className = 'thinkchip';
5131
+ chip.textContent = thinkLabel(live);
5132
+ chip.setAttribute('aria-expanded', open ? 'true' : 'false');
5133
+ const body = document.createElement('div');
5134
+ body.className = 'thinkbody';
5135
+ if (open) body.textContent = text;
5136
+ chip.addEventListener('click', function (e) {
5137
+ e.preventDefault();
5138
+ const next = !fold.classList.contains('open');
5139
+ fold.classList.toggle('open', next);
5140
+ chip.setAttribute('aria-expanded', next ? 'true' : 'false');
5141
+ if (live) liveThinkOpen = next;
5142
+ if (next) body.textContent = fold.getAttribute('data-think') || text || '';
5143
+ else body.textContent = '';
5144
+ if (live) paintStream();
5145
+ });
5146
+ fold.setAttribute('data-think', text || '');
5147
+ fold.appendChild(chip);
5148
+ fold.appendChild(body);
5149
+ return fold;
5150
+ }
5151
+ function runFoldLabel(status) {
5152
+ if (status === 'pending') return 'run';
5153
+ if (status === 'running') return 'running...';
5154
+ if (status === 'denied') return 'denied';
5155
+ return 'ran';
5156
+ }
5157
+ function parseLegacyRun(text) {
5158
+ const raw = String(text || '');
5159
+ if (!raw.startsWith('$ ')) return null;
5160
+ const nl = raw.indexOf('\\n');
5161
+ if (nl < 0) return { command: raw.slice(2), output: '', status: 'done' };
5162
+ return { command: raw.slice(2, nl), output: raw.slice(nl + 1), status: 'done' };
5163
+ }
5164
+ function makeRunFold(command, output, status) {
5165
+ const pending = status === 'pending' || status === 'running';
5166
+ const fold = document.createElement('div');
5167
+ fold.className = 'runfold' + (pending ? ' open' : '');
5168
+ const chip = document.createElement('button');
5169
+ chip.type = 'button';
5170
+ chip.className = 'runchip';
5171
+ chip.textContent = runFoldLabel(status);
5172
+ chip.setAttribute('aria-expanded', pending ? 'true' : 'false');
5173
+ const body = document.createElement('div');
5174
+ body.className = 'runbody';
5175
+ function fillBody() {
5176
+ body.innerHTML = '';
5177
+ const cmdEl = document.createElement('div');
5178
+ cmdEl.className = 'runcmd';
5179
+ cmdEl.textContent = '$ ' + command;
5180
+ cmdEl.appendChild(copyBtn(() => command, 'copy'));
5181
+ body.appendChild(cmdEl);
5182
+ if (status && status !== 'pending') {
5183
+ const st = document.createElement('div');
5184
+ st.className = 'runstatus';
5185
+ st.textContent = status === 'running' ? 'Running…' : status === 'denied' ? 'Denied' : 'Done';
5186
+ body.appendChild(st);
5187
+ }
5188
+ if (output) {
5189
+ const out = document.createElement('pre');
5190
+ out.className = 'runoutput';
5191
+ out.textContent = output;
5192
+ out.appendChild(copyBtn(() => output, 'copy'));
5193
+ body.appendChild(out);
5194
+ }
5195
+ }
5196
+ if (pending) fillBody();
5197
+ chip.addEventListener('click', function (e) {
5198
+ e.preventDefault();
5199
+ const next = !fold.classList.contains('open');
5200
+ fold.classList.toggle('open', next);
5201
+ chip.setAttribute('aria-expanded', next ? 'true' : 'false');
5202
+ if (next) fillBody();
5203
+ else body.innerHTML = '';
5204
+ });
5205
+ fold.appendChild(chip);
5206
+ fold.appendChild(body);
5207
+ return fold;
5208
+ }
5209
+ function addRow(who, text, color, name, run, images, thinking, live) {
5210
+ if (who === 'bot') {
5211
+ const parts = splitThinkTags(text);
5212
+ text = parts.visible;
5213
+ if (!thinking) thinking = parts.thinking;
5214
+ }
4811
5215
  const speakerKey = who + '|' + name;
4812
5216
  if (who === 'bot' && speakerKey !== lastSpeaker) {
4813
5217
  const hdr = document.createElement('div');
@@ -4818,17 +5222,25 @@ const APP_HTML = `<!doctype html>
4818
5222
  lastSpeaker = speakerKey;
4819
5223
  const row = document.createElement('div');
4820
5224
  row.className = 'row ' + who;
5225
+ const col = document.createElement('div');
5226
+ col.className = 'msgcol';
5227
+ const thinkText = (who === 'bot' && thinking) ? String(thinking).trim() : '';
5228
+ if (live) {
5229
+ const fold = makeThinkFold(thinkText, true, liveThinkOpen && !!thinkText);
5230
+ fold.id = 'streamThink';
5231
+ if (!thinkText) fold.hidden = true;
5232
+ col.appendChild(fold);
5233
+ } else if (thinkText) {
5234
+ col.appendChild(makeThinkFold(thinkText, false, false));
5235
+ }
4821
5236
  if (run) {
5237
+ const cmd = run.command || text;
5238
+ const st = run.status || 'pending';
5239
+ const pending = st === 'pending' || st === 'running';
4822
5240
  const card = document.createElement('div');
4823
- card.className = 'runcard';
4824
- const cmdEl = document.createElement('div');
4825
- cmdEl.className = 'runcmd';
4826
- cmdEl.textContent = '$ ' + text;
4827
- // Copy WITHOUT the '$ ' prompt — pasting that into a shell is a syntax
4828
- // error, and this is the single most re-run thing in the UI.
4829
- cmdEl.appendChild(copyBtn(() => text, 'copy'));
4830
- card.appendChild(cmdEl);
4831
- if (run.status === 'pending') {
5241
+ card.className = 'runcard' + (pending ? ' pending' : ' folded');
5242
+ card.appendChild(makeRunFold(cmd, run.output || '', st));
5243
+ if (run.id && st === 'pending') {
4832
5244
  const actions = document.createElement('div');
4833
5245
  actions.className = 'runactions';
4834
5246
  const approve = document.createElement('button');
@@ -4850,20 +5262,8 @@ const APP_HTML = `<!doctype html>
4850
5262
  actions.appendChild(approve);
4851
5263
  actions.appendChild(deny);
4852
5264
  card.appendChild(actions);
4853
- } else {
4854
- const status = document.createElement('div');
4855
- status.className = 'runstatus';
4856
- status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
4857
- card.appendChild(status);
4858
- if (run.output) {
4859
- const out = document.createElement('pre');
4860
- out.className = 'runoutput';
4861
- out.textContent = run.output;
4862
- out.appendChild(copyBtn(() => run.output, 'copy'));
4863
- card.appendChild(out);
4864
- }
4865
5265
  }
4866
- row.appendChild(card);
5266
+ col.appendChild(card);
4867
5267
  } else {
4868
5268
  const bubble = document.createElement('div');
4869
5269
  bubble.className = 'bubble';
@@ -4893,7 +5293,7 @@ const APP_HTML = `<!doctype html>
4893
5293
  maybeOpenPayForEmptyWallet(text);
4894
5294
  }
4895
5295
  bubble.appendChild(textEl);
4896
- row.appendChild(bubble);
5296
+ col.appendChild(bubble);
4897
5297
  // Copy the message SOURCE, not rendered HTML — markdown, code fences and
4898
5298
  // directive lines are what people want back; innerText drops fences and
4899
5299
  // mangles indentation.
@@ -4909,6 +5309,7 @@ const APP_HTML = `<!doctype html>
4909
5309
  }, 'copy'));
4910
5310
  }
4911
5311
  }
5312
+ row.appendChild(col);
4912
5313
  log.appendChild(row);
4913
5314
  }
4914
5315
 
@@ -4921,10 +5322,10 @@ const APP_HTML = `<!doctype html>
4921
5322
  const full = await loadActiveMessages();
4922
5323
  if (!full || full.id !== activeId) return;
4923
5324
  const renderKey = String(workspacePort) + '|' + full.id + '|' + full.status + '|' + (full.history || []).map(function (h) {
4924
- return [h.who, h.text, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
5325
+ return [h.who, h.text, h.thinking, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
4925
5326
  }).join('||');
4926
5327
  if (renderKey === lastRenderKey) {
4927
- if (streamBuf) paintStream();
5328
+ if (streamBuf || streamThink) paintStream();
4928
5329
  return;
4929
5330
  }
4930
5331
  lastRenderKey = renderKey;
@@ -4935,8 +5336,14 @@ const APP_HTML = `<!doctype html>
4935
5336
  log.innerHTML = '';
4936
5337
  lastSpeaker = null;
4937
5338
  for (const h of full.history) {
4938
- addRow(h.who, h.text, h.color || t.color, h.name || t.name,
4939
- h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
5339
+ let run;
5340
+ if (h.runId || h.runStatus) {
5341
+ run = { id: h.runId, status: h.runStatus, output: h.runOutput, command: h.text };
5342
+ } else if (h.who === 'bot') {
5343
+ const legacy = parseLegacyRun(h.text);
5344
+ if (legacy) run = { status: legacy.status, output: legacy.output, command: legacy.command };
5345
+ }
5346
+ addRow(h.who, h.text, h.color || t.color, h.name || t.name, run, h.images, h.thinking);
4940
5347
  }
4941
5348
  if (full.status === 'thinking') {
4942
5349
  if (full.liveStatus) streamStatus = full.liveStatus;
@@ -4946,19 +5353,21 @@ const APP_HTML = `<!doctype html>
4946
5353
  } else if (streamRaceId !== full.id) {
4947
5354
  streamRace = null;
4948
5355
  }
4949
- addRow('bot', streamBuf || '…', t.color, t.name);
5356
+ addRow('bot', streamBuf || '…', t.color, t.name, undefined, undefined, streamThink, true);
4950
5357
  // Tag the live bubble so deltas can repaint just this node instead of
4951
5358
  // re-rendering (and re-fetching) the whole thread on every token.
4952
5359
  const b = log.querySelector('.row:last-child .bubble');
4953
5360
  if (b) { b.id = 'streamBubble'; paintStream(); }
4954
5361
  }
4955
5362
  if (wasNearBottom) log.scrollTop = log.scrollHeight;
5363
+ if (findBarOpen()) applyFind(true);
4956
5364
  }
4957
5365
 
4958
5366
  // --- live token stream ---------------------------------------------------
4959
5367
  // The server has always been able to stream; /drive just never asked for it,
4960
5368
  // so a turn showed "…" for its whole duration and then arrived in one lump.
4961
5369
  let streamBuf = '';
5370
+ let streamThink = '';
4962
5371
  let streamStatus = '';
4963
5372
  let streamRace = null;
4964
5373
  let streamRaceId = '';
@@ -5012,44 +5421,76 @@ const APP_HTML = `<!doctype html>
5012
5421
  return '<div class="racewrap"><div class="racecaption">' + escapeHtml(caption) + '</div>'
5013
5422
  + '<div class="racegrid n' + n + '">' + cells + '</div>' + judge + '</div>';
5014
5423
  }
5424
+ function streamParts() {
5425
+ const parts = splitThinkTags(streamBuf);
5426
+ var think = streamThink;
5427
+ if (parts.thinking) think = think ? (think + '\\n' + parts.thinking) : parts.thinking;
5428
+ return { visible: parts.visible, think: think };
5429
+ }
5015
5430
  function liveBubbleHtml() {
5016
5431
  if (raceIsLive(streamRace)) return raceGridHtml(streamRace);
5017
- if (streamBuf) {
5432
+ const vis = streamParts().visible;
5433
+ if (vis) {
5018
5434
  const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
5019
5435
  ? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
5020
- return escapeHtml(stripThinkTags(streamBuf)) + trail;
5436
+ return escapeHtml(vis) + trail;
5021
5437
  }
5022
5438
  const dots = '<span class="dots"><span></span><span></span><span></span></span>';
5023
5439
  const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
5024
5440
  return dots + (st ? ' ' + st : '');
5025
5441
  }
5442
+ function paintThinkFoldLive(think) {
5443
+ const fold = document.getElementById('streamThink');
5444
+ if (!fold) return;
5445
+ const chip = fold.querySelector('.thinkchip');
5446
+ const body = fold.querySelector('.thinkbody');
5447
+ const has = !!(think && String(think).trim());
5448
+ fold.hidden = !has;
5449
+ if (!has) {
5450
+ if (body) body.textContent = '';
5451
+ return;
5452
+ }
5453
+ fold.setAttribute('data-think', think);
5454
+ fold.classList.toggle('open', !!liveThinkOpen);
5455
+ if (chip) {
5456
+ chip.textContent = 'thinking...';
5457
+ chip.setAttribute('aria-expanded', liveThinkOpen ? 'true' : 'false');
5458
+ }
5459
+ if (body) body.textContent = liveThinkOpen ? think : '';
5460
+ }
5026
5461
  function paintStream() {
5027
5462
  const b = document.getElementById('streamBubble');
5028
5463
  if (!b) { render(); return; }
5464
+ const parts = streamParts();
5465
+ paintThinkFoldLive(parts.think);
5029
5466
  if (raceIsLive(streamRace)) {
5030
5467
  b.classList.add('raceboard');
5031
5468
  b.innerHTML = liveBubbleHtml();
5032
5469
  if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
5470
+ scheduleFindPaint();
5033
5471
  return;
5034
5472
  }
5035
5473
  b.classList.remove('raceboard');
5036
- // Deltas stay as text; a silent wait paints dots + one mutating status
5037
- // line so a 20–40s pay/model wait is obviously alive.
5038
- if (streamBuf && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
5039
- b.textContent = streamBuf;
5474
+ // Visible tokens only. Chain-of-thought stays in the fold and only
5475
+ // paints into the fold body when the user has unfurled it.
5476
+ if (parts.visible && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
5477
+ b.textContent = parts.visible;
5040
5478
  } else {
5041
5479
  b.innerHTML = liveBubbleHtml();
5042
5480
  }
5043
5481
  if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
5482
+ scheduleFindPaint();
5044
5483
  }
5045
5484
  function connectStream(id) {
5046
5485
  if (!id || esId === id) return;
5047
5486
  if (es) es.close();
5048
5487
  esId = id;
5049
5488
  streamBuf = '';
5489
+ streamThink = '';
5050
5490
  streamStatus = '';
5051
5491
  streamRace = null;
5052
5492
  streamRaceId = id;
5493
+ liveThinkOpen = false;
5053
5494
  raceHandoff += 1;
5054
5495
  es = new EventSource('/stream/' + id); // EventSource reconnects on its own
5055
5496
  es.onmessage = (e) => {
@@ -5057,9 +5498,11 @@ const APP_HTML = `<!doctype html>
5057
5498
  try { ev = JSON.parse(e.data); } catch { return; }
5058
5499
  if (ev.type === 'start') {
5059
5500
  streamBuf = '';
5501
+ streamThink = '';
5060
5502
  streamStatus = ev.detail || 'waiting on model…';
5061
5503
  streamRace = null;
5062
5504
  streamRaceId = id;
5505
+ liveThinkOpen = false;
5063
5506
  paintStream();
5064
5507
  }
5065
5508
  else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
@@ -5070,8 +5513,13 @@ const APP_HTML = `<!doctype html>
5070
5513
  }
5071
5514
  paintStream();
5072
5515
  }
5516
+ else if (ev.type === 'think') {
5517
+ streamThink += ev.delta || '';
5518
+ paintStream();
5519
+ }
5073
5520
  else if (ev.type === 'delta') {
5074
5521
  streamBuf = ev.replace ? (ev.delta || '') : streamBuf + (ev.delta || '');
5522
+ if (ev.replace) streamThink = '';
5075
5523
  paintStream();
5076
5524
  }
5077
5525
  else if (ev.type === 'final' || ev.type === 'run-pending') {
@@ -5081,6 +5529,7 @@ const APP_HTML = `<!doctype html>
5081
5529
  setTimeout(function () {
5082
5530
  if (token !== raceHandoff) return;
5083
5531
  streamBuf = '';
5532
+ streamThink = '';
5084
5533
  streamStatus = '';
5085
5534
  streamRace = null;
5086
5535
  render();
@@ -5088,6 +5537,7 @@ const APP_HTML = `<!doctype html>
5088
5537
  return;
5089
5538
  }
5090
5539
  streamBuf = '';
5540
+ streamThink = '';
5091
5541
  streamStatus = '';
5092
5542
  streamRace = null;
5093
5543
  render();
@@ -5148,7 +5598,11 @@ const APP_HTML = `<!doctype html>
5148
5598
 
5149
5599
  async function submit() {
5150
5600
  const task = inp.value.trim();
5151
- if (/^\/sitrep\b/i.test(task)) {
5601
+ // String compare — never a regex. A sitrep word-boundary regex inside
5602
+ // this template literal is eaten by the backtick parser and the whole
5603
+ // client script dies (empty sidebar, send is a no-op).
5604
+ const s = String(task).trim().toLowerCase();
5605
+ if (s === '/sitrep' || s.startsWith('/sitrep ')) {
5152
5606
  inp.value = '';
5153
5607
  send.classList.remove('show');
5154
5608
  openSitrep();
@@ -5366,26 +5820,196 @@ const APP_HTML = `<!doctype html>
5366
5820
  // It cannot change the version baked into the IMAGE — only the site's spawn
5367
5821
  // path can — so it says restart, not update. Promising an upgrade it cannot
5368
5822
  // deliver is how a UI teaches people to distrust it.
5369
- // Cmd/Ctrl+K -> search, the shortcut every chat app trains you to expect.
5370
- // Escape returns focus to the composer instead of leaving you stranded in
5371
- // a box you just cleared. Bound on keydown at the document so it works no
5372
- // matter which pane has focus.
5823
+ // Cmd/Ctrl+K -> sidebar thread search (GET /search). Cmd/Ctrl+F is find
5824
+ // inside the current #log a different box, on purpose. Bound on document
5825
+ // keydown so it works no matter which pane has focus; preventDefault so
5826
+ // Chromium cannot swallow F for a page-find that was never wired.
5373
5827
  document.addEventListener('keydown', (e) => {
5374
5828
  const k = (e.key || '').toLowerCase();
5375
- if ((e.metaKey || e.ctrlKey) && k === 'k') {
5829
+ const withMod = e.metaKey || e.ctrlKey;
5830
+ if (withMod && k === 'k') {
5376
5831
  e.preventDefault();
5377
5832
  const el = document.getElementById('search');
5378
5833
  if (el) { el.focus(); el.select(); }
5379
5834
  return;
5380
5835
  }
5836
+ if (withMod && k === 'f') {
5837
+ e.preventDefault();
5838
+ openFindBar();
5839
+ return;
5840
+ }
5841
+ if (withMod && k === 'g' && findBarOpen()) {
5842
+ e.preventDefault();
5843
+ findStep(e.shiftKey ? -1 : 1);
5844
+ return;
5845
+ }
5846
+ if (k === 'escape' && findBarOpen()) {
5847
+ e.preventDefault();
5848
+ closeFindBar();
5849
+ return;
5850
+ }
5381
5851
  // Cmd/Ctrl+Enter sends from anywhere — useful when focus drifted into a
5382
5852
  // RUN card or a copy button mid-thought.
5383
- if ((e.metaKey || e.ctrlKey) && k === 'enter') {
5853
+ if (withMod && k === 'enter') {
5384
5854
  e.preventDefault();
5385
5855
  try { submit(); } catch (err) { /* not ready */ }
5386
5856
  }
5387
5857
  });
5388
5858
 
5859
+ // FIND IN THREAD. Highlights visible .bubble text only — not sidebar
5860
+ // threads, not think folds, not RUN cards, not tool JSON dumps.
5861
+ let findMarks = [];
5862
+ let findIndex = 0;
5863
+ let findPaintTimer = null;
5864
+ const findBar = document.getElementById('findBar');
5865
+ const findInp = document.getElementById('findInp');
5866
+ const findCount = document.getElementById('findCount');
5867
+ function scheduleFindPaint() {
5868
+ if (!findBarOpen()) return;
5869
+ clearTimeout(findPaintTimer);
5870
+ findPaintTimer = setTimeout(function () { applyFind(true); }, 160);
5871
+ }
5872
+ function findBarOpen() {
5873
+ const el = document.getElementById('findBar');
5874
+ return !!(el && el.classList.contains('show'));
5875
+ }
5876
+ function placeFindBar() {
5877
+ const bar = document.getElementById('findBar');
5878
+ const main = document.getElementById('main');
5879
+ if (!bar || !chatHeader || !main) return;
5880
+ const top = chatHeader.getBoundingClientRect().bottom - main.getBoundingClientRect().top + 8;
5881
+ bar.style.top = Math.max(8, Math.round(top)) + 'px';
5882
+ }
5883
+ function clearFindMarks() {
5884
+ const logEl = document.getElementById('log');
5885
+ if (!logEl) { findMarks = []; return; }
5886
+ const marks = logEl.querySelectorAll('mark.findhit');
5887
+ for (let i = 0; i < marks.length; i++) {
5888
+ const mark = marks[i];
5889
+ const parent = mark.parentNode;
5890
+ if (!parent) continue;
5891
+ parent.replaceChild(document.createTextNode(mark.textContent), mark);
5892
+ parent.normalize();
5893
+ }
5894
+ findMarks = [];
5895
+ }
5896
+ function paintFindCount() {
5897
+ if (!findCount) return;
5898
+ const n = findMarks.length;
5899
+ findCount.textContent = n ? ((findIndex + 1) + ' / ' + n) : (findInp && findInp.value.trim() ? '0 / 0' : '');
5900
+ }
5901
+ function skipFindNode(node) {
5902
+ const p = node && node.parentNode;
5903
+ if (!p || !p.closest) return true;
5904
+ return !!p.closest('.copybtn, .html-preview-wrap, button, script, style');
5905
+ }
5906
+ function highlightTextNode(node, query) {
5907
+ const text = node.nodeValue || '';
5908
+ const hay = text.toLowerCase();
5909
+ const needle = query.toLowerCase();
5910
+ if (!needle) return;
5911
+ let from = 0;
5912
+ const parts = [];
5913
+ let idx = hay.indexOf(needle, from);
5914
+ while (idx !== -1) {
5915
+ if (idx > from) parts.push({ t: text.slice(from, idx), hit: false });
5916
+ parts.push({ t: text.slice(idx, idx + needle.length), hit: true });
5917
+ from = idx + needle.length;
5918
+ idx = hay.indexOf(needle, from);
5919
+ }
5920
+ if (!parts.length) return;
5921
+ if (from < text.length) parts.push({ t: text.slice(from), hit: false });
5922
+ const frag = document.createDocumentFragment();
5923
+ for (let i = 0; i < parts.length; i++) {
5924
+ if (parts[i].hit) {
5925
+ const mark = document.createElement('mark');
5926
+ mark.className = 'findhit';
5927
+ mark.textContent = parts[i].t;
5928
+ findMarks.push(mark);
5929
+ frag.appendChild(mark);
5930
+ } else {
5931
+ frag.appendChild(document.createTextNode(parts[i].t));
5932
+ }
5933
+ }
5934
+ node.parentNode.replaceChild(frag, node);
5935
+ }
5936
+ function applyFind(preserve) {
5937
+ const q = findInp ? findInp.value.trim() : '';
5938
+ const keep = preserve ? findIndex : 0;
5939
+ clearFindMarks();
5940
+ if (!q) { findIndex = 0; paintFindCount(); return; }
5941
+ const logEl = document.getElementById('log');
5942
+ if (!logEl) { paintFindCount(); return; }
5943
+ const bubbles = logEl.querySelectorAll('.bubble');
5944
+ for (let b = 0; b < bubbles.length; b++) {
5945
+ const nodes = [];
5946
+ const walker = document.createTreeWalker(bubbles[b], NodeFilter.SHOW_TEXT, null);
5947
+ let n = walker.nextNode();
5948
+ while (n) {
5949
+ if (n.nodeValue && !skipFindNode(n)) nodes.push(n);
5950
+ n = walker.nextNode();
5951
+ }
5952
+ for (let i = 0; i < nodes.length; i++) highlightTextNode(nodes[i], q);
5953
+ }
5954
+ if (!findMarks.length) { findIndex = 0; paintFindCount(); return; }
5955
+ findIndex = keep % findMarks.length;
5956
+ if (findIndex < 0) findIndex = 0;
5957
+ focusFindHit(findIndex);
5958
+ }
5959
+ function focusFindHit(i) {
5960
+ for (let m = 0; m < findMarks.length; m++) findMarks[m].classList.remove('cur');
5961
+ const mark = findMarks[i];
5962
+ if (!mark) { paintFindCount(); return; }
5963
+ mark.classList.add('cur');
5964
+ if (mark.scrollIntoView) mark.scrollIntoView({ block: 'center', inline: 'nearest' });
5965
+ paintFindCount();
5966
+ }
5967
+ function findStep(dir) {
5968
+ if (!findMarks.length) return;
5969
+ findIndex = (findIndex + dir + findMarks.length) % findMarks.length;
5970
+ focusFindHit(findIndex);
5971
+ }
5972
+ function openFindBar() {
5973
+ if (!findBar || !findInp) return;
5974
+ findBar.classList.add('show');
5975
+ placeFindBar();
5976
+ findInp.focus();
5977
+ findInp.select();
5978
+ if (findInp.value.trim()) applyFind(true);
5979
+ else paintFindCount();
5980
+ }
5981
+ function closeFindBar() {
5982
+ if (findBar) findBar.classList.remove('show');
5983
+ clearFindMarks();
5984
+ findIndex = 0;
5985
+ if (findCount) findCount.textContent = '';
5986
+ if (inp) inp.focus();
5987
+ }
5988
+ if (findInp) {
5989
+ findInp.addEventListener('input', () => applyFind(false));
5990
+ findInp.addEventListener('keydown', (e) => {
5991
+ if (e.key === 'Enter') {
5992
+ e.preventDefault();
5993
+ findStep(e.shiftKey ? -1 : 1);
5994
+ }
5995
+ if (e.key === 'Escape') {
5996
+ e.preventDefault();
5997
+ closeFindBar();
5998
+ }
5999
+ });
6000
+ }
6001
+ if (findBar) {
6002
+ const prevBtn = document.getElementById('findPrev');
6003
+ const nextBtn = document.getElementById('findNext');
6004
+ const closeBtn = document.getElementById('findClose');
6005
+ if (prevBtn) prevBtn.addEventListener('click', () => findStep(-1));
6006
+ if (nextBtn) nextBtn.addEventListener('click', () => findStep(1));
6007
+ if (closeBtn) closeBtn.addEventListener('click', closeFindBar);
6008
+ }
6009
+ if (window.electronAPI && typeof window.electronAPI.onFindInThread === 'function') {
6010
+ window.electronAPI.onFindInThread(function () { openFindBar(); });
6011
+ }
6012
+
5389
6013
  const reloadBtn = document.getElementById('reloadBtn');
5390
6014
  if (reloadBtn) {
5391
6015
  reloadBtn.addEventListener('click', async () => {
@@ -5413,6 +6037,32 @@ const APP_HTML = `<!doctype html>
5413
6037
  const top = chatHeader.getBoundingClientRect().bottom - mainEl.getBoundingClientRect().top + 8;
5414
6038
  hud.style.top = Math.max(8, Math.round(top)) + 'px';
5415
6039
  }
6040
+ function fmtDockX(n) {
6041
+ if (n == null || !Number.isFinite(n)) return '—';
6042
+ return (n >= 100 ? String(Math.round(n)) : Number(n).toFixed(n >= 10 ? 1 : 2)) + 'x';
6043
+ }
6044
+ function paintDock(you) {
6045
+ const spillEl = document.getElementById('dockSpill');
6046
+ const sessEl = document.getElementById('dockSession');
6047
+ const paidEl = document.getElementById('dockPaid');
6048
+ const bindEl = document.getElementById('dockBind');
6049
+ const callsEl = document.getElementById('dockCalls');
6050
+ if (!spillEl || !sessEl || !paidEl || !bindEl || !callsEl) return;
6051
+ const spent = Number(you && you.spentUsd) || 0;
6052
+ const direct = Number(you && you.directUsd) || 0;
6053
+ const spillX = Number(you && you.spilled && you.spilled.savingX);
6054
+ const spillCalls = Number(you && you.spilled && you.spilled.calls) || 0;
6055
+ const spillOn = Number.isFinite(spillX) && spillX > 0;
6056
+ const bound = spillOn || spillCalls > 0;
6057
+ const sessionX = spent > 0 ? direct / spent : null;
6058
+ spillEl.textContent = spillOn ? fmtDockX(spillX) : '—';
6059
+ spillEl.className = spillOn ? 'dv hlime' : 'dv';
6060
+ sessEl.textContent = sessionX == null ? '—' : fmtDockX(sessionX);
6061
+ sessEl.className = 'dv';
6062
+ paidEl.textContent = usd(spent);
6063
+ bindEl.textContent = bound ? 'yes' : 'no';
6064
+ callsEl.textContent = String((you && you.paidCalls) || 0);
6065
+ }
5416
6066
  function usd(n) {
5417
6067
  if (n === null || n === undefined) return '—';
5418
6068
  if (n === 0) return '$0';
@@ -5453,13 +6103,15 @@ const APP_HTML = `<!doctype html>
5453
6103
  const savedEl = document.getElementById('hYouSaved');
5454
6104
  const hintEl = document.getElementById('hHint');
5455
6105
  if (spent > 0) {
5456
- const mult = direct / spent;
6106
+ const sav = formatSavingLabel(you);
6107
+ const mult = sav.mult;
5457
6108
  // honest either way: >=1x is a real saving vs a naked direct call,
5458
6109
  // <1x means you're currently paying MORE than direct would cost —
5459
6110
  // don't dress that up as green when it isn't one.
5460
6111
  // Asking a bound corpus makes this genuinely large (the counterfactual
5461
6112
  // is shipping the WHOLE corpus), so 2dp would read as noise up there.
5462
- savedEl.textContent = (mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x';
6113
+ // Label the number: "Nx spilled" when bound, else "Nx session".
6114
+ savedEl.textContent = sav.text;
5463
6115
  savedEl.className = mult >= 1 ? 'hlime' : 'hember';
5464
6116
  // Session direct/spent (never first-call). Ember when cogs > spent —
5465
6117
  // house losing. Do not treat race_unused as a user refund.
@@ -5471,7 +6123,7 @@ const APP_HTML = `<!doctype html>
5471
6123
  hintEl.className = mult >= 1 ? 'hhint' : 'hhint show';
5472
6124
  hintEl.innerHTML = '<b>feed it more.</b> you\\'re billed on the slice actually sent, '
5473
6125
  + 'not the corpus — so the more you bind, the further ahead this gets. '
5474
- + 'small inputs cost more than sending them straight.';
6126
+ + 'small inputs cost more than sending them straight. HUD is spilled-call x when any call bound.';
5475
6127
  }
5476
6128
  } else {
5477
6129
  savedEl.textContent = '—';
@@ -5483,25 +6135,38 @@ const APP_HTML = `<!doctype html>
5483
6135
  }
5484
6136
  }
5485
6137
  document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
6138
+ paintDock(you);
5486
6139
  } catch (e) {
5487
6140
  document.getElementById('hFoot').textContent = 'error: ' + e.message;
5488
6141
  }
5489
6142
  }
5490
6143
  let hudTimer = null;
6144
+ function ensureHudTick() {
6145
+ if (hudTimer) return;
6146
+ hudTimer = setInterval(refreshHud, 30000);
6147
+ }
5491
6148
  hudBtn.addEventListener('click', (e) => {
5492
6149
  e.stopPropagation();
5493
6150
  hud.classList.toggle('show');
5494
6151
  if (hud.classList.contains('show')) {
5495
6152
  placeHud();
5496
6153
  refreshHud();
5497
- hudTimer = setInterval(refreshHud, 30000);
5498
- } else if (hudTimer) {
5499
- clearInterval(hudTimer); hudTimer = null;
5500
6154
  }
6155
+ echoSlash('/hud');
6156
+ });
6157
+ refreshHud();
6158
+ ensureHudTick();
6159
+ window.addEventListener('resize', () => {
6160
+ if (hud.classList.contains('show')) placeHud();
6161
+ if (findBarOpen()) placeFindBar();
5501
6162
  });
5502
- window.addEventListener('resize', () => { if (hud.classList.contains('show')) placeHud(); });
5503
- if (typeof ResizeObserver !== 'undefined' && chatHeader) {
5504
- new ResizeObserver(() => { if (hud.classList.contains('show')) placeHud(); }).observe(chatHeader);
6163
+ if (typeof ResizeObserver !== 'undefined') {
6164
+ if (chatHeader) {
6165
+ new ResizeObserver(() => {
6166
+ if (hud.classList.contains('show')) placeHud();
6167
+ if (findBarOpen()) placeFindBar();
6168
+ }).observe(chatHeader);
6169
+ }
5505
6170
  }
5506
6171
  document.addEventListener('click', (e) => { if (!hud.contains(e.target)) hud.classList.remove('show'); });
5507
6172
  </script>
@@ -5682,6 +6347,7 @@ const server = http.createServer((req, res) => {
5682
6347
  let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
5683
6348
  try { you = { ...you, ...(await (await fetch('http://127.0.0.1:8402/v1/session', { signal: AbortSignal.timeout(2000) })).json()) }; }
5684
6349
  catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
6350
+ await attachSpilled(you);
5685
6351
  try {
5686
6352
  you.creditUsd = await creditBalance();
5687
6353
  } catch { /* credit is advisory */ }
@@ -5856,20 +6522,24 @@ const server = http.createServer((req, res) => {
5856
6522
  threadId = j.threadId; task = (j.task || '').toString();
5857
6523
  images = Array.isArray(j.images) ? j.images.filter((u) => typeof u === 'string') : [];
5858
6524
  } catch { /* ignore */ }
5859
- res.writeHead(200, { 'content-type': 'application/json' });
5860
- res.end(JSON.stringify({ ok: true }));
5861
6525
  const t = threads.get(threadId);
6526
+ const driveOk = () => {
6527
+ if (res.headersSent) return;
6528
+ res.writeHead(200, { 'content-type': 'application/json' });
6529
+ res.end(JSON.stringify({ ok: true }));
6530
+ };
5862
6531
  // Every OTHER slash command. Local, free, instant — no model call, so
5863
6532
  // checking your spend or clearing a thread never costs anything.
5864
6533
  // /dir and /mode keep their own handlers below, untouched.
5865
6534
  if (t && /^\//.test(task.trim())) {
5866
6535
  // Drawer-only. Never dump sitrep into the transcript.
5867
- if (/^\/sitrep\b/i.test(task.trim())) return;
6536
+ if (/^\/sitrep\b/i.test(task.trim())) { driveOk(); return; }
5868
6537
  const handled = await handleSlash(task.trim(), t).catch((e) => `error: ${e.message}`);
5869
6538
  if (handled !== null && handled !== undefined) {
5870
6539
  t.history.push({ who: 'bot', text: handled });
5871
6540
  t.lastActivityAt = Date.now();
5872
6541
  saveThreads();
6542
+ driveOk();
5873
6543
  return;
5874
6544
  }
5875
6545
  }
@@ -5888,19 +6558,22 @@ const server = http.createServer((req, res) => {
5888
6558
  t.history.push({ who: 'bot', text: `"${full}" isn't a directory that exists.` });
5889
6559
  }
5890
6560
  saveThreads();
6561
+ driveOk();
5891
6562
  return;
5892
6563
  }
5893
- // "/mode auto|ask" toggles whether RUN: commands execute immediately
5894
- // or wait for an explicit approve/deny also free/instant, no model call
6564
+ // "/mode auto|ask" Auto is the Claude Code harness (openzoo claude
6565
+ // env). Ask stays a chat completion; RUN: waits for approve/deny.
5895
6566
  const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
5896
6567
  if (modeCmd && t) {
5897
6568
  t.runMode = modeCmd[1];
5898
- t.history.push({ who: 'bot', text: `Run mode set to ${modeCmd[1]}${modeCmd[1] === 'auto' ? ' — commands execute immediately, no approval.' : ' — commands wait for your approval.'}` });
6569
+ t.history.push({ who: 'bot', text: `Run mode set to ${modeCmd[1]}${modeCmd[1] === 'auto' ? ' — Claude Code via OpenZoo (x402). Native tools, not RUN: text.' : ' — chat completion; RUN: waits for your approval.'}` });
5899
6570
  saveThreads();
6571
+ driveOk();
5900
6572
  return;
5901
6573
  }
5902
6574
  // Stream to whoever is watching this thread. emitToThread is a no-op
5903
6575
  // when nobody is, so a spawned subagent nobody has open costs nothing.
6576
+ driveOk();
5904
6577
  runTurn(threadId, task, (ev) => emitToThread(threadId, ev), images).catch(() => {});
5905
6578
  });
5906
6579
  return;
@@ -5913,12 +6586,14 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
5913
6586
 
5914
6587
  export {
5915
6588
  tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
5916
- parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
5917
- handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, runTurn,
6589
+ parseRun, looksLikeMcpAsBash, stripThinkTags, takeThink, safeResolveIn, inDir, listDir,
6590
+ handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, setClaudeRunnerForTest, runTurn,
6591
+ runAutoClaudeTurn, autoClaudePrompt,
5918
6592
  AUTO_CONTINUE, AUTO_RACE_RETRY, AUTO_EMPTY_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
5919
6593
  isDoneReply, isTransientModelFail, isPaymentFailed, isEmptyWalletPayment, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
5920
6594
  attachChildDir, finishChildDir,
5921
6595
  lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
5922
6596
  filesForCorpus, noteFileForCorpus, noteRunForCorpus, resetFilesForCorpus,
5923
6597
  filesForCorpusKeys, scheduleFilesForCorpus, inFlightChars, BIND_MIN_CHARS, KEEP_MAX,
6598
+ formatSavingLabel, holobrainOf, looksLikeProxyShell, CHAT_NOT_PROXY, PROXY_SHELL_REFUSE,
5924
6599
  };