openzoo 0.49.7 → 0.49.9

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
@@ -8,12 +8,14 @@
8
8
  import { exec } from 'node:child_process';
9
9
  import http from 'node:http';
10
10
  import { randomUUID } from 'node:crypto';
11
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
11
+ import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, writeFileSync, writeSync } from 'node:fs';
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,11 @@ 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
+ sanitizeClaudeCanvas, closeClaudeSession, GROKUI_RESERVED_SLASH, claudeModelArg,
40
+ } from './claudecode.js';
34
41
 
35
42
  const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
36
43
  // BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
@@ -95,18 +102,11 @@ function stripBasePrefix(base, spec) {
95
102
  }
96
103
 
97
104
  /**
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.
105
+ * Reasoning used to leak `<think>…</think>` into the visible bubble and then
106
+ * get sent back to the model. Visible text is still stripped (see takeThink);
107
+ * the plaintext is kept on the history row as `thinking` and folded in the
108
+ * canvas. Encrypted blobs never become a chip that lives in lib/think.js.
102
109
  */
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
110
  const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
111
111
  mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
112
112
  jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
@@ -211,6 +211,23 @@ function colorFor(name) {
211
211
  return PALETTE[h % PALETTE.length];
212
212
  }
213
213
 
214
+ // Frozen SYSTEM is baked into old threads at creation. This string is also
215
+ // injected every turn (extras + AUTO_DIRECTIVE) so live Auto cannot "shell
216
+ // the proxy" just because the thread was born with the old "you CAN curl
217
+ // :8402" paragraph. Site-check curls of localhost:8080 stay allowed.
218
+ const CHAT_NOT_PROXY = `You already ARE the chat. Never RUN curl, wget, or fetch against localhost:8402 or
219
+ /v1/chat/completions — that dumps another model's JSON into the canvas and pays twice.
220
+ Orange Auto = WRITE / READ / RUN / GLOB for real work, not "shell the proxy."
221
+ Never mkdir empty trees and declare DONE — WRITE the files.`;
222
+ 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.';
223
+ function looksLikeProxyShell(cmd) {
224
+ const s = String(cmd || '');
225
+ if (!/\b(curl|wget|fetch)\b/i.test(s)) return false;
226
+ if (/(?:localhost|127\.0\.0\.1|\[::1\])/i.test(s) && /:8402\b/.test(s)) return true;
227
+ if (/(?:localhost|127\.0\.0\.1|\[::1\])[^\s'"]*\/v1\/chat\/completions/i.test(s)) return true;
228
+ return false;
229
+ }
230
+
214
231
  const SYSTEM = `You are a helpful assistant served over openzoo (pay-per-call access to ~435
215
232
  models, no API key, no account). Reply normally in plain text, concisely.
216
233
 
@@ -361,7 +378,7 @@ call. Ask for everything you know you need at once instead of discovering it one
361
378
  time. Mutating directives (RUN, WRITE, EDIT, SPAWN, SEND) stay sequential on purpose — racing
362
379
  them against each other corrupts the tree.
363
380
  RUN: <shell command> run a REAL shell command in this
364
- thread's directory — by default this
381
+ thread's directory. Stay in that directory. Never find / use GLOB: or find . -maxdepth N. By default this
365
382
  pauses and waits for the user to
366
383
  approve or deny it before anything
367
384
  executes ("/mode auto" in chat skips
@@ -385,17 +402,7 @@ output and exit code back; that, not silence, is what failure looks like.
385
402
  Never fabricate command output, file contents, or payment receipts. If you did not run it,
386
403
  say so and then actually run it.
387
404
 
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.
405
+ ${CHAT_NOT_PROXY}
399
406
 
400
407
  COST ACCOUNTING — do NOT compute this yourself from token counts. Every response carries an
401
408
  "x402" object; read the numbers off it: x402.billedUsd (what the user paid — OpenRouter price, plus 33% of savings vs
@@ -425,8 +432,16 @@ const turnAborts = new WeakMap();
425
432
  function saveThreads() {
426
433
  try {
427
434
  mkdirSync(STORE_DIR, { recursive: true });
428
- writeFileSync(STORE_FILE, JSON.stringify([...threads.values()]));
429
- } catch { /* best effort */ }
435
+ const data = JSON.stringify([...threads.values()]);
436
+ const fd = openSync(STORE_FILE, 'w');
437
+ try {
438
+ writeSync(fd, data);
439
+ fsyncSync(fd);
440
+ } finally {
441
+ closeSync(fd);
442
+ }
443
+ return true;
444
+ } catch { return false; }
430
445
  }
431
446
 
432
447
  function loadThreads() {
@@ -443,8 +458,16 @@ function loadThreads() {
443
458
  }
444
459
  if (Array.isArray(t.history)) {
445
460
  for (const h of t.history) {
446
- if (h && h.who === 'bot' && typeof h.text === 'string') h.text = stripThinkTags(h.text);
461
+ if (h && h.who === 'bot' && typeof h.text === 'string') {
462
+ const parts = takeThink(h.text, h.thinking);
463
+ h.text = parts.text;
464
+ if (parts.thinking) h.thinking = parts.thinking;
465
+ else delete h.thinking;
466
+ }
447
467
  }
468
+ // AUTO continue / nudge / command-output hops must never come back
469
+ // as user bubbles after a remount. They are harness-only.
470
+ t.history = t.history.filter(isVisibleHistoryEntry);
448
471
  }
449
472
  if (Array.isArray(t.messages)) {
450
473
  for (const m of t.messages) {
@@ -507,6 +530,10 @@ function newThread(name, parent, members, spec) {
507
530
  ...(p?.race ? { race: p.race } : {}),
508
531
  ...(p?.raceNeed ? { raceNeed: p.raceNeed } : {}) };
509
532
  if (p) attachChildDir(t, p, spec);
533
+ // Brand-new chat (no parent) starts empty: own holobrain, no contextId,
534
+ // no boundItems. Do not copy the previous thread's bind / corpus.
535
+ // SPAWN kids also start unbound here; bindThread shares the parent root
536
+ // on first bind. Existing threads on disk keep whatever they already have.
510
537
  threads.set(id, t);
511
538
  saveThreads();
512
539
  return t;
@@ -630,6 +657,7 @@ savedUsd, savesVsDirect). Never derive it by summing usage.cost or usage.prompt_
630
657
  provider's list price: on a bound context prompt_tokens counts only the slice leCore
631
658
  recalled, not the corpus it stands in for, so that math prices the discount against itself
632
659
  and wrongly concludes the zoo cost more.
660
+ ${CHAT_NOT_PROXY}
633
661
  For normal replies just answer directly — do not use any of these unless the request
634
662
  actually calls for delegation or file work.` };
635
663
  }
@@ -650,8 +678,12 @@ function contentFor(text, images) {
650
678
  }
651
679
 
652
680
  function buildMemberMessages(t, member) {
653
- const msgs = [{ role: 'system', content: member.systemPrompt || SYSTEM }];
681
+ const msgs = [
682
+ { role: 'system', content: member.systemPrompt || SYSTEM },
683
+ { role: 'system', content: CHAT_NOT_PROXY },
684
+ ];
654
685
  for (const h of t.history) {
686
+ if (h.who === 'user' && isHarnessUserText(h.text)) continue;
655
687
  if (h.who === 'user') msgs.push({ role: 'user', content: contentFor(h.text, h.images) });
656
688
  else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
657
689
  else msgs.push({ role: 'user', content: `[${h.name}]: ${h.text}` });
@@ -693,9 +725,7 @@ const NUDGE = 'That reply announced work instead of doing it — no directive li
693
725
  + 'RUN:, SPAWN:, READ:, WRITE:, GLOB:, FETCH:, MCP: or SERVE:. '
694
726
  + 'Exactly the syntax from your instructions — not [TOOL_CALL], not JSON, not a function-call envelope. '
695
727
  + '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.';
728
+ const AUTO_CONTINUE = 'Please continue the current job. Do not stop to ask the user.';
699
729
  const AUTO_RACE_RETRY = 'AUTO is still on — the last model call failed (race/empty/error). '
700
730
  + 'Do not stop and do not ask the user to type continue. '
701
731
  + 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
@@ -716,8 +746,41 @@ function isTransientModelFail(text) {
716
746
  if (/returned nothing \d+ times|each returned nothing/i.test(s)) return true;
717
747
  return false;
718
748
  }
749
+ // Quiet Claude TUI / --model miss / chrome-only canvas. 1.5.99 PTY Auto
750
+ // treated that as a finished turn, painted "(no response)" / "upstream HTTP N",
751
+ // and went idle. Fall through to Ask/Auto chat-completions instead.
752
+ function isClaudeFallbackReply(text) {
753
+ const s = String(text || '').trim();
754
+ if (!s) return true;
755
+ if (s === '(no response)') return true;
756
+ if (/^(?:error:\s*)?upstream HTTP \d+\s*$/i.test(s)) return true;
757
+ return false;
758
+ }
759
+ function isVisibleBotReply(h) {
760
+ if (!h || h.who !== 'bot') return false;
761
+ if (!isVisibleHistoryEntry(h)) return false;
762
+ const text = String(h.text || '').trim();
763
+ if (!text) return false;
764
+ if (isClaudeFallbackReply(text)) return false;
765
+ return true;
766
+ }
767
+ function threadHasVisibleBotReply(t) {
768
+ return (t?.history || []).some(isVisibleBotReply);
769
+ }
770
+ function popClaudeFallbackBot(t) {
771
+ if (!t || !Array.isArray(t.history) || !t.history.length) return false;
772
+ const last = t.history[t.history.length - 1];
773
+ if (last.who !== 'bot' || !isClaudeFallbackReply(last.text)) return false;
774
+ t.history.pop();
775
+ return true;
776
+ }
777
+ function isEmptyWalletPayment(text) {
778
+ // Empty/underfunded only — not a generic HTTP 402 handshake.
779
+ return /\b(?:wallet is empty|empty wallet|wallet underfunded|underfunded)\b/i.test(String(text || ''));
780
+ }
719
781
  function isPaymentFailed(text) {
720
- return /\b(?:payment failed|HTTP 402|wallet is empty|empty wallet)\b/i.test(String(text || ''));
782
+ return isEmptyWalletPayment(text)
783
+ || /\b(?:payment failed|HTTP 402|payment required)\b/i.test(String(text || ''));
721
784
  }
722
785
  // Empty stdout, "(no output)", or a directive that found nothing. That is
723
786
  // still a command-output hop today, so AUTO used to chain once and then park
@@ -847,7 +910,22 @@ without a fact only the user has.
847
910
  When the job is actually finished, emit DONE: as the first line. A status
848
911
  sentence is not a stop — the harness keeps this thread working until DONE:,
849
912
  a real blocking question, or the step cap. Empty output, "(no output)", and
850
- GLOB/GREP with no matches are not finished — try a different command or path.`;
913
+ GLOB/GREP with no matches are not finished — try a different command or path.
914
+
915
+ ${CHAT_NOT_PROXY}`;
916
+
917
+ /**
918
+ * Holobrain owner for a thread.
919
+ * SPAWN kids share the project root (current SPAWN semantics).
920
+ * A brand-new chat (no parent) is its own root — never attach another
921
+ * thread's contextId / boundItems.
922
+ */
923
+ function holobrainOf(t) {
924
+ if (!t) return null;
925
+ if (t.parent) return threads.get(rootOf(t).rootId) || t;
926
+ return t;
927
+ }
928
+
851
929
  async function bindThread(t) {
852
930
  // Only bind what's NEW since the last successful bind, continuing the
853
931
  // existing context_id — previously this rebuilt and re-sent the WHOLE
@@ -862,15 +940,13 @@ async function bindThread(t) {
862
940
  const corpus = delta.map((h) => '[' + t.name + '] ' + (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
863
941
  if (!corpus.trim()) { t.boundHistoryCount = t.history.length; return; }
864
942
  try {
865
- // ONE CONTEXT PER PROJECT, not per thread. Every thread used to bind to
866
- // its own private context, so sibling agents spawned for the same job were
867
- // memory-isolated: arc-tetris-engine could not recall a thing
868
- // arc-token-bets had established, and the user paid to re-explain shared
869
- // facts to each one. They are a team; the memory should be too.
870
- // The context lives on the ROOT and every descendant binds into it, while
871
- // each thread keeps its OWN boundHistoryCount so nothing is re-sent.
872
- const root = threads.get(rootOf(t).rootId) || t;
873
- let ctx = root.contextId || t.contextId;
943
+ // SPAWN kids share the project root holobrain so sibling agents can
944
+ // recall what the crew already bound. A brand-new chat (no parent) is
945
+ // its own root — do not attach the previous thread's contextId.
946
+ // Existing threads that already have a bind keep it (we never delete
947
+ // contextId here).
948
+ const brain = holobrainOf(t) || t;
949
+ let ctx = brain.contextId || t.contextId;
874
950
  for (let i = 0; i < corpus.length; i += BIND_CHUNK_BYTES) {
875
951
  const part = corpus.slice(i, i + BIND_CHUNK_BYTES);
876
952
  const body = ctx ? { corpus: part, context_id: ctx } : { corpus: part };
@@ -884,12 +960,12 @@ async function bindThread(t) {
884
960
  // How many chunks the project's holobrain now holds. This is the number
885
961
  // adaptive top_k scales on — without it we would be guessing, which is
886
962
  // exactly how top_k ended up pinned at 8 in the first place.
887
- if (Number(j?.bound)) root.boundItems = (root.boundItems || 0) + Number(j.bound);
963
+ if (Number(j?.bound)) brain.boundItems = (brain.boundItems || 0) + Number(j.bound);
888
964
  else break; // this chunk failed — stop, keep whatever bound so far rather than lose it all
889
965
  }
890
- // Write to the ROOT so later siblings inherit it, and to this thread so
891
- // the per-call header is readable without walking the tree again.
892
- if (ctx) { root.contextId = ctx; t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
966
+ // Write to the holobrain owner (project root for SPAWN kids, this thread
967
+ // for a new chat) and to this thread so the per-call header is local.
968
+ if (ctx) { brain.contextId = ctx; t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
893
969
  } catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
894
970
  }
895
971
 
@@ -1030,8 +1106,10 @@ const RUN_SHELL = process.platform !== 'win32' && existsSync('/bin/bash') ? '/bi
1030
1106
  const RUN_TIMEOUT_MS = Number(process.env.OZ_RUN_TIMEOUT_MS || 600000);
1031
1107
 
1032
1108
  function execCommand(command, cwd) {
1109
+ if (looksLikeProxyShell(command)) return Promise.resolve(PROXY_SHELL_REFUSE);
1110
+ const guarded = guardFindCwd(command, cwd);
1033
1111
  return new Promise((resolve) => {
1034
- exec(command, { cwd, shell: RUN_SHELL, timeout: RUN_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
1112
+ exec(guarded, { cwd, shell: RUN_SHELL, timeout: RUN_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
1035
1113
  let out = (stdout || '') + (stderr ? '\n' + stderr : '');
1036
1114
  if (err) out += `\n(exit ${err.code ?? 1})`;
1037
1115
  resolve(keepWhole(out).trim() || '(no output)');
@@ -1078,11 +1156,41 @@ const SLASH_COMMANDS = [
1078
1156
  { name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
1079
1157
  { name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
1080
1158
  { name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
1081
- { name: '/mode', args: 'auto|ask', help: 'run commands immediately, or ask first' },
1159
+ { name: '/mode', args: 'auto|ask', help: 'Auto = Claude Code via OpenZoo; ask = chat + approve RUN' },
1082
1160
  ];
1083
1161
 
1162
+ /** Claude TUI slashes that grokui must not intercept in Auto. /model is
1163
+ * Claude's picker here — grokui's /model pin stays on ask mode. */
1164
+ const CLAUDE_SLASH_IN_AUTO = new Set(['agents', 'tasks', 'context', 'model']);
1165
+
1166
+ function isGrokuiOwnedSlash(line, runMode) {
1167
+ const cmd = /^\/(\w+)/.exec(String(line || '').trim())?.[1]?.toLowerCase();
1168
+ if (!cmd) return false;
1169
+ if (GROKUI_RESERVED_SLASH.includes(cmd)) return true;
1170
+ if (runMode === 'auto' && CLAUDE_SLASH_IN_AUTO.has(cmd)) return false;
1171
+ return SLASH_COMMANDS.some((c) => c.name.slice(1).toLowerCase() === cmd)
1172
+ || cmd === 'pay' || cmd === 'hud';
1173
+ }
1174
+
1084
1175
  const usd = (n) => (n >= 0.01 || n === 0 ? '$' + n.toFixed(2) : '$' + n.toFixed(5));
1085
1176
 
1177
+ /**
1178
+ * HUD / sitrep / /cost: prefer spilled-call x when anything bound.
1179
+ * Never an unlabeled Nx — "2.10x spilled" vs "2.10x session".
1180
+ * Rounding: 100+ integer, 10+ 1dp, else 2dp.
1181
+ */
1182
+ function formatSavingLabel(you) {
1183
+ const spent = Number(you && you.spentUsd) || 0;
1184
+ if (spent <= 0) return { text: '—', mult: null, spilled: false };
1185
+ const spillX = Number(you && you.spilled && you.spilled.savingX);
1186
+ const sessionX = (Number(you && you.directUsd) || 0) / spent;
1187
+ const spilled = Number.isFinite(spillX) && spillX > 0;
1188
+ const mult = spilled ? spillX : sessionX;
1189
+ if (!Number.isFinite(mult)) return { text: '—', mult: null, spilled: false };
1190
+ const num = (mult >= 100 ? String(Math.round(mult)) : Number(mult).toFixed(mult >= 10 ? 1 : 2)) + 'x';
1191
+ return { text: num + (spilled ? ' spilled' : ' session'), mult, spilled };
1192
+ }
1193
+
1086
1194
  // BIND, DON'T PASTE — the whole point of the thing this runs on.
1087
1195
  //
1088
1196
  // Directive results get fed back to the model as a user message, verbatim. A
@@ -1210,8 +1318,8 @@ function inFlightChars(t) {
1210
1318
  */
1211
1319
  function scheduleFilesForCorpus(t, collected, opts = {}) {
1212
1320
  if (!collected?.pending?.length) return null;
1213
- const root = t ? (threads.get(rootOf(t).rootId) || t) : null;
1214
- const ctx = opts.contextId || root?.contextId || t?.contextId || null;
1321
+ const brain = t ? holobrainOf(t) : null;
1322
+ const ctx = opts.contextId || brain?.contextId || t?.contextId || null;
1215
1323
  const chars = opts.sentChars ?? inFlightChars(t);
1216
1324
  const background = chars < BIND_MIN_CHARS;
1217
1325
  const fetchImpl = opts.fetchImpl || fetch;
@@ -1228,7 +1336,7 @@ function scheduleFilesForCorpus(t, collected, opts = {}) {
1228
1336
  }).then(async (r) => {
1229
1337
  const j = await r.json().catch(() => ({}));
1230
1338
  if (j?.context_id && t) {
1231
- const live = threads.get(rootOf(t).rootId) || t;
1339
+ const live = holobrainOf(t) || t;
1232
1340
  live.contextId = j.context_id;
1233
1341
  t.contextId = j.context_id;
1234
1342
  if (Number(j.bound)) live.boundItems = (live.boundItems || 0) + Number(j.bound);
@@ -1296,9 +1404,20 @@ function emitToThread(threadId, ev) {
1296
1404
  }
1297
1405
  }
1298
1406
 
1407
+ async function attachSpilled(you) {
1408
+ if (!you || you.spilled != null) return you;
1409
+ try {
1410
+ const info = await (await fetch(`${PROXY}/info`, { signal: AbortSignal.timeout(2000) })).json();
1411
+ if (info && info.spilled) you.spilled = info.spilled;
1412
+ } catch { /* session label stays honest if info is down */ }
1413
+ return you;
1414
+ }
1415
+
1299
1416
  async function sessionStats() {
1300
- try { return await (await fetch(`${PROXY}/session`, { signal: AbortSignal.timeout(2000) })).json(); }
1301
- catch { return null; }
1417
+ try {
1418
+ const s = await (await fetch(`${PROXY}/session`, { signal: AbortSignal.timeout(2000) })).json();
1419
+ return attachSpilled(s);
1420
+ } catch { return null; }
1302
1421
  }
1303
1422
 
1304
1423
  function todoBlock(t) {
@@ -1323,7 +1442,7 @@ async function handleSlash(task, t) {
1323
1442
  }
1324
1443
  if (cmd === 'tools') {
1325
1444
  return 'Directives:\n'
1326
- + ' RUN: <cmd> real shell, in this thread’s dir\n'
1445
+ + ' RUN: <cmd> real shell, in this thread’s dir (never find /)\n'
1327
1446
  + ' WRITE: <path> | <content> create/overwrite a file\n'
1328
1447
  + ' EDIT: <path> | <old> ||| <new> change part of a file\n'
1329
1448
  + ' MULTIEDIT: <path> | a|||b ;; c|||d several edits, all-or-nothing\n'
@@ -1344,6 +1463,20 @@ async function handleSlash(task, t) {
1344
1463
  + 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
1345
1464
  + 'appear in one reply — four files cost one round trip, not four.';
1346
1465
  }
1466
+ // Header Pay / ◎ echo through the same /drive → history.push path as
1467
+ // /mode and /tier. Short lines only — never wallet JSON or a sitrep dump.
1468
+ if (cmd === 'pay') {
1469
+ return 'Pay — card checkout or the local wallet/x402 burner. Drawer opened.';
1470
+ }
1471
+ if (cmd === 'hud') {
1472
+ const s = await sessionStats();
1473
+ const mode = t.runMode || 'ask';
1474
+ const tier = t.tier || 'auto';
1475
+ if (!s) return `Sitrep — mode ${mode} · ${tier} · proxy unreachable.`;
1476
+ const spent = Number(s.spentUsd) || 0;
1477
+ const sav = formatSavingLabel(s);
1478
+ return `Sitrep — mode ${mode} · ${tier} · paid ${usd(spent)} · ${sav.text} · ${s.paidCalls || 0} calls`;
1479
+ }
1347
1480
  if (cmd === 'cost' || cmd === 'tokens') {
1348
1481
  const s = await sessionStats();
1349
1482
  if (!s) return 'The local openzoo proxy isn’t reachable, so there are no real numbers to show. (Not zero — unknown.)';
@@ -1355,9 +1488,9 @@ async function handleSlash(task, t) {
1355
1488
  ` paid calls ${s.paidCalls || 0}`,
1356
1489
  ];
1357
1490
  if (spent > 0) {
1358
- const mult = direct / spent;
1359
- lines.push(` multiple ${mult >= 100 ? Math.round(mult) : mult.toFixed(2)}x`
1360
- + (mult < 1 ? ' — under 1x: small inputs cost MORE than sending them directly' : ''));
1491
+ const sav = formatSavingLabel(s);
1492
+ lines.push(` multiple ${sav.text}`
1493
+ + (sav.mult != null && sav.mult < 1 ? ' — under 1x: small inputs cost MORE than sending them directly' : ''));
1361
1494
  }
1362
1495
  return 'This session:\n' + lines.join('\n');
1363
1496
  }
@@ -1372,16 +1505,27 @@ async function handleSlash(task, t) {
1372
1505
  // tier silently overriding an explicit id would make /model a suggestion.
1373
1506
  if (cmd === 'tier') {
1374
1507
  if (!arg) {
1375
- const picks = await tierModels(t.tier || 'medium', 3);
1376
- return `This thread: ${t.tier || 'medium'}${t.tier ? '' : ' (default)'}\n`
1377
- + `Tiers: ${TIER_NAMES.join(' · ')}\n`
1378
- + `Top of ${t.tier || 'medium'} right now: ${picks.join(', ')}\n`
1508
+ const cur = t.tier || 'auto';
1509
+ if (cur === 'auto') {
1510
+ return `This thread: auto (classifier openzoo/auto)\n`
1511
+ + `Tiers: auto · ${TIER_NAMES.join(' · ')}\n`
1512
+ + (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so Auto is ignored until you /model default.\n` : '')
1513
+ + 'Switch with /tier <name> · /race <n> to ask several at once.';
1514
+ }
1515
+ const picks = await tierModels(cur, 3);
1516
+ return `This thread: ${cur}\n`
1517
+ + `Tiers: auto · ${TIER_NAMES.join(' · ')}\n`
1518
+ + `Top of ${cur} right now: ${picks.join(', ')}\n`
1379
1519
  + (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so the tier is ignored until you /model default.\n` : '')
1380
1520
  + 'Switch with /tier <name> · /race <n> to ask several at once.';
1381
1521
  }
1382
1522
  const want = normalizeTier(arg);
1383
- if (!want) return `Unknown tier "${arg}". One of: ${TIER_NAMES.join(', ')} (also: grok 4.6).`;
1523
+ if (!want) return `Unknown tier "${arg}". One of: auto, ${TIER_NAMES.join(', ')} (also: grok 4.6).`;
1384
1524
  t.tier = want; saveThreads();
1525
+ if (want === 'auto') {
1526
+ return 'This thread now uses Auto — cheapest model that clears the bar (openzoo/auto).'
1527
+ + (t.model ? `\nBut /model ${t.model} is still pinned and wins. Run /model default to let Auto take over.` : '');
1528
+ }
1385
1529
  const picks = await tierModels(want, 3);
1386
1530
  return `This thread now runs on the ${want} tier — ${picks.join(', ')}…`
1387
1531
  + (t.model ? `\nBut /model ${t.model} is still pinned and wins. Run /model default to let the tier take over.` : '');
@@ -1516,7 +1660,7 @@ async function handleSlash(task, t) {
1516
1660
 
1517
1661
  // Wake the room. Used to be a free last-line dump — idle children stayed
1518
1662
  // idle, and a parent reading "kid: <old reply>" thought they had acted.
1519
- // Empty extra is a nudge (AUTO_CONTINUE), not a cancel. Thinking stays
1663
+ // Empty extra is a continue wake (Claude Code on Auto), not a cancel. Thinking stays
1520
1664
  // thinking; pendingRun stays on the human. Same branch scope as /all.
1521
1665
  if (cmd === 'ping') {
1522
1666
  const crew = subtreeOf(t.id, true);
@@ -1879,6 +2023,69 @@ function isHarnessUserText(text) {
1879
2023
  || String(text || '').startsWith('AUTO_EMPTY_RETRY:');
1880
2024
  }
1881
2025
 
2026
+ function isVisibleHistoryEntry(h) {
2027
+ if (!h) return false;
2028
+ if (h.who === 'user' && (h.harness || isHarnessUserText(h.text))) return false;
2029
+ return true;
2030
+ }
2031
+
2032
+ function visibleHistory(history) {
2033
+ return (history || []).filter(isVisibleHistoryEntry);
2034
+ }
2035
+
2036
+ function sameUserTurn(entry, userText, images) {
2037
+ if (!entry || entry.who !== 'user') return false;
2038
+ if (String(entry.text || '') !== String(userText || '')) return false;
2039
+ const a = entry.images || [];
2040
+ const b = images || [];
2041
+ if (a.length !== b.length) return false;
2042
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2043
+ return true;
2044
+ }
2045
+
2046
+ // Flush the user's turn to RAM + grokui-threads.json BEFORE any model call
2047
+ // or UI refresh. A 500 / sidecar bounce / sitrep redraw used to wipe the
2048
+ // bubble because it lived only in the live stream. Do not roll this back
2049
+ // if the upstream request later fails. Harness continues (AUTO_CONTINUE,
2050
+ // AUTO_RACE_RETRY, NUDGE, command-output hops) are not user bubbles.
2051
+ function persistUserTurn(t, userText, images) {
2052
+ if (!t) return { ok: false };
2053
+ if (isHarnessUserText(userText)) return { ok: true, skipped: true };
2054
+ if (!Array.isArray(t.history)) t.history = [];
2055
+ const last = t.history[t.history.length - 1];
2056
+ if (sameUserTurn(last, userText, images) && isVisibleHistoryEntry(last)) {
2057
+ t.lastActivityAt = Date.now();
2058
+ saveThreads();
2059
+ return { ok: true, already: true, entry: last };
2060
+ }
2061
+ const entry = images && images.length
2062
+ ? { who: 'user', text: userText, images: images.slice() }
2063
+ : { who: 'user', text: userText };
2064
+ t.history.push(entry);
2065
+ let pushedMsg = false;
2066
+ if (Array.isArray(t.messages)) {
2067
+ const lastMsg = t.messages[t.messages.length - 1];
2068
+ let alreadyMsg = false;
2069
+ if (lastMsg && lastMsg.role === 'user') {
2070
+ const want = contentFor(userText, images);
2071
+ alreadyMsg = lastMsg.content === want
2072
+ || (typeof lastMsg.content === 'string' && lastMsg.content === String(userText || ''));
2073
+ }
2074
+ if (!alreadyMsg) {
2075
+ t.messages.push({ role: 'user', content: contentFor(userText, images) });
2076
+ pushedMsg = true;
2077
+ }
2078
+ }
2079
+ t.lastActivityAt = Date.now();
2080
+ const saved = saveThreads();
2081
+ if (!saved) {
2082
+ t.history.pop();
2083
+ if (pushedMsg && Array.isArray(t.messages)) t.messages.pop();
2084
+ return { ok: false };
2085
+ }
2086
+ return { ok: true, entry };
2087
+ }
2088
+
1882
2089
  function firstUserAsk(t) {
1883
2090
  return (t?.history || []).find((m) => m.who === 'user' && !isHarnessUserText(m.text));
1884
2091
  }
@@ -1906,7 +2113,7 @@ function spawnBrief(parent, { refresh = false, child } = {}) {
1906
2113
  const cwd = child?.dir || WORKSPACE_DIR;
1907
2114
  const branch = child?.worktree?.branch;
1908
2115
  const mode = parent.runMode || 'ask';
1909
- const tier = parent.tier || 'medium';
2116
+ const tier = parent.tier || 'auto';
1910
2117
  const race = Number(parent.race) || 0;
1911
2118
  const raceNeed = Number(parent.raceNeed) || 1;
1912
2119
  const model = parent.model || '';
@@ -2617,12 +2824,141 @@ async function mcpDirective(url, tool, args) {
2617
2824
  }
2618
2825
  }
2619
2826
 
2827
+ function autoClaudePrompt(t, userText, images) {
2828
+ const bits = [];
2829
+ if (t.memory?.length) bits.push(`Remember, for this thread:\n${t.memory.map((x) => `- ${x}`).join('\n')}`);
2830
+ if (t.todos?.length) {
2831
+ bits.push(`Current checklist:\n${t.todos.map((x, i) => `${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')}`);
2832
+ }
2833
+ if (images?.length) bits.push(`(${images.length} image(s) were attached in the desktop UI; work from the text ask.)`);
2834
+ bits.push(String(userText || ''));
2835
+ return bits.join('\n\n');
2836
+ }
2837
+
2838
+ /**
2839
+ * Orange Auto = interactive `claude` on a PTY (same env as `openzoo claude`).
2840
+ * Not --print, not the RUN:/WRITE:/DONE: text harness. Chat-box lines go to
2841
+ * PTY stdin; TUI output is stripped / folded onto the canvas.
2842
+ */
2843
+ async function runAutoClaudeTurn(t, userText, images, paint, stillMine, turnAbort) {
2844
+ paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'Claude Code via OpenZoo…' });
2845
+ // Child is alive: canvas must show a folded thinking… row immediately,
2846
+ // before the first thinking_delta or tool_use.
2847
+ paint({ type: 'think', name: t.name, color: t.color, delta: '' });
2848
+ let visible = '';
2849
+ let thinking = '';
2850
+ const toolLines = [];
2851
+ let streamedText = false;
2852
+ const noteTool = (name, input) => {
2853
+ const line = toolStatusLine(name, input);
2854
+ if (!line || toolLines[toolLines.length - 1] === line) return;
2855
+ toolLines.push(line);
2856
+ paint({ type: 'tool', name: t.name, color: t.color, detail: line });
2857
+ };
2858
+ const keepFold = () => paint({ type: 'think', name: t.name, color: t.color, delta: '' });
2859
+ const result = await runClaudeCode({
2860
+ prompt: autoClaudePrompt(t, userText, images),
2861
+ cwd: dirFor(t.id),
2862
+ sessionId: t.claudeSessionId,
2863
+ sessionKey: t.id,
2864
+ model: claudeModelArg(t.model),
2865
+ env: process.env,
2866
+ signal: turnAbort.signal,
2867
+ onEvent(folded) {
2868
+ if (!stillMine() || !folded) return;
2869
+ if (folded.sessionId) t.claudeSessionId = folded.sessionId;
2870
+ if (folded.kind === 'init' || folded.kind === 'partial' || folded.kind === 'tool_result') {
2871
+ keepFold();
2872
+ return;
2873
+ }
2874
+ if (folded.kind === 'tool') {
2875
+ noteTool(folded.name, folded.input);
2876
+ return;
2877
+ }
2878
+ if (folded.kind === 'think') {
2879
+ if (folded.text) {
2880
+ thinking += folded.text;
2881
+ paint({ type: 'think', name: t.name, color: t.color, delta: folded.text });
2882
+ } else {
2883
+ keepFold();
2884
+ }
2885
+ return;
2886
+ }
2887
+ if (folded.kind === 'text' && folded.text) {
2888
+ const clean = sanitizeClaudeCanvas(folded.text);
2889
+ if (!clean) {
2890
+ keepFold();
2891
+ return;
2892
+ }
2893
+ streamedText = true;
2894
+ if (folded.replace) visible = clean;
2895
+ else visible += clean;
2896
+ paint({ type: 'delta', name: t.name, color: t.color, delta: clean, replace: Boolean(folded.replace) });
2897
+ return;
2898
+ }
2899
+ if (folded.kind === 'tui') {
2900
+ if (folded.thinking && folded.thinking !== thinking) {
2901
+ thinking = folded.thinking;
2902
+ paint({ type: 'think', name: t.name, color: t.color, delta: folded.thinking, replace: true });
2903
+ } else {
2904
+ keepFold();
2905
+ }
2906
+ const tuiText = folded.text ? sanitizeClaudeCanvas(folded.text) : '';
2907
+ if (tuiText && tuiText !== visible) {
2908
+ visible = tuiText;
2909
+ streamedText = true;
2910
+ paint({ type: 'delta', name: t.name, color: t.color, delta: tuiText, replace: true });
2911
+ }
2912
+ for (const tool of folded.tools || []) noteTool(tool.name, tool.input);
2913
+ return;
2914
+ }
2915
+ if (folded.kind !== 'assistant') return;
2916
+ if (folded.thinking && !thinking) {
2917
+ thinking = folded.thinking;
2918
+ paint({ type: 'think', name: t.name, color: t.color, delta: folded.thinking });
2919
+ } else {
2920
+ keepFold();
2921
+ }
2922
+ if (folded.text && !streamedText) {
2923
+ const clean = sanitizeClaudeCanvas(folded.text);
2924
+ if (clean) {
2925
+ visible = clean;
2926
+ paint({ type: 'delta', name: t.name, color: t.color, delta: clean, replace: true });
2927
+ }
2928
+ }
2929
+ for (const tool of folded.tools || []) noteTool(tool.name, tool.input);
2930
+ },
2931
+ });
2932
+ if (!stillMine()) {
2933
+ return sanitizeClaudeCanvas(result.paymentFailed || result.text || visible || '', { error: result.error });
2934
+ }
2935
+ if (result.sessionId) t.claudeSessionId = result.sessionId;
2936
+ const thinkAll = [thinking, ...toolLines].filter(Boolean).join('\n');
2937
+ let finalText = result.paymentFailed
2938
+ || result.text
2939
+ || visible
2940
+ || (result.missing ? CLAUDE_MISSING : '');
2941
+ if (!result.paymentFailed && !result.missing) {
2942
+ finalText = sanitizeClaudeCanvas(finalText, { error: result.error });
2943
+ }
2944
+ if (!finalText) finalText = result.error ? 'upstream HTTP 400' : '(no response)';
2945
+ // Empty / chrome-only / HTTP-N: do not commit a dead bot row. runTurn pops
2946
+ // any that slipped through and falls through to Ask/Auto chat-completions.
2947
+ if (isClaudeFallbackReply(finalText) && !result.paymentFailed && !result.missing) {
2948
+ return finalText;
2949
+ }
2950
+ t.history.push({ who: 'bot', text: finalText, thinking: thinkAll || undefined });
2951
+ paint({ type: 'final', name: t.name, color: t.color, text: finalText, thinking: thinkAll || undefined });
2952
+ return finalText;
2953
+ }
2954
+
2620
2955
  // onEvent (optional) gets live progress for whoever's actually watching this
2621
2956
  // call: {type:'start',name,color} when a bot begins its turn, {type:'status',
2622
2957
  // detail} while paying / waiting / racing / walking tools, {type:'race',race}
2623
2958
  // for the spectator grid (one cell per launched model + a judging beat),
2624
2959
  // {type:'delta',name,color,delta} per streamed token (replace:true swaps the
2625
- // bubble once), {type:'final',name,color,text} once its full reply (or
2960
+ // bubble once), {type:'think',delta} for folded chain-of-thought (not the
2961
+ // Auto run-mode chip), {type:'final',name,color,text} once its full reply (or
2626
2962
  // directive ack) is settled. Background turns go through kickTurn →
2627
2963
  // emitToThread, which is a no-op if nobody has the thread open.
2628
2964
  async function runTurn(threadId, userText, onEvent, images) {
@@ -2642,14 +2978,15 @@ async function runTurn(threadId, userText, onEvent, images) {
2642
2978
  if (!stillMine()) return;
2643
2979
  if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
2644
2980
  if (ev.type === 'race' && ev.race && t.status === 'thinking') t.liveRace = ev.race;
2645
- if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race') t.lastDeltaAt = Date.now();
2981
+ if (ev.type === 'delta' || ev.type === 'think' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race' || ev.type === 'tool') t.lastDeltaAt = Date.now();
2646
2982
  onEvent?.(ev);
2647
2983
  };
2648
- t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
2984
+ persistUserTurn(t, userText, images);
2649
2985
  t.lastActivityAt = Date.now();
2650
2986
  t.status = 'thinking';
2651
2987
  t.thinkingAt = Date.now();
2652
2988
  t.lastDeltaAt = Date.now();
2989
+ saveThreads();
2653
2990
  try { turnAborts.get(t)?.abort(); } catch { /* none */ }
2654
2991
  const turnAbort = new AbortController();
2655
2992
  turnAborts.set(t, turnAbort);
@@ -2660,6 +2997,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2660
2997
  t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
2661
2998
  let chained = false;
2662
2999
  let parked = false;
3000
+ let usedClaude = false;
3001
+ let claudeFallback = false;
2663
3002
  let lastReply = '';
2664
3003
  try {
2665
3004
  if (t.members) {
@@ -2675,11 +3014,15 @@ async function runTurn(threadId, userText, onEvent, images) {
2675
3014
  const emitStatus = (detail) => paint({ type: 'status', name: m.name, color: m.color, detail });
2676
3015
  try {
2677
3016
  r = onEvent
2678
- ? (await brainStream(msgs, (delta) => paint({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId, undefined, undefined, 0, 0, emitStatus)).trim()
3017
+ ? (await brainStream(msgs, (delta, meta) => {
3018
+ if (meta?.think) paint({ type: 'think', name: m.name, color: m.color, delta });
3019
+ else paint({ type: 'delta', name: m.name, color: m.color, delta });
3020
+ }, t.contextId, undefined, undefined, 0, 0, emitStatus)).trim()
2679
3021
  : (await brain(msgs, t.contextId)).trim();
2680
3022
  } catch (e) { r = `error: ${e.message}`; }
2681
3023
  if (!stillMine()) return;
2682
- r = stripThinkTags(r);
3024
+ const memberThink = takeThink(r);
3025
+ r = memberThink.text;
2683
3026
  memberReply = r;
2684
3027
  const runCmd = parseRun(r);
2685
3028
  if (runCmd) {
@@ -2689,16 +3032,19 @@ async function runTurn(threadId, userText, onEvent, images) {
2689
3032
  const output = await execCommand(command, dirFor(t.id));
2690
3033
  noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
2691
3034
  const shown = `$ ${command}\n${output}`;
2692
- t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
2693
- paint({ type: 'final', name: m.name, color: m.color, text: shown });
3035
+ t.history.push({
3036
+ who: 'bot', text: command, runStatus: 'done', runOutput: output,
3037
+ name: m.name, color: m.color, thinking: memberThink.thinking,
3038
+ });
3039
+ paint({ type: 'final', name: m.name, color: m.color, text: command, thinking: memberThink.thinking });
2694
3040
  memberReply = shown;
2695
3041
  // this member's turn is done; the round continues to the next member
2696
3042
  continue;
2697
3043
  }
2698
3044
  const runId = randomUUID();
2699
3045
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
2700
- t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
2701
- paint({ type: 'run-pending', runId, command, name: m.name, color: m.color });
3046
+ t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color, thinking: memberThink.thinking });
3047
+ paint({ type: 'run-pending', runId, command, name: m.name, color: m.color, thinking: memberThink.thinking });
2702
3048
  // pauses the WHOLE round here — the rest of the group gets their turn
2703
3049
  // on the round that runs after the user approves/denies
2704
3050
  parked = true;
@@ -2706,8 +3052,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2706
3052
  }
2707
3053
  const ack = await tryDirective(r, t.id, paint);
2708
3054
  const finalText = ack ?? (r || '(no response)');
2709
- t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
2710
- paint({ type: 'final', name: m.name, color: m.color, text: finalText });
3055
+ t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color, thinking: memberThink.thinking });
3056
+ paint({ type: 'final', name: m.name, color: m.color, text: finalText, thinking: memberThink.thinking });
2711
3057
  memberReply = r;
2712
3058
  }
2713
3059
  bindThread(t).catch(() => {});
@@ -2717,6 +3063,38 @@ async function runTurn(threadId, userText, onEvent, images) {
2717
3063
  }
2718
3064
  return;
2719
3065
  }
3066
+ // Orange Auto is interactive Claude Code on a PTY (`openzoo claude` env),
3067
+ // not --print and not the RUN:/WRITE: text loop. Ask still uses chat/completions.
3068
+ if (t.runMode === 'auto') {
3069
+ usedClaude = true;
3070
+ // Auto/Auto: a PTY spawn, --resume, or proxy 500 used to remount the
3071
+ // canvas before finally-saveThreads. Flush the real user turn (history
3072
+ // + messages + disk) immediately before the PTY turn. Harness continue
3073
+ // text is skipped — it must not replace the just-sent bubble.
3074
+ persistUserTurn(t, userText, images);
3075
+ saveThreads();
3076
+ // 1.5.99 PTY Auto swallowed the first send: persist worked, waitIdle
3077
+ // treated a quiet TUI as done, sanitizeClaudeCanvas wiped chrome, no bot
3078
+ // bubble. Skip PTY until a send has produced a visible reply; serve
3079
+ // Ask/Auto chat-completions. Once a visible bot row exists, try PTY and
3080
+ // still fall through if that turn comes back empty / HTTP-N.
3081
+ if (threadHasVisibleBotReply(t)) {
3082
+ usedClaude = true;
3083
+ lastReply = await runAutoClaudeTurn(t, userText, images, paint, stillMine, turnAbort);
3084
+ if (!stillMine()) return;
3085
+ if (!isClaudeFallbackReply(lastReply)) {
3086
+ bindThread(t).catch(() => {});
3087
+ return;
3088
+ }
3089
+ popClaudeFallbackBot(t);
3090
+ usedClaude = false;
3091
+ claudeFallback = true;
3092
+ paint({ type: 'status', name: t.name, color: t.color, detail: 'retrying…' });
3093
+ } else {
3094
+ usedClaude = false;
3095
+ claudeFallback = true;
3096
+ }
3097
+ }
2720
3098
  // A real message from the user resets the auto budget AND the announcement
2721
3099
  // nudge. Harness-injected hops (command output, directive result, nudge,
2722
3100
  // auto-continue) must not re-arm — that would make AUTO_MAX_STEPS a no-op
@@ -2726,7 +3104,12 @@ async function runTurn(threadId, userText, onEvent, images) {
2726
3104
  t.autoSteps = 0;
2727
3105
  delete t.autoNudged;
2728
3106
  }
2729
- t.messages.push({ role: 'user', content: contentFor(userText, images) });
3107
+ // Real user text was already flushed in persistUserTurn (history + messages).
3108
+ // Harness hops stay out of the transcript; ask-mode still needs them in
3109
+ // t.messages so the model sees the continue without a user bubble.
3110
+ if (isHarnessUserText(userText) && Array.isArray(t.messages)) {
3111
+ t.messages.push({ role: 'user', content: contentFor(userText, images) });
3112
+ }
2730
3113
  let reply = '';
2731
3114
  paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'waiting on model…' });
2732
3115
  // Transient: the nudge is appended for THIS call only and never pushed into
@@ -2743,7 +3126,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2743
3126
  if (t.todos?.length) {
2744
3127
  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')}` });
2745
3128
  }
2746
- if (t.runMode === 'auto') extras.push({ role: 'system', content: AUTO_DIRECTIVE });
3129
+ extras.push({ role: 'system', content: CHAT_NOT_PROXY });
3130
+ if (t.runMode === 'auto' && !claudeFallback) extras.push({ role: 'system', content: AUTO_DIRECTIVE });
2747
3131
  const callMsgs = extras.length ? [...t.messages, ...extras] : t.messages;
2748
3132
  // WHICH MODEL SERVES THIS TURN.
2749
3133
  // /model <id> pins one explicitly and always wins — an explicit choice is
@@ -2758,18 +3142,34 @@ async function runTurn(threadId, userText, onEvent, images) {
2758
3142
  thread: t, attempt, userText, messages: callMsgs,
2759
3143
  }) ?? '').trim();
2760
3144
  }
2761
- const emit = (delta, meta) => paint({
2762
- type: 'delta', name: t.name, color: t.color, delta,
2763
- ...(meta?.replace ? { replace: true } : {}),
2764
- ...(meta?.model ? { model: meta.model } : {}),
2765
- });
3145
+ const emit = (delta, meta) => {
3146
+ if (meta?.think) {
3147
+ paint({ type: 'think', name: t.name, color: t.color, delta });
3148
+ return;
3149
+ }
3150
+ paint({
3151
+ type: 'delta', name: t.name, color: t.color, delta,
3152
+ ...(meta?.replace ? { replace: true } : {}),
3153
+ ...(meta?.model ? { model: meta.model } : {}),
3154
+ });
3155
+ };
2766
3156
  const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
2767
- // Retrieval breadth scales with the PROJECT's corpus, not this thread's
2768
- // the holobrain is shared at the root, so that is the pool being searched.
2769
- const topK = adaptiveTopK((threads.get(rootOf(t).rootId) || t).boundItems);
3157
+ // SPAWN kids search the project root's corpus. A brand-new chat is its
3158
+ // own holobrain do not scale top_k off another thread's boundItems.
3159
+ const topK = adaptiveTopK((holobrainOf(t) || t).boundItems);
2770
3160
  const race = Math.min(Number(t.race) || 0, 4);
2771
3161
  if (!t.model && race >= 2) {
2772
- const models = await tierModels(t.tier || 'medium', race, true);
3162
+ const useAuto = !t.tier || t.tier === 'auto';
3163
+ let models = [];
3164
+ if (useAuto) {
3165
+ try {
3166
+ const routed = routeChatBody({ messages: callMsgs }, { k: race, allow_free: false, bindable: true });
3167
+ models = (routed.shortlist || []).map((s) => s.model).filter(Boolean);
3168
+ if (routed.model && !models.includes(routed.model)) models.unshift(routed.model);
3169
+ models = [...new Set(models)].slice(0, race);
3170
+ } catch { models = []; }
3171
+ }
3172
+ if (!models.length) models = await tierModels(useAuto ? 'medium' : (t.tier || 'medium'), race, true);
2773
3173
  // need = how many must come BACK before judging. need 1 is a plain
2774
3174
  // first-past-the-post race; need N waits for all of them. The point of
2775
3175
  // the middle (2 of 3) is a judged answer without the slowest entrant
@@ -2784,7 +3184,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2784
3184
  })).trim();
2785
3185
  }
2786
3186
  // A retry draws a DIFFERENT model from the tier rather than the same one.
2787
- const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
3187
+ const model = t.model || 'openzoo/auto';
2788
3188
  return (onEvent
2789
3189
  ? (await brainStream(callMsgs, emit, t.contextId, model, undefined, 0, topK, emitStatus)).trim()
2790
3190
  : (await brain(callMsgs, t.contextId, model, topK)).trim());
@@ -2822,20 +3222,21 @@ async function runTurn(threadId, userText, onEvent, images) {
2822
3222
  reply = `error: ${e.message}`;
2823
3223
  }
2824
3224
  if (!stillMine()) return;
2825
- reply = stripThinkTags(reply);
3225
+ const settled = takeThink(reply);
3226
+ reply = settled.text;
2826
3227
  lastReply = reply;
2827
3228
  t.messages.push({ role: 'assistant', content: reply });
2828
3229
  const runCmd = parseRun(reply);
2829
3230
  if (runCmd) {
2830
3231
  const command = runCmd;
2831
- if (t.runMode === 'auto') {
3232
+ if (t.runMode === 'auto' && !claudeFallback) {
2832
3233
  emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
2833
3234
  const output = await execCommand(command, dirFor(t.id));
2834
3235
  noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
2835
3236
  if (!stillMine()) return;
2836
3237
  const shown = `$ ${command}\n${output}`;
2837
- t.history.push({ who: 'bot', text: shown });
2838
- paint({ type: 'final', name: t.name, color: t.color, text: shown });
3238
+ t.history.push({ who: 'bot', text: command, runStatus: 'done', runOutput: output, thinking: settled.thinking });
3239
+ paint({ type: 'final', name: t.name, color: t.color, text: command, thinking: settled.thinking });
2839
3240
  // FEED THE OUTPUT BACK. The 'ask' path already does this on approve, so
2840
3241
  // auto mode was strictly LESS capable than the gated one: the command
2841
3242
  // ran, the result was shown, and the model never saw it — no diagnosis,
@@ -2860,8 +3261,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2860
3261
  {
2861
3262
  const runId = randomUUID();
2862
3263
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
2863
- t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
2864
- paint({ type: 'run-pending', runId, command, name: t.name, color: t.color });
3264
+ t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', thinking: settled.thinking });
3265
+ paint({ type: 'run-pending', runId, command, name: t.name, color: t.color, thinking: settled.thinking });
2865
3266
  }
2866
3267
  parked = true;
2867
3268
  return;
@@ -2869,8 +3270,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2869
3270
  const ack = await tryDirective(reply, t.id, paint);
2870
3271
  if (!stillMine()) return;
2871
3272
  const finalText = ack ?? (reply || '(no response)');
2872
- t.history.push({ who: 'bot', text: finalText });
2873
- paint({ type: 'final', name: t.name, color: t.color, text: finalText });
3273
+ t.history.push({ who: 'bot', text: finalText, thinking: settled.thinking });
3274
+ paint({ type: 'final', name: t.name, color: t.color, text: finalText, thinking: settled.thinking });
2874
3275
 
2875
3276
  // AUTO CONTINUES AFTER *ANY* DIRECTIVE, not just RUN.
2876
3277
  //
@@ -2883,7 +3284,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2883
3284
  //
2884
3285
  // Same budget as RUN (shared t.autoSteps, reset when the user speaks), so
2885
3286
  // this cannot spend more than a chained RUN loop already could.
2886
- if (t.runMode === 'auto' && ack !== null && ack !== undefined
3287
+ if (t.runMode === 'auto' && !claudeFallback && ack !== null && ack !== undefined
2887
3288
  && (isEmptyDirectiveAck(ack) || !isDoneReply(reply))) {
2888
3289
  chained = enqueueAutoHop(
2889
3290
  t, threadId,
@@ -2907,7 +3308,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2907
3308
  // NUDGE announcements (stronger than a bare continue). Plain replies used
2908
3309
  // to park here; they now fall through to AUTO_CONTINUE unless DONE: or a
2909
3310
  // real blocking question. The step cap is the wallet bound.
2910
- if (t.runMode === 'auto' && (ack === null || ack === undefined)
3311
+ if (t.runMode === 'auto' && !claudeFallback && (ack === null || ack === undefined)
2911
3312
  && (STALLED_OFFER.test(reply) || ANNOUNCEMENT.test(reply))) {
2912
3313
  // Offers and "Spawned X — working on it" with no directive must not end
2913
3314
  // the run. The old once-only autoNudged gate parked the thread after one
@@ -2919,7 +3320,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2919
3320
 
2920
3321
  // After any auto reply that is not DONE: and not waiting on approval,
2921
3322
  // kick immediately. Race/empty/error uses AUTO_RACE_RETRY.
2922
- if (shouldKeepAuto(t, reply, userText)) {
3323
+ if (!claudeFallback && shouldKeepAuto(t, reply, userText)) {
2923
3324
  chained = enqueueAutoHop(t, threadId, autoHopText(reply, userText), onEvent);
2924
3325
  return;
2925
3326
  }
@@ -2929,7 +3330,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2929
3330
  // ask mode, 402/empty-wallet, or the hard cap. Empty /(no output) is not
2930
3331
  // DONE — AUTO_EMPTY_RETRY. Otherwise kick again.
2931
3332
  if (stillMine() && !chained && !parked) {
2932
- if (shouldKeepAuto(t, lastReply, userText)) {
3333
+ if (!usedClaude && !claudeFallback && shouldKeepAuto(t, lastReply, userText)) {
2933
3334
  enqueueAutoHop(t, threadId, autoHopText(lastReply, userText), onEvent);
2934
3335
  } else if (!t.pendingRun) {
2935
3336
  t.status = 'idle';
@@ -3058,7 +3459,8 @@ function subtreeOf(id, includeSelf = false) {
3058
3459
  }
3059
3460
 
3060
3461
  function threadSummary(t) {
3061
- const last = t.history[t.history.length - 1];
3462
+ const vis = visibleHistory(t.history);
3463
+ const last = vis[vis.length - 1];
3062
3464
  return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
3063
3465
  preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
3064
3466
  createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
@@ -3090,19 +3492,19 @@ const APP_HTML = `<!doctype html>
3090
3492
  <style>
3091
3493
  :root { color-scheme: dark; }
3092
3494
  * { box-sizing: border-box; }
3093
- html, body { margin: 0; height: 100%; background: #000; }
3495
+ html, body { margin: 0; height: 100%; width: 100%; overflow: hidden; background: #000; }
3094
3496
  body { color: #ececec; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
3095
3497
  display: flex; }
3096
3498
  #dragbar { -webkit-app-region: drag; position: fixed; top: 0; left: 0; right: 0; height: 28px; z-index: 1000; }
3097
3499
  #sidebar { width: 280px; flex: 0 0 280px; border-right: 1px solid #1c1c1e; display: flex; flex-direction: column;
3098
- height: 100vh; padding-top: 28px; }
3500
+ height: 100%; padding-top: 28px; min-height: 0; position: relative; }
3099
3501
  #main { padding-top: 28px; }
3100
3502
  #sideTop { display: flex; align-items: center; gap: 4px; padding: 0 8px; }
3101
3503
  #sideTop #search { flex: 1; }
3102
3504
  #search { margin: 12px; padding: 8px 12px; background: #1c1c1e; border-radius: 10px; color: #ececec;
3103
3505
  border: none; font: inherit; }
3104
3506
  #search::placeholder { color: #8e8e93; }
3105
- #threads { flex: 1; overflow-y: auto; }
3507
+ #threads { flex: 1; min-height: 0; overflow-y: auto; }
3106
3508
  .trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
3107
3509
  margin: 0 6px 2px; }
3108
3510
  /* PROJECT HEADER. The tree indentation shows who spawned whom, but there was
@@ -3167,7 +3569,7 @@ const APP_HTML = `<!doctype html>
3167
3569
  @media (prefers-reduced-motion: reduce) {
3168
3570
  .twarn, .bot-pfp .bot-bob, .bot-pfp .bot-eyes { animation: none; }
3169
3571
  }
3170
- #main { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
3572
+ #main { position: relative; flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; height: 100%; }
3171
3573
  #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
3172
3574
  flex-wrap: wrap; gap: 8px 10px; font-weight: 600; }
3173
3575
  #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 50%; font-size: 11px; flex: 0 0 26px; }
@@ -3212,6 +3614,10 @@ const APP_HTML = `<!doctype html>
3212
3614
  #walletOverlay, #sitrepOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.66); z-index: 1200;
3213
3615
  display: none; align-items: center; justify-content: center; padding: 24px; }
3214
3616
  #walletOverlay.show, #sitrepOverlay.show { display: flex; }
3617
+ .payneed-btn { display: block; margin-top: 10px; border: 0; cursor: pointer;
3618
+ background: #b8f240; color: #0b0b0d; font: 700 12px/1.2 inherit;
3619
+ letter-spacing: .04em; padding: 7px 14px; border-radius: 999px; }
3620
+ .payneed-btn:hover { filter: brightness(1.05); }
3215
3621
  #walletBox, #sitrepBox { width: 100%; max-width: 560px; max-height: 82vh; overflow-y: auto; background: #111113;
3216
3622
  border: 1px solid #2c2c2e; border-radius: 16px; padding: 20px 22px; }
3217
3623
  #walletBox h3, #sitrepBox h3 { margin: 0 0 2px; font-size: 15px; font-weight: 600; }
@@ -3290,6 +3696,21 @@ const APP_HTML = `<!doctype html>
3290
3696
  #hud { position: absolute; right: 14px; width: 270px; background: rgba(14,14,17,.94);
3291
3697
  border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
3292
3698
  display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
3699
+ /* Always-on strip. Sidebar footer — bottom-left of the window, inside the
3700
+ bot list column. Never a child of #main: position:absolute; left:14px
3701
+ there sat on the transcript and covered the last bubbles. ◎ stays a
3702
+ toggle; this one never hides. pointer-events none so it cannot steal
3703
+ thread-row clicks. */
3704
+ #dockHud { flex: 0 0 auto; position: relative; left: auto; bottom: auto;
3705
+ width: 100%; max-width: 100%; z-index: 1;
3706
+ pointer-events: none; background: rgba(14,14,17,.94);
3707
+ border: 0; border-top: 1px solid #333340; border-radius: 0;
3708
+ padding: 8px 12px 12px; color: #8e8e93;
3709
+ font: 10.5px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
3710
+ display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; }
3711
+ #dockHud .dk { color: #6f7080; }
3712
+ #dockHud .dv { color: #c8c8c2; }
3713
+ #dockHud .dv.hlime { color: #b8f240; }
3293
3714
  #hud.show { display: block; }
3294
3715
  #hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
3295
3716
  #hud .htitle.hsession { margin-top: 12px; padding-top: 10px; border-top: 1px solid #333340; color: #6f7080; }
@@ -3307,8 +3728,8 @@ const APP_HTML = `<!doctype html>
3307
3728
  color: #f0c9a8; font-size: 10.5px; line-height: 1.45; }
3308
3729
  #hud .hhint.show { display: block; }
3309
3730
  #hud .hhint b { color: #f28c4d; font-weight: 600; }
3310
- #sidebar, #main, #walletOverlay, #sitrepOverlay, #composeOverlay,
3311
- #inp, #search, #composeInp, .bubble, .md-pre, .runoutput, .runcmd {
3731
+ #sidebar, #main, #walletOverlay, #sitrepOverlay, #composeOverlay, #findBar,
3732
+ #inp, #search, #composeInp, #findInp, .bubble, .md-pre, .runoutput, .runcmd {
3312
3733
  -webkit-app-region: no-drag; }
3313
3734
  #log { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 20px 24px 12px;
3314
3735
  display: flex; flex-direction: column; gap: 6px;
@@ -3390,6 +3811,18 @@ const APP_HTML = `<!doctype html>
3390
3811
  }
3391
3812
  .runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px;
3392
3813
  max-width: 100%; min-width: 0; overflow: hidden; }
3814
+ /* Done RUNs collapse like thinking… — raw curl JSON must not be the message. */
3815
+ .runcard.folded { background: transparent; border: none; padding: 0; }
3816
+ .runfold { align-self: flex-start; max-width: 100%; min-width: 0; }
3817
+ .runchip {
3818
+ border: 0; background: transparent; color: #8e8e93; padding: 0;
3819
+ font: 12px/1.4 inherit; cursor: pointer; letter-spacing: .01em;
3820
+ -webkit-user-select: none; user-select: none;
3821
+ }
3822
+ .runchip:hover { color: #c8c8d0; }
3823
+ .runchip:focus-visible { outline: 2px solid #ff9500; outline-offset: 2px; border-radius: 4px; }
3824
+ .runbody { display: none; margin: 4px 0 2px; }
3825
+ .runfold.open .runbody { display: block; }
3393
3826
  .runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
3394
3827
  word-break: break-word; margin-bottom: 8px; }
3395
3828
  .runactions { display: flex; gap: 8px; }
@@ -3402,6 +3835,34 @@ const APP_HTML = `<!doctype html>
3402
3835
  word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
3403
3836
  .row.user .bubble { background: #57575c; }
3404
3837
  .row.bot .bubble { background: #262626; color: #ececec; }
3838
+ /* Folded chain-of-thought. Not the Auto run-mode chip — that is /mode auto.
3839
+ Default collapsed; click the label to unfurl, click again to furl.
3840
+ Live Auto turns always show thinking… while the Claude child is alive. */
3841
+ .msgcol { display: flex; flex-direction: column; gap: 6px; min-width: 0; max-width: 100%; flex: 1; }
3842
+ .thinkfold { align-self: flex-start; max-width: 100%; min-width: 0; }
3843
+ .thinkchip {
3844
+ border: 0; background: transparent; color: #8e8e93; padding: 0;
3845
+ font: 12px/1.4 inherit; cursor: pointer; letter-spacing: .01em;
3846
+ -webkit-user-select: none; user-select: none;
3847
+ }
3848
+ .thinkchip:hover { color: #c8c8d0; }
3849
+ .thinkchip:focus-visible { outline: 2px solid #b8f240; outline-offset: 2px; border-radius: 4px; }
3850
+ .thinkbody {
3851
+ display: none; margin: 4px 0 2px; padding: 8px 12px;
3852
+ color: #8e8e93; font-size: 12.5px; line-height: 1.45;
3853
+ white-space: pre-wrap; word-break: break-word;
3854
+ border-left: 2px solid #3a3a3c; max-height: 240px; overflow-y: auto;
3855
+ }
3856
+ .thinkfold.open .thinkbody { display: block; }
3857
+ /* Compact Claude TUI surface. Stripped text is the bubble; this is the
3858
+ optional monospace frame (folded by default, same chip pattern). */
3859
+ .claudeterm {
3860
+ display: none; margin: 4px 0 2px; padding: 8px 10px;
3861
+ font: 11.5px/1.4 Menlo, ui-monospace, monospace; color: #c8c8d0;
3862
+ background: #141416; border: 1px solid #2a2a2d; border-radius: 8px;
3863
+ white-space: pre-wrap; word-break: break-word; max-height: 220px; overflow: auto;
3864
+ }
3865
+ .thinkfold.open .claudeterm { display: block; }
3405
3866
  .row.bot.pending .bubble { color: #8e8e93; }
3406
3867
  .dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
3407
3868
  background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
@@ -3526,6 +3987,23 @@ const APP_HTML = `<!doctype html>
3526
3987
  transition: opacity .14s ease, transform .14s ease;
3527
3988
  }
3528
3989
  #copiedToast.show { opacity: 1; transform: translate(-50%, 0); }
3990
+ /* FIND IN THREAD. Cmd/Ctrl+F — not the sidebar thread search (that's Cmd+K).
3991
+ Sits over #log so matches stay in the conversation, not the thread list. */
3992
+ #findBar { display: none; position: absolute; right: 16px; z-index: 320;
3993
+ align-items: center; gap: 6px; background: rgba(18,18,22,.98);
3994
+ border: 1px solid #3a3a3c; border-radius: 12px; padding: 6px 8px;
3995
+ box-shadow: 0 10px 28px rgba(0,0,0,.45); }
3996
+ #findBar.show { display: flex; }
3997
+ #findInp { width: 180px; background: #0b0b0d; border: 1px solid #2c2c2e; border-radius: 8px;
3998
+ color: #ececec; font: inherit; font-size: 13px; padding: 5px 8px; }
3999
+ #findInp:focus { outline: 2px solid #6ab0ff; outline-offset: 1px; }
4000
+ #findCount { min-width: 48px; color: #8e8e93; font-size: 12px; text-align: right;
4001
+ font-variant-numeric: tabular-nums; }
4002
+ #findBar button { border: 0; background: transparent; color: #ececec; width: 26px; height: 26px;
4003
+ border-radius: 8px; cursor: pointer; font: inherit; line-height: 1; }
4004
+ #findBar button:hover { background: #3a3a3c; }
4005
+ mark.findhit { background: #f2d64b; color: #111; border-radius: 3px; padding: 0 1px; }
4006
+ mark.findhit.cur { background: #b8f240; }
3529
4007
  </style></head>
3530
4008
  <body>
3531
4009
  <div id="copiedToast" role="status" aria-live="polite">copied</div>
@@ -3538,6 +4016,13 @@ const APP_HTML = `<!doctype html>
3538
4016
  <input id="search" placeholder="Search">
3539
4017
  </div>
3540
4018
  <div id="threads"></div>
4019
+ <div id="dockHud" data-component="dock-hud">
4020
+ <span><span class="dk">spill</span> <span id="dockSpill" class="dv">—</span></span>
4021
+ <span><span class="dk">session</span> <span id="dockSession" class="dv">—</span></span>
4022
+ <span><span class="dk">paid</span> <span id="dockPaid" class="dv">—</span></span>
4023
+ <span><span class="dk">bind</span> <span id="dockBind" class="dv">no</span></span>
4024
+ <span><span class="dk">calls</span> <span id="dockCalls" class="dv">0</span></span>
4025
+ </div>
3541
4026
  </div>
3542
4027
  <div id="composeOverlay">
3543
4028
  <div id="composeBox">
@@ -3598,9 +4083,10 @@ const APP_HTML = `<!doctype html>
3598
4083
  title="Shell commands run immediately, with no approval prompt">auto</button>
3599
4084
  </div>
3600
4085
  <select class="dial" id="tierSel" data-component="model-tier" aria-label="Model tier"
3601
- title="How much to spend per turn when no model is pinned">
4086
+ title="Auto = cheapest model that clears the bar. Other tiers only apply to /race.">
4087
+ <option value="auto" selected>auto</option>
3602
4088
  <option value="cheap">cheap</option>
3603
- <option value="medium" selected>medium</option>
4089
+ <option value="medium">medium</option>
3604
4090
  <option value="expensive">expensive</option>
3605
4091
  <option value="grok4.6">grok 4.6</option>
3606
4092
  </select>
@@ -3638,6 +4124,13 @@ const APP_HTML = `<!doctype html>
3638
4124
  <div class="hfoot" id="hFoot">loading…</div>
3639
4125
  </div>
3640
4126
  <div id="log"></div>
4127
+ <div id="findBar" role="search" data-component="find-in-thread">
4128
+ <input id="findInp" type="search" placeholder="Find in conversation" autocomplete="off" spellcheck="false">
4129
+ <span id="findCount" aria-live="polite"></span>
4130
+ <button type="button" id="findPrev" title="Previous" aria-label="Previous match">↑</button>
4131
+ <button type="button" id="findNext" title="Next" aria-label="Next match">↓</button>
4132
+ <button type="button" id="findClose" title="Close" aria-label="Close find">×</button>
4133
+ </div>
3641
4134
  <div id="bar">
3642
4135
  <div id="plusMenu">
3643
4136
  <div class="pop-item" id="attachBtn">
@@ -3669,6 +4162,17 @@ const APP_HTML = `<!doctype html>
3669
4162
  </div>
3670
4163
  </div>
3671
4164
  <script>
4165
+ function formatSavingLabel(you) {
4166
+ const spent = Number(you && you.spentUsd) || 0;
4167
+ if (spent <= 0) return { text: '—', mult: null, spilled: false };
4168
+ const spillX = Number(you && you.spilled && you.spilled.savingX);
4169
+ const sessionX = (Number(you && you.directUsd) || 0) / spent;
4170
+ const spilled = Number.isFinite(spillX) && spillX > 0;
4171
+ const mult = spilled ? spillX : sessionX;
4172
+ if (!Number.isFinite(mult)) return { text: '—', mult: null, spilled: false };
4173
+ const num = (mult >= 100 ? String(Math.round(mult)) : Number(mult).toFixed(mult >= 10 ? 1 : 2)) + 'x';
4174
+ return { text: num + (spilled ? ' spilled' : ' session'), mult, spilled };
4175
+ }
3672
4176
  const threadsEl = document.getElementById('threads');
3673
4177
  const chatHeader = document.getElementById('chatHeader');
3674
4178
  const log = document.getElementById('log');
@@ -3999,7 +4503,7 @@ const APP_HTML = `<!doctype html>
3999
4503
  const tierSel = document.getElementById('tierSel');
4000
4504
  const raceSel = document.getElementById('raceSel');
4001
4505
  if (!tierSel || !raceSel) return;
4002
- tierSel.value = t.tier || 'medium';
4506
+ tierSel.value = t.tier || 'auto';
4003
4507
  raceSel.value = (t.race || 0) < 2 ? '0'
4004
4508
  : ((t.raceNeed || 1) > 1 ? t.raceNeed + ' ' + t.race : String(t.race));
4005
4509
  // A pinned /model makes BOTH dials inert. Showing them live while they do
@@ -4022,15 +4526,18 @@ const APP_HTML = `<!doctype html>
4022
4526
  }
4023
4527
  }
4024
4528
 
4025
- async function setDial(cmd, value) {
4529
+ // Header dials and Pay / ◎ all go through /drive so handleSlash (or the
4530
+ // /mode handler) appends the same short bot line as typing the command.
4531
+ async function echoSlash(task) {
4026
4532
  if (!activeId) return;
4027
- // Reuses the SAME slash-command path, so there is one implementation of the
4028
- // rule rather than a second that can disagree with it.
4029
4533
  await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
4030
- body: JSON.stringify({ threadId: activeId, task: '/' + cmd + ' ' + value }) });
4534
+ body: JSON.stringify({ threadId: activeId, task: task }) });
4031
4535
  await loadThreads();
4032
4536
  await render();
4033
4537
  }
4538
+ async function setDial(cmd, value) {
4539
+ await echoSlash('/' + cmd + ' ' + value);
4540
+ }
4034
4541
  // WALLET MODAL.
4035
4542
  //
4036
4543
  // Public addresses and balances only — /wallet proxies the proxy's own
@@ -4056,6 +4563,21 @@ const APP_HTML = `<!doctype html>
4056
4563
  });
4057
4564
  return row;
4058
4565
  }
4566
+ function isEmptyWalletPayment(text) {
4567
+ // String ops. A word-boundary regex inside APP_HTML is eaten: \b
4568
+ // becomes a literal backspace and the client never matches a real 402.
4569
+ const s = String(text || '').toLowerCase();
4570
+ return s.includes('wallet is empty')
4571
+ || s.includes('empty wallet')
4572
+ || s.includes('wallet underfunded')
4573
+ || s.includes('underfunded');
4574
+ }
4575
+ var openedPayForEmpty = false;
4576
+ function maybeOpenPayForEmptyWallet(text) {
4577
+ if (openedPayForEmpty || !isEmptyWalletPayment(text)) return;
4578
+ openedPayForEmpty = true;
4579
+ openWallet();
4580
+ }
4059
4581
  async function openWallet() {
4060
4582
  walletOverlay.classList.add('show');
4061
4583
  walletBody.textContent = 'loading…';
@@ -4269,7 +4791,10 @@ const APP_HTML = `<!doctype html>
4269
4791
  setSubNote('Subscription key removed. Wallet/x402 is the pay method again.');
4270
4792
  await openWallet();
4271
4793
  }
4272
- document.getElementById('walletBtn').addEventListener('click', openWallet);
4794
+ document.getElementById('walletBtn').addEventListener('click', () => {
4795
+ echoSlash('/pay');
4796
+ openWallet();
4797
+ });
4273
4798
  const subKeyBtn = document.getElementById('subKeyBtn');
4274
4799
  if (subKeyBtn) subKeyBtn.addEventListener('click', savePastedSub);
4275
4800
  const subForgetBtn = document.getElementById('subForgetBtn');
@@ -4338,10 +4863,10 @@ const APP_HTML = `<!doctype html>
4338
4863
  const spent = Number(you.spentUsd) || 0;
4339
4864
  const cogs = Number(you.cogsUsd) || 0;
4340
4865
  const direct = Number(you.directUsd) || 0;
4341
- const mult = spent > 0 ? direct / spent : null;
4342
- const saved = mult == null ? '—'
4343
- : ((mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x');
4344
- const savedCls = mult == null ? '' : (mult >= 1 ? 'hlime' : 'hember');
4866
+ const proxyDown = you.proxyReachable === false;
4867
+ const sav = formatSavingLabel(you);
4868
+ const saved = proxyDown ? 'proxy unreachable' : sav.text;
4869
+ const savedCls = proxyDown ? 'hember' : (sav.mult == null ? '' : (sav.mult >= 1 ? 'hlime' : 'hember'));
4345
4870
  const thinking = (full && full.status === 'thinking') || t.status === 'thinking';
4346
4871
  const race = (full && full.liveRace) || null;
4347
4872
  let flight = 'idle';
@@ -4358,11 +4883,12 @@ const APP_HTML = `<!doctype html>
4358
4883
  + sitrepRow('cwd', cwd)
4359
4884
  + sitrepRow('in flight', flight)
4360
4885
  + '<div class="wlanetitle" style="margin-top:16px">this session</div>'
4361
- + sitrepRow('paid', '$' + (spent >= 0.01 || spent === 0 ? spent.toFixed(2) : spent.toFixed(5)))
4362
- + sitrepRow('cogs', '$' + (cogs >= 0.01 || cogs === 0 ? cogs.toFixed(2) : cogs.toFixed(5)))
4363
- + sitrepRow('direct', '$' + (direct >= 0.01 || direct === 0 ? direct.toFixed(2) : direct.toFixed(5)))
4886
+ + sitrepRow('proxy', proxyDown ? 'unreachable' : 'ok', proxyDown ? 'hember' : 'hlime')
4887
+ + sitrepRow('paid', proxyDown ? 'proxy unreachable' : ('$' + (spent >= 0.01 || spent === 0 ? spent.toFixed(2) : spent.toFixed(5))))
4888
+ + sitrepRow('cogs', proxyDown ? '—' : ('$' + (cogs >= 0.01 || cogs === 0 ? cogs.toFixed(2) : cogs.toFixed(5))))
4889
+ + sitrepRow('direct', proxyDown ? '—' : ('$' + (direct >= 0.01 || direct === 0 ? direct.toFixed(2) : direct.toFixed(5))))
4364
4890
  + sitrepRow('saved vs naked', saved, savedCls)
4365
- + sitrepRow('paid calls', String(you.paidCalls || 0))
4891
+ + sitrepRow('paid calls', proxyDown ? '—' : String(you.paidCalls || 0))
4366
4892
  + sitrepRow('prepaid', (Number(you.creditUsd) > 0) ? 'yes' : 'no');
4367
4893
  }
4368
4894
  function closeSitrep() { sitrepOverlay.classList.remove('show'); }
@@ -4390,22 +4916,44 @@ const APP_HTML = `<!doctype html>
4390
4916
  setModeButtons(mode); // optimistic: the click should feel instant
4391
4917
  // Reuses the SAME "/mode" path the chat command takes, so there is one
4392
4918
  // implementation of the rule rather than a second one that can disagree.
4393
- await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
4394
- body: JSON.stringify({ threadId: activeId, task: '/mode ' + mode }) });
4395
- await loadThreads(); // refresh runMode + the confirmation line /mode appends
4396
- await render();
4919
+ await echoSlash('/mode ' + mode);
4397
4920
  }
4398
4921
  document.getElementById('modeAsk').addEventListener('click', () => setMode('ask'));
4399
4922
  document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
4400
4923
 
4401
4924
  function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
4402
- function stripThinkTags(s) {
4925
+ function scrubBotText(s) {
4926
+ s = String(s == null ? '' : s);
4927
+ s = s.replace(/<system-reminder\\b[^>]*>[\\s\\S]*?<\\/system-reminder>/gi, '');
4928
+ s = s.replace(/<system-reminder\\b[^>]*>[\\s\\S]*$/i, '');
4929
+ s = s.replace(/^[ \\t]*(?:#\\s*)?currentDir\\b[^\\n]*\\n?/gim, '');
4930
+ if (/\\uFFFD/.test(s) || /^\\s*API Error:\\s*(\\d{3})\\b/im.test(s)) {
4931
+ var m = /API Error:\\s*(\\d{3})/i.exec(s);
4932
+ return 'upstream HTTP ' + (m ? m[1] : '400');
4933
+ }
4934
+ return s;
4935
+ }
4936
+ function splitThinkTags(s) {
4403
4937
  s = String(s == null ? '' : s);
4404
- s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*?<\\/think(?:ing)?>/gi, '');
4405
- s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*$/i, '');
4938
+ var bits = [];
4939
+ s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*?<\\/think(?:ing)?>/gi, function (m) {
4940
+ var a = m.indexOf('>');
4941
+ var b = m.toLowerCase().lastIndexOf('</think');
4942
+ if (a >= 0 && b > a) bits.push(m.slice(a + 1, b));
4943
+ return '';
4944
+ });
4945
+ s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*$/i, function (m) {
4946
+ var a = m.indexOf('>');
4947
+ bits.push(a >= 0 ? m.slice(a + 1) : '');
4948
+ return '';
4949
+ });
4406
4950
  s = s.replace(/<\\/think(?:ing)?>/gi, '');
4407
- return s.replace(/^\\n+|\\n+$/g, '').trim();
4951
+ return {
4952
+ visible: s.replace(/^\\n+|\\n+$/g, '').trim(),
4953
+ thinking: bits.join('\\n').replace(/^\\n+|\\n+$/g, '').trim()
4954
+ };
4408
4955
  }
4956
+ function stripThinkTags(s) { return splitThinkTags(s).visible; }
4409
4957
  function clientWorkspaceUrl(rel) {
4410
4958
  if (!workspacePort || !activeId) return '';
4411
4959
  rel = String(rel || '').replace(/^\\/+/, '');
@@ -4788,8 +5336,140 @@ const APP_HTML = `<!doctype html>
4788
5336
  }
4789
5337
 
4790
5338
  let lastSpeaker = null;
4791
- function addRow(who, text, color, name, run, images) {
4792
- if (who === 'bot') text = stripThinkTags(text);
5339
+ let liveThinkOpen = false;
5340
+ let streamTools = [];
5341
+ let pendingTurns = [];
5342
+ let sending = false;
5343
+ // Mirror of server isHarnessUserText. No regex — APP_HTML template
5344
+ // literals eat \\b / \\/ and kill the whole script.
5345
+ function isHarnessUserText(text) {
5346
+ const s = String(text || '');
5347
+ if (s.indexOf('(command output)') === 0 || s.indexOf('(directive result)') === 0) return true;
5348
+ if (s === 'Please continue the current job. Do not stop to ask the user.') return true;
5349
+ if (s.indexOf('AUTO is still on ') === 0) return true;
5350
+ if (s.indexOf('AUTO_EMPTY_RETRY:') === 0) return true;
5351
+ if (s.indexOf('That reply announced work instead of doing it') === 0) return true;
5352
+ return false;
5353
+ }
5354
+ function pendingStoreKey(id) { return 'openzoo.userTurn.' + id; }
5355
+ function rememberUserTurn(id, text, images) {
5356
+ if (!id || !text || isHarnessUserText(text)) return;
5357
+ try { localStorage.setItem(pendingStoreKey(id), JSON.stringify({ text: text, images: images || [] })); } catch (e) {}
5358
+ }
5359
+ function recalledUserTurn(id) {
5360
+ try {
5361
+ const raw = localStorage.getItem(pendingStoreKey(id));
5362
+ if (!raw) return null;
5363
+ const j = JSON.parse(raw);
5364
+ if (!j || !j.text || isHarnessUserText(j.text)) return null;
5365
+ return j;
5366
+ } catch (e) { return null; }
5367
+ }
5368
+ function forgetUserTurn(id, text) {
5369
+ try {
5370
+ const j = recalledUserTurn(id);
5371
+ if (!id) return;
5372
+ if (!text || (j && j.text === text)) localStorage.removeItem(pendingStoreKey(id));
5373
+ } catch (e) {}
5374
+ }
5375
+ function thinkLabel(live) { return live ? 'thinking...' : 'thought'; }
5376
+ function foldBodyText(think) {
5377
+ const tools = streamTools.length ? streamTools.join('\\n') : '';
5378
+ const t = String(think || '').trim();
5379
+ if (t && tools) return t + '\\n' + tools;
5380
+ return t || tools;
5381
+ }
5382
+ function makeThinkFold(text, live, open) {
5383
+ const fold = document.createElement('div');
5384
+ fold.className = 'thinkfold' + (open ? ' open' : '');
5385
+ const chip = document.createElement('button');
5386
+ chip.type = 'button';
5387
+ chip.className = 'thinkchip';
5388
+ chip.textContent = thinkLabel(live);
5389
+ chip.setAttribute('aria-expanded', open ? 'true' : 'false');
5390
+ const body = document.createElement('div');
5391
+ body.className = 'thinkbody';
5392
+ if (open) body.textContent = text;
5393
+ chip.addEventListener('click', function (e) {
5394
+ e.preventDefault();
5395
+ const next = !fold.classList.contains('open');
5396
+ fold.classList.toggle('open', next);
5397
+ chip.setAttribute('aria-expanded', next ? 'true' : 'false');
5398
+ if (live) liveThinkOpen = next;
5399
+ if (next) body.textContent = fold.getAttribute('data-think') || text || '';
5400
+ else body.textContent = '';
5401
+ if (live) paintStream();
5402
+ });
5403
+ fold.setAttribute('data-think', text || '');
5404
+ fold.appendChild(chip);
5405
+ fold.appendChild(body);
5406
+ return fold;
5407
+ }
5408
+ function runFoldLabel(status) {
5409
+ if (status === 'pending') return 'run';
5410
+ if (status === 'running') return 'running...';
5411
+ if (status === 'denied') return 'denied';
5412
+ return 'ran';
5413
+ }
5414
+ function parseLegacyRun(text) {
5415
+ const raw = String(text || '');
5416
+ if (!raw.startsWith('$ ')) return null;
5417
+ const nl = raw.indexOf('\\n');
5418
+ if (nl < 0) return { command: raw.slice(2), output: '', status: 'done' };
5419
+ return { command: raw.slice(2, nl), output: raw.slice(nl + 1), status: 'done' };
5420
+ }
5421
+ function makeRunFold(command, output, status) {
5422
+ const pending = status === 'pending' || status === 'running';
5423
+ const fold = document.createElement('div');
5424
+ fold.className = 'runfold' + (pending ? ' open' : '');
5425
+ const chip = document.createElement('button');
5426
+ chip.type = 'button';
5427
+ chip.className = 'runchip';
5428
+ chip.textContent = runFoldLabel(status);
5429
+ chip.setAttribute('aria-expanded', pending ? 'true' : 'false');
5430
+ const body = document.createElement('div');
5431
+ body.className = 'runbody';
5432
+ function fillBody() {
5433
+ body.innerHTML = '';
5434
+ const cmdEl = document.createElement('div');
5435
+ cmdEl.className = 'runcmd';
5436
+ cmdEl.textContent = '$ ' + command;
5437
+ cmdEl.appendChild(copyBtn(() => command, 'copy'));
5438
+ body.appendChild(cmdEl);
5439
+ if (status && status !== 'pending') {
5440
+ const st = document.createElement('div');
5441
+ st.className = 'runstatus';
5442
+ st.textContent = status === 'running' ? 'Running…' : status === 'denied' ? 'Denied' : 'Done';
5443
+ body.appendChild(st);
5444
+ }
5445
+ if (output) {
5446
+ const out = document.createElement('pre');
5447
+ out.className = 'runoutput';
5448
+ out.textContent = output;
5449
+ out.appendChild(copyBtn(() => output, 'copy'));
5450
+ body.appendChild(out);
5451
+ }
5452
+ }
5453
+ if (pending) fillBody();
5454
+ chip.addEventListener('click', function (e) {
5455
+ e.preventDefault();
5456
+ const next = !fold.classList.contains('open');
5457
+ fold.classList.toggle('open', next);
5458
+ chip.setAttribute('aria-expanded', next ? 'true' : 'false');
5459
+ if (next) fillBody();
5460
+ else body.innerHTML = '';
5461
+ });
5462
+ fold.appendChild(chip);
5463
+ fold.appendChild(body);
5464
+ return fold;
5465
+ }
5466
+ function addRow(who, text, color, name, run, images, thinking, live) {
5467
+ if (who === 'bot') {
5468
+ text = scrubBotText(text);
5469
+ const parts = splitThinkTags(text);
5470
+ text = parts.visible;
5471
+ if (!thinking) thinking = parts.thinking;
5472
+ }
4793
5473
  const speakerKey = who + '|' + name;
4794
5474
  if (who === 'bot' && speakerKey !== lastSpeaker) {
4795
5475
  const hdr = document.createElement('div');
@@ -4800,17 +5480,25 @@ const APP_HTML = `<!doctype html>
4800
5480
  lastSpeaker = speakerKey;
4801
5481
  const row = document.createElement('div');
4802
5482
  row.className = 'row ' + who;
5483
+ const col = document.createElement('div');
5484
+ col.className = 'msgcol';
5485
+ const thinkText = (who === 'bot' && thinking) ? String(thinking).trim() : '';
5486
+ if (live) {
5487
+ const fold = makeThinkFold(foldBodyText(thinkText), true, !!liveThinkOpen);
5488
+ fold.id = 'streamThink';
5489
+ fold.hidden = false;
5490
+ col.appendChild(fold);
5491
+ } else if (thinkText) {
5492
+ col.appendChild(makeThinkFold(thinkText, false, false));
5493
+ }
4803
5494
  if (run) {
5495
+ const cmd = run.command || text;
5496
+ const st = run.status || 'pending';
5497
+ const pending = st === 'pending' || st === 'running';
4804
5498
  const card = document.createElement('div');
4805
- card.className = 'runcard';
4806
- const cmdEl = document.createElement('div');
4807
- cmdEl.className = 'runcmd';
4808
- cmdEl.textContent = '$ ' + text;
4809
- // Copy WITHOUT the '$ ' prompt — pasting that into a shell is a syntax
4810
- // error, and this is the single most re-run thing in the UI.
4811
- cmdEl.appendChild(copyBtn(() => text, 'copy'));
4812
- card.appendChild(cmdEl);
4813
- if (run.status === 'pending') {
5499
+ card.className = 'runcard' + (pending ? ' pending' : ' folded');
5500
+ card.appendChild(makeRunFold(cmd, run.output || '', st));
5501
+ if (run.id && st === 'pending') {
4814
5502
  const actions = document.createElement('div');
4815
5503
  actions.className = 'runactions';
4816
5504
  const approve = document.createElement('button');
@@ -4832,20 +5520,8 @@ const APP_HTML = `<!doctype html>
4832
5520
  actions.appendChild(approve);
4833
5521
  actions.appendChild(deny);
4834
5522
  card.appendChild(actions);
4835
- } else {
4836
- const status = document.createElement('div');
4837
- status.className = 'runstatus';
4838
- status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
4839
- card.appendChild(status);
4840
- if (run.output) {
4841
- const out = document.createElement('pre');
4842
- out.className = 'runoutput';
4843
- out.textContent = run.output;
4844
- out.appendChild(copyBtn(() => run.output, 'copy'));
4845
- card.appendChild(out);
4846
- }
4847
5523
  }
4848
- row.appendChild(card);
5524
+ col.appendChild(card);
4849
5525
  } else {
4850
5526
  const bubble = document.createElement('div');
4851
5527
  bubble.className = 'bubble';
@@ -4861,12 +5537,22 @@ const APP_HTML = `<!doctype html>
4861
5537
  }
4862
5538
  const textEl = document.createElement('div');
4863
5539
  textEl.innerHTML = renderMentions(text);
5540
+ if (live && (!text || text === '…')) bubble.hidden = true;
4864
5541
  if (who === 'bot') {
4865
5542
  const preview = htmlPreviewUrl(text);
4866
5543
  if (preview) textEl.appendChild(previewFrame(preview, htmlPreviewKey(text, preview)));
4867
5544
  }
5545
+ if (who === 'bot' && isEmptyWalletPayment(text)) {
5546
+ const pay = document.createElement('button');
5547
+ pay.type = 'button';
5548
+ pay.className = 'payneed-btn';
5549
+ pay.textContent = 'payment required';
5550
+ pay.addEventListener('click', function (e) { e.preventDefault(); openWallet(); });
5551
+ textEl.appendChild(pay);
5552
+ maybeOpenPayForEmptyWallet(text);
5553
+ }
4868
5554
  bubble.appendChild(textEl);
4869
- row.appendChild(bubble);
5555
+ col.appendChild(bubble);
4870
5556
  // Copy the message SOURCE, not rendered HTML — markdown, code fences and
4871
5557
  // directive lines are what people want back; innerText drops fences and
4872
5558
  // mangles indentation.
@@ -4882,6 +5568,7 @@ const APP_HTML = `<!doctype html>
4882
5568
  }, 'copy'));
4883
5569
  }
4884
5570
  }
5571
+ row.appendChild(col);
4885
5572
  log.appendChild(row);
4886
5573
  }
4887
5574
 
@@ -4894,10 +5581,11 @@ const APP_HTML = `<!doctype html>
4894
5581
  const full = await loadActiveMessages();
4895
5582
  if (!full || full.id !== activeId) return;
4896
5583
  const renderKey = String(workspacePort) + '|' + full.id + '|' + full.status + '|' + (full.history || []).map(function (h) {
4897
- return [h.who, h.text, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
4898
- }).join('||');
5584
+ return [h.who, h.text, h.thinking, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
5585
+ }).join('||') + '|pending:' + pendingTurns.filter(function (p) { return p.threadId === activeId; }).map(function (p) { return p.text; }).join('\\n')
5586
+ + '|recall:' + ((recalledUserTurn(activeId) || {}).text || '');
4899
5587
  if (renderKey === lastRenderKey) {
4900
- if (streamBuf) paintStream();
5588
+ if (streamBuf || streamThink) paintStream();
4901
5589
  return;
4902
5590
  }
4903
5591
  lastRenderKey = renderKey;
@@ -4907,9 +5595,33 @@ const APP_HTML = `<!doctype html>
4907
5595
  parkedPreviews = parkPreviews();
4908
5596
  log.innerHTML = '';
4909
5597
  lastSpeaker = null;
5598
+ const seenUser = {};
4910
5599
  for (const h of full.history) {
4911
- addRow(h.who, h.text, h.color || t.color, h.name || t.name,
4912
- h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
5600
+ if (h.who === 'user' && isHarnessUserText(h.text)) continue;
5601
+ if (h.who === 'user') seenUser[h.text] = true;
5602
+ let run;
5603
+ if (h.runId || h.runStatus) {
5604
+ run = { id: h.runId, status: h.runStatus, output: h.runOutput, command: h.text };
5605
+ } else if (h.who === 'bot') {
5606
+ const legacy = parseLegacyRun(h.text);
5607
+ if (legacy) run = { status: legacy.status, output: legacy.output, command: legacy.command };
5608
+ }
5609
+ addRow(h.who, h.text, h.color || t.color, h.name || t.name, run, h.images, h.thinking);
5610
+ }
5611
+ const keepPending = [];
5612
+ for (let i = 0; i < pendingTurns.length; i++) {
5613
+ const p = pendingTurns[i];
5614
+ if (p.threadId !== activeId) { keepPending.push(p); continue; }
5615
+ if (seenUser[p.text]) { forgetUserTurn(p.threadId, p.text); continue; }
5616
+ addRow('user', p.text, t.color, t.name, undefined, p.images);
5617
+ seenUser[p.text] = true;
5618
+ keepPending.push(p);
5619
+ }
5620
+ pendingTurns = keepPending;
5621
+ const recalled = recalledUserTurn(activeId);
5622
+ if (recalled && recalled.text) {
5623
+ if (seenUser[recalled.text]) forgetUserTurn(activeId, recalled.text);
5624
+ else addRow('user', recalled.text, t.color, t.name, undefined, recalled.images);
4913
5625
  }
4914
5626
  if (full.status === 'thinking') {
4915
5627
  if (full.liveStatus) streamStatus = full.liveStatus;
@@ -4919,19 +5631,21 @@ const APP_HTML = `<!doctype html>
4919
5631
  } else if (streamRaceId !== full.id) {
4920
5632
  streamRace = null;
4921
5633
  }
4922
- addRow('bot', streamBuf || '…', t.color, t.name);
5634
+ addRow('bot', streamBuf || '…', t.color, t.name, undefined, undefined, streamThink, true);
4923
5635
  // Tag the live bubble so deltas can repaint just this node instead of
4924
5636
  // re-rendering (and re-fetching) the whole thread on every token.
4925
5637
  const b = log.querySelector('.row:last-child .bubble');
4926
5638
  if (b) { b.id = 'streamBubble'; paintStream(); }
4927
5639
  }
4928
5640
  if (wasNearBottom) log.scrollTop = log.scrollHeight;
5641
+ if (findBarOpen()) applyFind(true);
4929
5642
  }
4930
5643
 
4931
5644
  // --- live token stream ---------------------------------------------------
4932
5645
  // The server has always been able to stream; /drive just never asked for it,
4933
5646
  // so a turn showed "…" for its whole duration and then arrived in one lump.
4934
5647
  let streamBuf = '';
5648
+ let streamThink = '';
4935
5649
  let streamStatus = '';
4936
5650
  let streamRace = null;
4937
5651
  let streamRaceId = '';
@@ -4985,44 +5699,117 @@ const APP_HTML = `<!doctype html>
4985
5699
  return '<div class="racewrap"><div class="racecaption">' + escapeHtml(caption) + '</div>'
4986
5700
  + '<div class="racegrid n' + n + '">' + cells + '</div>' + judge + '</div>';
4987
5701
  }
5702
+ function streamParts() {
5703
+ const parts = splitThinkTags(streamBuf);
5704
+ var think = streamThink;
5705
+ if (parts.thinking) think = think ? (think + '\\n' + parts.thinking) : parts.thinking;
5706
+ return { visible: parts.visible, think: think };
5707
+ }
4988
5708
  function liveBubbleHtml() {
4989
5709
  if (raceIsLive(streamRace)) return raceGridHtml(streamRace);
4990
- if (streamBuf) {
4991
- const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
4992
- ? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
4993
- return escapeHtml(stripThinkTags(streamBuf)) + trail;
5710
+ const vis = streamParts().visible;
5711
+ return vis ? escapeHtml(vis) : '';
5712
+ }
5713
+ function paintThinkFoldLive(think) {
5714
+ const fold = document.getElementById('streamThink');
5715
+ if (!fold) return;
5716
+ const chip = fold.querySelector('.thinkchip');
5717
+ const body = fold.querySelector('.thinkbody');
5718
+ const text = foldBodyText(think);
5719
+ fold.hidden = false;
5720
+ fold.setAttribute('data-think', text);
5721
+ fold.classList.toggle('open', !!liveThinkOpen);
5722
+ if (chip) {
5723
+ chip.textContent = 'thinking...';
5724
+ chip.setAttribute('aria-expanded', liveThinkOpen ? 'true' : 'false');
4994
5725
  }
4995
- const dots = '<span class="dots"><span></span><span></span><span></span></span>';
4996
- const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
4997
- return dots + (st ? ' ' + st : '');
5726
+ if (body) body.textContent = liveThinkOpen ? text : '';
5727
+ }
5728
+ // Follow the live assistant bubble only while the reader is still on
5729
+ // it. A post-paint pad check is what broke autoscroll: a lump >140px
5730
+ // (long reply, 400 dump, table) put them past the pad, then every
5731
+ // later token also refused to follow. Snapshot first; sticky-follow
5732
+ // the turn they are watching; drop follow if they scroll up.
5733
+ let followLive = false;
5734
+ let pinningLog = false;
5735
+ let pinRaf = 0;
5736
+ function pinLogBottom() {
5737
+ pinningLog = true;
5738
+ log.scrollTop = log.scrollHeight;
5739
+ // Tables / markdown can grow the bubble after this paint's layout.
5740
+ // A second pin on the next frame follows that growth. Do not
5741
+ // re-check the pad here — the snapshot already decided to follow.
5742
+ if (pinRaf) cancelAnimationFrame(pinRaf);
5743
+ pinRaf = requestAnimationFrame(function () {
5744
+ pinRaf = 0;
5745
+ log.scrollTop = log.scrollHeight;
5746
+ pinningLog = false;
5747
+ });
5748
+ }
5749
+ log.addEventListener('scroll', function () {
5750
+ if (pinningLog) return;
5751
+ followLive = log.scrollHeight - log.scrollTop - log.clientHeight < 140;
5752
+ });
5753
+ function ensureLiveBotRow() {
5754
+ if (document.getElementById('streamBubble')) return;
5755
+ const t = knownThreads.find((x) => x.id === activeId);
5756
+ if (!t) return;
5757
+ addRow('bot', streamBuf || '…', t.color, t.name, undefined, undefined, streamThink, true);
5758
+ const row = log.querySelector('.row:last-child');
5759
+ if (!row) return;
5760
+ const b = row.querySelector('.bubble');
5761
+ if (b) b.id = 'streamBubble';
5762
+ const fold = row.querySelector('.thinkfold');
5763
+ if (fold) fold.id = 'streamThink';
4998
5764
  }
4999
5765
  function paintStream() {
5000
- const b = document.getElementById('streamBubble');
5001
- if (!b) { render(); return; }
5766
+ let b = document.getElementById('streamBubble');
5767
+ if (!b) {
5768
+ // Claude TUI start/replace used to call render(), which wiped #log
5769
+ // and rebuilt from a fetch that could miss the just-sent user turn.
5770
+ // Keep existing user rows; only attach the live assistant bubble.
5771
+ ensureLiveBotRow();
5772
+ b = document.getElementById('streamBubble');
5773
+ if (!b) return;
5774
+ }
5775
+ // Snapshot BEFORE the bubble grows. Same idea as render()'s wasNearBottom.
5776
+ const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 140;
5777
+ if (wasNearBottom) followLive = true;
5778
+ const parts = streamParts();
5779
+ paintThinkFoldLive(parts.think);
5002
5780
  if (raceIsLive(streamRace)) {
5781
+ b.hidden = false;
5003
5782
  b.classList.add('raceboard');
5004
5783
  b.innerHTML = liveBubbleHtml();
5005
- if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
5784
+ if (wasNearBottom || followLive) pinLogBottom();
5785
+ scheduleFindPaint();
5006
5786
  return;
5007
5787
  }
5008
5788
  b.classList.remove('raceboard');
5009
- // Deltas stay as text; a silent wait paints dots + one mutating status
5010
- // line so a 20–40s pay/model wait is obviously alive.
5011
- if (streamBuf && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
5012
- b.textContent = streamBuf;
5789
+ // Model text only. Tool status / CoT live in the thinking… fold.
5790
+ if (parts.visible) {
5791
+ b.hidden = false;
5792
+ b.textContent = scrubBotText(parts.visible);
5013
5793
  } else {
5794
+ b.hidden = true;
5014
5795
  b.innerHTML = liveBubbleHtml();
5015
5796
  }
5016
- if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
5797
+ if (wasNearBottom || followLive) pinLogBottom();
5798
+ scheduleFindPaint();
5017
5799
  }
5018
5800
  function connectStream(id) {
5019
5801
  if (!id || esId === id) return;
5020
5802
  if (es) es.close();
5021
5803
  esId = id;
5022
5804
  streamBuf = '';
5805
+ streamThink = '';
5806
+ streamTools = [];
5023
5807
  streamStatus = '';
5024
5808
  streamRace = null;
5025
5809
  streamRaceId = id;
5810
+ liveThinkOpen = false;
5811
+ // New thread: do not inherit follow from the previous log position.
5812
+ followLive = log.scrollHeight - log.scrollTop - log.clientHeight < 140;
5026
5813
  raceHandoff += 1;
5027
5814
  es = new EventSource('/stream/' + id); // EventSource reconnects on its own
5028
5815
  es.onmessage = (e) => {
@@ -5030,9 +5817,17 @@ const APP_HTML = `<!doctype html>
5030
5817
  try { ev = JSON.parse(e.data); } catch { return; }
5031
5818
  if (ev.type === 'start') {
5032
5819
  streamBuf = '';
5820
+ streamThink = '';
5821
+ streamTools = [];
5033
5822
  streamStatus = ev.detail || 'waiting on model…';
5034
5823
  streamRace = null;
5035
5824
  streamRaceId = id;
5825
+ liveThinkOpen = false;
5826
+ paintStream();
5827
+ }
5828
+ else if (ev.type === 'tool') {
5829
+ const line = ev.detail || '';
5830
+ if (line && streamTools[streamTools.length - 1] !== line) streamTools.push(line);
5036
5831
  paintStream();
5037
5832
  }
5038
5833
  else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
@@ -5043,8 +5838,14 @@ const APP_HTML = `<!doctype html>
5043
5838
  }
5044
5839
  paintStream();
5045
5840
  }
5841
+ else if (ev.type === 'think') {
5842
+ streamThink = ev.replace ? (ev.delta || '') : streamThink + (ev.delta || '');
5843
+ paintStream();
5844
+ }
5046
5845
  else if (ev.type === 'delta') {
5047
5846
  streamBuf = ev.replace ? (ev.delta || '') : streamBuf + (ev.delta || '');
5847
+ // TUI frames replace the bubble without eating the folded thinking row.
5848
+ if (ev.replace && /<think/i.test(ev.delta || '')) streamThink = '';
5048
5849
  paintStream();
5049
5850
  }
5050
5851
  else if (ev.type === 'final' || ev.type === 'run-pending') {
@@ -5054,6 +5855,8 @@ const APP_HTML = `<!doctype html>
5054
5855
  setTimeout(function () {
5055
5856
  if (token !== raceHandoff) return;
5056
5857
  streamBuf = '';
5858
+ streamThink = '';
5859
+ streamTools = [];
5057
5860
  streamStatus = '';
5058
5861
  streamRace = null;
5059
5862
  render();
@@ -5061,6 +5864,8 @@ const APP_HTML = `<!doctype html>
5061
5864
  return;
5062
5865
  }
5063
5866
  streamBuf = '';
5867
+ streamThink = '';
5868
+ streamTools = [];
5064
5869
  streamStatus = '';
5065
5870
  streamRace = null;
5066
5871
  render();
@@ -5121,15 +5926,22 @@ const APP_HTML = `<!doctype html>
5121
5926
 
5122
5927
  async function submit() {
5123
5928
  const task = inp.value.trim();
5124
- if (/^\/sitrep\b/i.test(task)) {
5929
+ // String compare — never a regex. A sitrep word-boundary regex inside
5930
+ // this template literal is eaten by the backtick parser and the whole
5931
+ // client script dies (empty sidebar, send is a no-op).
5932
+ const s = String(task).trim().toLowerCase();
5933
+ if (s === '/sitrep' || s.startsWith('/sitrep ')) {
5125
5934
  inp.value = '';
5126
5935
  send.classList.remove('show');
5127
5936
  openSitrep();
5128
5937
  return;
5129
5938
  }
5130
5939
  if ((!task && !pendingFiles.length && !pendingImages.length) || !activeId) return;
5131
- inp.value = '';
5132
- send.classList.remove('show');
5940
+ if (sending) return;
5941
+ sending = true;
5942
+ const draft = inp.value;
5943
+ const hadFiles = pendingFiles.slice();
5944
+ const hadImages = pendingImages.slice();
5133
5945
  let full = task;
5134
5946
  // an image with no caption still needs SOME text — an empty text block
5135
5947
  // alongside image_url content gets rejected (400) by at least one path
@@ -5140,13 +5952,39 @@ const APP_HTML = `<!doctype html>
5140
5952
  : '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
5141
5953
  }
5142
5954
  const images = pendingImages.map((i) => i.dataUrl);
5143
- pendingFiles = [];
5144
- pendingImages = [];
5145
- renderAttachChips();
5146
- await fetch(API + '/drive', {
5147
- method: 'POST', headers: { 'content-type': 'application/json' },
5148
- body: JSON.stringify({ threadId: activeId, task: full, images }),
5149
- });
5955
+ const t = knownThreads.find((x) => x.id === activeId) || { color: '', name: '' };
5956
+ pendingTurns.push({ threadId: activeId, text: full, images: images });
5957
+ rememberUserTurn(activeId, full, images);
5958
+ addRow('user', full, t.color, t.name, undefined, images);
5959
+ try { log.scrollTop = log.scrollHeight; } catch (e) {}
5960
+ followLive = true;
5961
+ let persisted = false;
5962
+ try {
5963
+ const r = await fetch(API + '/drive', {
5964
+ method: 'POST', headers: { 'content-type': 'application/json' },
5965
+ body: JSON.stringify({ threadId: activeId, task: full, images }),
5966
+ });
5967
+ let body = {};
5968
+ try { body = await r.json(); } catch (e) { body = {}; }
5969
+ persisted = r.ok && body.persisted !== false;
5970
+ } catch (e) { persisted = false; }
5971
+ if (persisted) {
5972
+ inp.value = '';
5973
+ send.classList.remove('show');
5974
+ pendingFiles = [];
5975
+ pendingImages = [];
5976
+ renderAttachChips();
5977
+ } else {
5978
+ pendingTurns = pendingTurns.filter((p) => !(p.threadId === activeId && p.text === full));
5979
+ forgetUserTurn(activeId, full);
5980
+ inp.value = draft;
5981
+ pendingFiles = hadFiles;
5982
+ pendingImages = hadImages;
5983
+ renderAttachChips();
5984
+ send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0);
5985
+ lastRenderKey = '';
5986
+ }
5987
+ sending = false;
5150
5988
  render();
5151
5989
  }
5152
5990
 
@@ -5339,26 +6177,196 @@ const APP_HTML = `<!doctype html>
5339
6177
  // It cannot change the version baked into the IMAGE — only the site's spawn
5340
6178
  // path can — so it says restart, not update. Promising an upgrade it cannot
5341
6179
  // deliver is how a UI teaches people to distrust it.
5342
- // Cmd/Ctrl+K -> search, the shortcut every chat app trains you to expect.
5343
- // Escape returns focus to the composer instead of leaving you stranded in
5344
- // a box you just cleared. Bound on keydown at the document so it works no
5345
- // matter which pane has focus.
6180
+ // Cmd/Ctrl+K -> sidebar thread search (GET /search). Cmd/Ctrl+F is find
6181
+ // inside the current #log a different box, on purpose. Bound on document
6182
+ // keydown so it works no matter which pane has focus; preventDefault so
6183
+ // Chromium cannot swallow F for a page-find that was never wired.
5346
6184
  document.addEventListener('keydown', (e) => {
5347
6185
  const k = (e.key || '').toLowerCase();
5348
- if ((e.metaKey || e.ctrlKey) && k === 'k') {
6186
+ const withMod = e.metaKey || e.ctrlKey;
6187
+ if (withMod && k === 'k') {
5349
6188
  e.preventDefault();
5350
6189
  const el = document.getElementById('search');
5351
6190
  if (el) { el.focus(); el.select(); }
5352
6191
  return;
5353
6192
  }
6193
+ if (withMod && k === 'f') {
6194
+ e.preventDefault();
6195
+ openFindBar();
6196
+ return;
6197
+ }
6198
+ if (withMod && k === 'g' && findBarOpen()) {
6199
+ e.preventDefault();
6200
+ findStep(e.shiftKey ? -1 : 1);
6201
+ return;
6202
+ }
6203
+ if (k === 'escape' && findBarOpen()) {
6204
+ e.preventDefault();
6205
+ closeFindBar();
6206
+ return;
6207
+ }
5354
6208
  // Cmd/Ctrl+Enter sends from anywhere — useful when focus drifted into a
5355
6209
  // RUN card or a copy button mid-thought.
5356
- if ((e.metaKey || e.ctrlKey) && k === 'enter') {
6210
+ if (withMod && k === 'enter') {
5357
6211
  e.preventDefault();
5358
6212
  try { submit(); } catch (err) { /* not ready */ }
5359
6213
  }
5360
6214
  });
5361
6215
 
6216
+ // FIND IN THREAD. Highlights visible .bubble text only — not sidebar
6217
+ // threads, not think folds, not RUN cards, not tool JSON dumps.
6218
+ let findMarks = [];
6219
+ let findIndex = 0;
6220
+ let findPaintTimer = null;
6221
+ const findBar = document.getElementById('findBar');
6222
+ const findInp = document.getElementById('findInp');
6223
+ const findCount = document.getElementById('findCount');
6224
+ function scheduleFindPaint() {
6225
+ if (!findBarOpen()) return;
6226
+ clearTimeout(findPaintTimer);
6227
+ findPaintTimer = setTimeout(function () { applyFind(true); }, 160);
6228
+ }
6229
+ function findBarOpen() {
6230
+ const el = document.getElementById('findBar');
6231
+ return !!(el && el.classList.contains('show'));
6232
+ }
6233
+ function placeFindBar() {
6234
+ const bar = document.getElementById('findBar');
6235
+ const main = document.getElementById('main');
6236
+ if (!bar || !chatHeader || !main) return;
6237
+ const top = chatHeader.getBoundingClientRect().bottom - main.getBoundingClientRect().top + 8;
6238
+ bar.style.top = Math.max(8, Math.round(top)) + 'px';
6239
+ }
6240
+ function clearFindMarks() {
6241
+ const logEl = document.getElementById('log');
6242
+ if (!logEl) { findMarks = []; return; }
6243
+ const marks = logEl.querySelectorAll('mark.findhit');
6244
+ for (let i = 0; i < marks.length; i++) {
6245
+ const mark = marks[i];
6246
+ const parent = mark.parentNode;
6247
+ if (!parent) continue;
6248
+ parent.replaceChild(document.createTextNode(mark.textContent), mark);
6249
+ parent.normalize();
6250
+ }
6251
+ findMarks = [];
6252
+ }
6253
+ function paintFindCount() {
6254
+ if (!findCount) return;
6255
+ const n = findMarks.length;
6256
+ findCount.textContent = n ? ((findIndex + 1) + ' / ' + n) : (findInp && findInp.value.trim() ? '0 / 0' : '');
6257
+ }
6258
+ function skipFindNode(node) {
6259
+ const p = node && node.parentNode;
6260
+ if (!p || !p.closest) return true;
6261
+ return !!p.closest('.copybtn, .html-preview-wrap, button, script, style');
6262
+ }
6263
+ function highlightTextNode(node, query) {
6264
+ const text = node.nodeValue || '';
6265
+ const hay = text.toLowerCase();
6266
+ const needle = query.toLowerCase();
6267
+ if (!needle) return;
6268
+ let from = 0;
6269
+ const parts = [];
6270
+ let idx = hay.indexOf(needle, from);
6271
+ while (idx !== -1) {
6272
+ if (idx > from) parts.push({ t: text.slice(from, idx), hit: false });
6273
+ parts.push({ t: text.slice(idx, idx + needle.length), hit: true });
6274
+ from = idx + needle.length;
6275
+ idx = hay.indexOf(needle, from);
6276
+ }
6277
+ if (!parts.length) return;
6278
+ if (from < text.length) parts.push({ t: text.slice(from), hit: false });
6279
+ const frag = document.createDocumentFragment();
6280
+ for (let i = 0; i < parts.length; i++) {
6281
+ if (parts[i].hit) {
6282
+ const mark = document.createElement('mark');
6283
+ mark.className = 'findhit';
6284
+ mark.textContent = parts[i].t;
6285
+ findMarks.push(mark);
6286
+ frag.appendChild(mark);
6287
+ } else {
6288
+ frag.appendChild(document.createTextNode(parts[i].t));
6289
+ }
6290
+ }
6291
+ node.parentNode.replaceChild(frag, node);
6292
+ }
6293
+ function applyFind(preserve) {
6294
+ const q = findInp ? findInp.value.trim() : '';
6295
+ const keep = preserve ? findIndex : 0;
6296
+ clearFindMarks();
6297
+ if (!q) { findIndex = 0; paintFindCount(); return; }
6298
+ const logEl = document.getElementById('log');
6299
+ if (!logEl) { paintFindCount(); return; }
6300
+ const bubbles = logEl.querySelectorAll('.bubble');
6301
+ for (let b = 0; b < bubbles.length; b++) {
6302
+ const nodes = [];
6303
+ const walker = document.createTreeWalker(bubbles[b], NodeFilter.SHOW_TEXT, null);
6304
+ let n = walker.nextNode();
6305
+ while (n) {
6306
+ if (n.nodeValue && !skipFindNode(n)) nodes.push(n);
6307
+ n = walker.nextNode();
6308
+ }
6309
+ for (let i = 0; i < nodes.length; i++) highlightTextNode(nodes[i], q);
6310
+ }
6311
+ if (!findMarks.length) { findIndex = 0; paintFindCount(); return; }
6312
+ findIndex = keep % findMarks.length;
6313
+ if (findIndex < 0) findIndex = 0;
6314
+ focusFindHit(findIndex);
6315
+ }
6316
+ function focusFindHit(i) {
6317
+ for (let m = 0; m < findMarks.length; m++) findMarks[m].classList.remove('cur');
6318
+ const mark = findMarks[i];
6319
+ if (!mark) { paintFindCount(); return; }
6320
+ mark.classList.add('cur');
6321
+ if (mark.scrollIntoView) mark.scrollIntoView({ block: 'center', inline: 'nearest' });
6322
+ paintFindCount();
6323
+ }
6324
+ function findStep(dir) {
6325
+ if (!findMarks.length) return;
6326
+ findIndex = (findIndex + dir + findMarks.length) % findMarks.length;
6327
+ focusFindHit(findIndex);
6328
+ }
6329
+ function openFindBar() {
6330
+ if (!findBar || !findInp) return;
6331
+ findBar.classList.add('show');
6332
+ placeFindBar();
6333
+ findInp.focus();
6334
+ findInp.select();
6335
+ if (findInp.value.trim()) applyFind(true);
6336
+ else paintFindCount();
6337
+ }
6338
+ function closeFindBar() {
6339
+ if (findBar) findBar.classList.remove('show');
6340
+ clearFindMarks();
6341
+ findIndex = 0;
6342
+ if (findCount) findCount.textContent = '';
6343
+ if (inp) inp.focus();
6344
+ }
6345
+ if (findInp) {
6346
+ findInp.addEventListener('input', () => applyFind(false));
6347
+ findInp.addEventListener('keydown', (e) => {
6348
+ if (e.key === 'Enter') {
6349
+ e.preventDefault();
6350
+ findStep(e.shiftKey ? -1 : 1);
6351
+ }
6352
+ if (e.key === 'Escape') {
6353
+ e.preventDefault();
6354
+ closeFindBar();
6355
+ }
6356
+ });
6357
+ }
6358
+ if (findBar) {
6359
+ const prevBtn = document.getElementById('findPrev');
6360
+ const nextBtn = document.getElementById('findNext');
6361
+ const closeBtn = document.getElementById('findClose');
6362
+ if (prevBtn) prevBtn.addEventListener('click', () => findStep(-1));
6363
+ if (nextBtn) nextBtn.addEventListener('click', () => findStep(1));
6364
+ if (closeBtn) closeBtn.addEventListener('click', closeFindBar);
6365
+ }
6366
+ if (window.electronAPI && typeof window.electronAPI.onFindInThread === 'function') {
6367
+ window.electronAPI.onFindInThread(function () { openFindBar(); });
6368
+ }
6369
+
5362
6370
  const reloadBtn = document.getElementById('reloadBtn');
5363
6371
  if (reloadBtn) {
5364
6372
  reloadBtn.addEventListener('click', async () => {
@@ -5386,6 +6394,42 @@ const APP_HTML = `<!doctype html>
5386
6394
  const top = chatHeader.getBoundingClientRect().bottom - mainEl.getBoundingClientRect().top + 8;
5387
6395
  hud.style.top = Math.max(8, Math.round(top)) + 'px';
5388
6396
  }
6397
+ function fmtDockX(n) {
6398
+ if (n == null || !Number.isFinite(n)) return '—';
6399
+ return (n >= 100 ? String(Math.round(n)) : Number(n).toFixed(n >= 10 ? 1 : 2)) + 'x';
6400
+ }
6401
+ function paintDock(you) {
6402
+ const spillEl = document.getElementById('dockSpill');
6403
+ const sessEl = document.getElementById('dockSession');
6404
+ const paidEl = document.getElementById('dockPaid');
6405
+ const bindEl = document.getElementById('dockBind');
6406
+ const callsEl = document.getElementById('dockCalls');
6407
+ if (!spillEl || !sessEl || !paidEl || !bindEl || !callsEl) return;
6408
+ if (you && you.proxyReachable === false) {
6409
+ spillEl.textContent = '—';
6410
+ spillEl.className = 'dv';
6411
+ sessEl.textContent = '—';
6412
+ sessEl.className = 'dv';
6413
+ paidEl.textContent = 'proxy unreachable';
6414
+ bindEl.textContent = '—';
6415
+ callsEl.textContent = '—';
6416
+ return;
6417
+ }
6418
+ const spent = Number(you && you.spentUsd) || 0;
6419
+ const direct = Number(you && you.directUsd) || 0;
6420
+ const spillX = Number(you && you.spilled && you.spilled.savingX);
6421
+ const spillCalls = Number(you && you.spilled && you.spilled.calls) || 0;
6422
+ const spillOn = Number.isFinite(spillX) && spillX > 0;
6423
+ const bound = spillOn || spillCalls > 0;
6424
+ const sessionX = spent > 0 ? direct / spent : null;
6425
+ spillEl.textContent = spillOn ? fmtDockX(spillX) : '—';
6426
+ spillEl.className = spillOn ? 'dv hlime' : 'dv';
6427
+ sessEl.textContent = sessionX == null ? '—' : fmtDockX(sessionX);
6428
+ sessEl.className = 'dv';
6429
+ paidEl.textContent = usd(spent);
6430
+ bindEl.textContent = bound ? 'yes' : 'no';
6431
+ callsEl.textContent = String((you && you.paidCalls) || 0);
6432
+ }
5389
6433
  function usd(n) {
5390
6434
  if (n === null || n === undefined) return '—';
5391
6435
  if (n === 0) return '$0';
@@ -5398,6 +6442,22 @@ const APP_HTML = `<!doctype html>
5398
6442
  // straight to localhost:8402 would work fine, but routing it through
5399
6443
  // our own backend keeps one fetch path if that ever needs to change.
5400
6444
  const you = await (await fetch(API + '/hud-summary')).json();
6445
+ const proxyDown = you.proxyReachable === false;
6446
+ setHudTick(proxyDown ? 2000 : 30000);
6447
+ if (proxyDown) {
6448
+ const creditEl = document.getElementById('hCredit');
6449
+ if (creditEl) creditEl.textContent = '—';
6450
+ document.getElementById('hYouSpent').textContent = 'proxy unreachable';
6451
+ document.getElementById('hYouCogs').textContent = '—';
6452
+ document.getElementById('hYouMargin').textContent = '—';
6453
+ document.getElementById('hYouDirect').textContent = '—';
6454
+ document.getElementById('hYouSaved').textContent = '—';
6455
+ document.getElementById('hFoot').textContent = 'proxy unreachable';
6456
+ const hintEl = document.getElementById('hHint');
6457
+ if (hintEl) hintEl.className = 'hhint';
6458
+ paintDock(you);
6459
+ return;
6460
+ }
5401
6461
  const creditEl = document.getElementById('hCredit');
5402
6462
  if (creditEl) creditEl.textContent = (you.creditUsd == null) ? '—' : usd(Number(you.creditUsd) || 0);
5403
6463
  const subRow = document.getElementById('hSubRow');
@@ -5426,13 +6486,15 @@ const APP_HTML = `<!doctype html>
5426
6486
  const savedEl = document.getElementById('hYouSaved');
5427
6487
  const hintEl = document.getElementById('hHint');
5428
6488
  if (spent > 0) {
5429
- const mult = direct / spent;
6489
+ const sav = formatSavingLabel(you);
6490
+ const mult = sav.mult;
5430
6491
  // honest either way: >=1x is a real saving vs a naked direct call,
5431
6492
  // <1x means you're currently paying MORE than direct would cost —
5432
6493
  // don't dress that up as green when it isn't one.
5433
6494
  // Asking a bound corpus makes this genuinely large (the counterfactual
5434
6495
  // is shipping the WHOLE corpus), so 2dp would read as noise up there.
5435
- savedEl.textContent = (mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x';
6496
+ // Label the number: "Nx spilled" when bound, else "Nx session".
6497
+ savedEl.textContent = sav.text;
5436
6498
  savedEl.className = mult >= 1 ? 'hlime' : 'hember';
5437
6499
  // Session direct/spent (never first-call). Ember when cogs > spent —
5438
6500
  // house losing. Do not treat race_unused as a user refund.
@@ -5444,7 +6506,7 @@ const APP_HTML = `<!doctype html>
5444
6506
  hintEl.className = mult >= 1 ? 'hhint' : 'hhint show';
5445
6507
  hintEl.innerHTML = '<b>feed it more.</b> you\\'re billed on the slice actually sent, '
5446
6508
  + 'not the corpus — so the more you bind, the further ahead this gets. '
5447
- + 'small inputs cost more than sending them straight.';
6509
+ + 'small inputs cost more than sending them straight. HUD is spilled-call x when any call bound.';
5448
6510
  }
5449
6511
  } else {
5450
6512
  savedEl.textContent = '—';
@@ -5456,25 +6518,45 @@ const APP_HTML = `<!doctype html>
5456
6518
  }
5457
6519
  }
5458
6520
  document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
6521
+ paintDock(you);
5459
6522
  } catch (e) {
5460
6523
  document.getElementById('hFoot').textContent = 'error: ' + e.message;
5461
6524
  }
5462
6525
  }
5463
6526
  let hudTimer = null;
6527
+ let hudTickMs = 30000;
6528
+ function setHudTick(ms) {
6529
+ if (hudTimer && hudTickMs === ms) return;
6530
+ hudTickMs = ms;
6531
+ if (hudTimer) clearInterval(hudTimer);
6532
+ hudTimer = setInterval(refreshHud, hudTickMs);
6533
+ }
6534
+ function ensureHudTick() {
6535
+ if (hudTimer) return;
6536
+ hudTimer = setInterval(refreshHud, hudTickMs);
6537
+ }
5464
6538
  hudBtn.addEventListener('click', (e) => {
5465
6539
  e.stopPropagation();
5466
6540
  hud.classList.toggle('show');
5467
6541
  if (hud.classList.contains('show')) {
5468
6542
  placeHud();
5469
6543
  refreshHud();
5470
- hudTimer = setInterval(refreshHud, 30000);
5471
- } else if (hudTimer) {
5472
- clearInterval(hudTimer); hudTimer = null;
5473
6544
  }
6545
+ echoSlash('/hud');
5474
6546
  });
5475
- window.addEventListener('resize', () => { if (hud.classList.contains('show')) placeHud(); });
5476
- if (typeof ResizeObserver !== 'undefined' && chatHeader) {
5477
- new ResizeObserver(() => { if (hud.classList.contains('show')) placeHud(); }).observe(chatHeader);
6547
+ refreshHud();
6548
+ ensureHudTick();
6549
+ window.addEventListener('resize', () => {
6550
+ if (hud.classList.contains('show')) placeHud();
6551
+ if (findBarOpen()) placeFindBar();
6552
+ });
6553
+ if (typeof ResizeObserver !== 'undefined') {
6554
+ if (chatHeader) {
6555
+ new ResizeObserver(() => {
6556
+ if (hud.classList.contains('show')) placeHud();
6557
+ if (findBarOpen()) placeFindBar();
6558
+ }).observe(chatHeader);
6559
+ }
5478
6560
  }
5479
6561
  document.addEventListener('click', (e) => { if (!hud.contains(e.target)) hud.classList.remove('show'); });
5480
6562
  </script>
@@ -5652,9 +6734,25 @@ const server = http.createServer((req, res) => {
5652
6734
 
5653
6735
  if (req.method === 'GET' && req.url === '/hud-summary') {
5654
6736
  (async () => {
5655
- let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
5656
- try { you = { ...you, ...(await (await fetch('http://127.0.0.1:8402/v1/session', { signal: AbortSignal.timeout(2000) })).json()) }; }
5657
- catch { /* local proxy not running HUD shows zeros rather than guessing */ }
6737
+ let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null, proxyReachable: false };
6738
+ try {
6739
+ const r = await fetch('http://127.0.0.1:8402/v1/session', { signal: AbortSignal.timeout(2000) });
6740
+ // 402 = empty wallet / Pay — the sidecar is up. Do not paint that as dead.
6741
+ if (r.ok || r.status === 402) {
6742
+ you.proxyReachable = true;
6743
+ if (r.ok) {
6744
+ try { you = { ...you, ...(await r.json()), proxyReachable: true }; }
6745
+ catch { /* body optional */ }
6746
+ }
6747
+ }
6748
+ } catch { /* session down — try /v1/models before calling the proxy dead */ }
6749
+ if (!you.proxyReachable) {
6750
+ try {
6751
+ const r = await fetch('http://127.0.0.1:8402/v1/models', { signal: AbortSignal.timeout(2000) });
6752
+ if (r.ok || r.status === 402) you.proxyReachable = true;
6753
+ } catch { /* local proxy not running — HUD must not pretend spend is $0 */ }
6754
+ }
6755
+ await attachSpilled(you);
5658
6756
  try {
5659
6757
  you.creditUsd = await creditBalance();
5660
6758
  } catch { /* credit is advisory */ }
@@ -5740,7 +6838,7 @@ const server = http.createServer((req, res) => {
5740
6838
  const t = threads.get(req.url.split('/')[2]);
5741
6839
  res.writeHead(200, { 'content-type': 'application/json' });
5742
6840
  res.end(t ? JSON.stringify({
5743
- id: t.id, history: t.history, status: t.status,
6841
+ id: t.id, history: visibleHistory(t.history), status: t.status,
5744
6842
  liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
5745
6843
  liveRace: t.status === 'thinking' ? (t.liveRace || null) : null,
5746
6844
  lastRaceFail: t.lastRaceFail || null,
@@ -5829,28 +6927,39 @@ const server = http.createServer((req, res) => {
5829
6927
  threadId = j.threadId; task = (j.task || '').toString();
5830
6928
  images = Array.isArray(j.images) ? j.images.filter((u) => typeof u === 'string') : [];
5831
6929
  } catch { /* ignore */ }
5832
- res.writeHead(200, { 'content-type': 'application/json' });
5833
- res.end(JSON.stringify({ ok: true }));
6930
+ const ack = (ok, extra = {}) => {
6931
+ if (res.writableEnded) return;
6932
+ const status = extra.status || (ok ? 200 : 500);
6933
+ res.writeHead(status, { 'content-type': 'application/json' });
6934
+ res.end(JSON.stringify({ ok: Boolean(ok), persisted: extra.persisted !== false, ...extra.body }));
6935
+ };
5834
6936
  const t = threads.get(threadId);
5835
- // Every OTHER slash command. Local, free, instant — no model call, so
5836
- // checking your spend or clearing a thread never costs anything.
5837
- // /dir and /mode keep their own handlers below, untouched.
5838
- if (t && /^\//.test(task.trim())) {
6937
+ if (!t) {
6938
+ ack(false, { status: 404, persisted: false });
6939
+ return;
6940
+ }
6941
+ // Grokui slashes stay local. In Auto, Claude's /agents /tasks /context
6942
+ // /model (and any slash grokui does not own) go to the PTY — print-mode
6943
+ // used to stub those as "wizard removed".
6944
+ if (/^\//.test(task.trim())) {
5839
6945
  // Drawer-only. Never dump sitrep into the transcript.
5840
- if (/^\/sitrep\b/i.test(task.trim())) return;
5841
- const handled = await handleSlash(task.trim(), t).catch((e) => `error: ${e.message}`);
5842
- if (handled !== null && handled !== undefined) {
5843
- t.history.push({ who: 'bot', text: handled });
5844
- t.lastActivityAt = Date.now();
5845
- saveThreads();
5846
- return;
6946
+ if (/^\/sitrep\b/i.test(task.trim())) { ack(true, { persisted: true }); return; }
6947
+ if (!(t.runMode === 'auto' && !isGrokuiOwnedSlash(task.trim(), t.runMode))) {
6948
+ const handled = await handleSlash(task.trim(), t).catch((e) => `error: ${e.message}`);
6949
+ if (handled !== null && handled !== undefined) {
6950
+ t.history.push({ who: 'bot', text: handled });
6951
+ t.lastActivityAt = Date.now();
6952
+ saveThreads();
6953
+ ack(true, { persisted: true });
6954
+ return;
6955
+ }
5847
6956
  }
5848
6957
  }
5849
6958
  // "/dir <path>" is a LOCAL control command, not sent to the model at
5850
6959
  // all — free, instant, sets which folder this thread's WRITE/READ/SERVE
5851
6960
  // are scoped to. Respecify any time by sending it again.
5852
6961
  const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
5853
- if (dirCmd && t) {
6962
+ if (dirCmd) {
5854
6963
  const full = path.resolve(expandHome(dirCmd[1].trim()));
5855
6964
  let ok = false;
5856
6965
  try { ok = statSync(full).isDirectory(); } catch { /* not a dir / doesn't exist */ }
@@ -5861,17 +6970,29 @@ const server = http.createServer((req, res) => {
5861
6970
  t.history.push({ who: 'bot', text: `"${full}" isn't a directory that exists.` });
5862
6971
  }
5863
6972
  saveThreads();
6973
+ ack(true, { persisted: true });
5864
6974
  return;
5865
6975
  }
5866
- // "/mode auto|ask" toggles whether RUN: commands execute immediately
5867
- // or wait for an explicit approve/deny also free/instant, no model call
6976
+ // "/mode auto|ask" Auto is the Claude Code harness (openzoo claude
6977
+ // env). Ask stays a chat completion; RUN: waits for approve/deny.
5868
6978
  const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
5869
- if (modeCmd && t) {
6979
+ if (modeCmd) {
5870
6980
  t.runMode = modeCmd[1];
5871
- 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.'}` });
6981
+ if (modeCmd[1] !== 'auto') closeClaudeSession(t.id);
6982
+ t.history.push({ who: 'bot', text: `Run mode set to ${modeCmd[1]}${modeCmd[1] === 'auto' ? ' — Claude Code via OpenZoo (x402). Interactive TUI on a PTY, not --print.' : ' — chat completion; RUN: waits for your approval.'}` });
5872
6983
  saveThreads();
6984
+ ack(true, { persisted: true });
6985
+ return;
6986
+ }
6987
+ // Persist the user bubble to disk BEFORE the model call and before
6988
+ // the HTTP ACK. A proxy 500 / sidecar bounce / remount must not
6989
+ // forget the turn, and must not rewrite it as an AUTO continue.
6990
+ const flushed = persistUserTurn(t, task, images);
6991
+ if (!flushed.ok) {
6992
+ ack(false, { status: 500, persisted: false });
5873
6993
  return;
5874
6994
  }
6995
+ ack(true, { persisted: true });
5875
6996
  // Stream to whoever is watching this thread. emitToThread is a no-op
5876
6997
  // when nobody is, so a spawned subagent nobody has open costs nothing.
5877
6998
  runTurn(threadId, task, (ev) => emitToThread(threadId, ev), images).catch(() => {});
@@ -5886,12 +7007,16 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
5886
7007
 
5887
7008
  export {
5888
7009
  tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
5889
- parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
5890
- handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, runTurn,
7010
+ parseRun, looksLikeMcpAsBash, stripThinkTags, takeThink, safeResolveIn, inDir, listDir,
7011
+ handleSlash, isGrokuiOwnedSlash, newThread, setRunTurnForTest, setBrainAskForTest, setClaudeRunnerForTest, runTurn,
7012
+ runAutoClaudeTurn, autoClaudePrompt,
5891
7013
  AUTO_CONTINUE, AUTO_RACE_RETRY, AUTO_EMPTY_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
5892
- isDoneReply, isTransientModelFail, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
7014
+ isDoneReply, isTransientModelFail, isPaymentFailed, isEmptyWalletPayment, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
5893
7015
  attachChildDir, finishChildDir,
5894
7016
  lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
5895
7017
  filesForCorpus, noteFileForCorpus, noteRunForCorpus, resetFilesForCorpus,
5896
7018
  filesForCorpusKeys, scheduleFilesForCorpus, inFlightChars, BIND_MIN_CHARS, KEEP_MAX,
7019
+ formatSavingLabel, holobrainOf, looksLikeProxyShell, CHAT_NOT_PROXY, PROXY_SHELL_REFUSE,
7020
+ persistUserTurn, isHarnessUserText, visibleHistory, isVisibleHistoryEntry, saveThreads,
7021
+ isClaudeFallbackReply, popClaudeFallbackBot, threadHasVisibleBotReply,
5897
7022
  };