openzoo 0.49.8 → 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,6 +746,34 @@ 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
+ }
719
777
  function isEmptyWalletPayment(text) {
720
778
  // Empty/underfunded only — not a generic HTTP 402 handshake.
721
779
  return /\b(?:wallet is empty|empty wallet|wallet underfunded|underfunded)\b/i.test(String(text || ''));
@@ -852,7 +910,22 @@ without a fact only the user has.
852
910
  When the job is actually finished, emit DONE: as the first line. A status
853
911
  sentence is not a stop — the harness keeps this thread working until DONE:,
854
912
  a real blocking question, or the step cap. Empty output, "(no output)", and
855
- GLOB/GREP with no matches are not finished — try a different command or path.`;
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
+
856
929
  async function bindThread(t) {
857
930
  // Only bind what's NEW since the last successful bind, continuing the
858
931
  // existing context_id — previously this rebuilt and re-sent the WHOLE
@@ -867,15 +940,13 @@ async function bindThread(t) {
867
940
  const corpus = delta.map((h) => '[' + t.name + '] ' + (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
868
941
  if (!corpus.trim()) { t.boundHistoryCount = t.history.length; return; }
869
942
  try {
870
- // ONE CONTEXT PER PROJECT, not per thread. Every thread used to bind to
871
- // its own private context, so sibling agents spawned for the same job were
872
- // memory-isolated: arc-tetris-engine could not recall a thing
873
- // arc-token-bets had established, and the user paid to re-explain shared
874
- // facts to each one. They are a team; the memory should be too.
875
- // The context lives on the ROOT and every descendant binds into it, while
876
- // each thread keeps its OWN boundHistoryCount so nothing is re-sent.
877
- const root = threads.get(rootOf(t).rootId) || t;
878
- let ctx = root.contextId || t.contextId;
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;
879
950
  for (let i = 0; i < corpus.length; i += BIND_CHUNK_BYTES) {
880
951
  const part = corpus.slice(i, i + BIND_CHUNK_BYTES);
881
952
  const body = ctx ? { corpus: part, context_id: ctx } : { corpus: part };
@@ -889,12 +960,12 @@ async function bindThread(t) {
889
960
  // How many chunks the project's holobrain now holds. This is the number
890
961
  // adaptive top_k scales on — without it we would be guessing, which is
891
962
  // exactly how top_k ended up pinned at 8 in the first place.
892
- if (Number(j?.bound)) root.boundItems = (root.boundItems || 0) + Number(j.bound);
963
+ if (Number(j?.bound)) brain.boundItems = (brain.boundItems || 0) + Number(j.bound);
893
964
  else break; // this chunk failed — stop, keep whatever bound so far rather than lose it all
894
965
  }
895
- // Write to the ROOT so later siblings inherit it, and to this thread so
896
- // the per-call header is readable without walking the tree again.
897
- if (ctx) { root.contextId = ctx; t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
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(); }
898
969
  } catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
899
970
  }
900
971
 
@@ -1035,8 +1106,10 @@ const RUN_SHELL = process.platform !== 'win32' && existsSync('/bin/bash') ? '/bi
1035
1106
  const RUN_TIMEOUT_MS = Number(process.env.OZ_RUN_TIMEOUT_MS || 600000);
1036
1107
 
1037
1108
  function execCommand(command, cwd) {
1109
+ if (looksLikeProxyShell(command)) return Promise.resolve(PROXY_SHELL_REFUSE);
1110
+ const guarded = guardFindCwd(command, cwd);
1038
1111
  return new Promise((resolve) => {
1039
- 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) => {
1040
1113
  let out = (stdout || '') + (stderr ? '\n' + stderr : '');
1041
1114
  if (err) out += `\n(exit ${err.code ?? 1})`;
1042
1115
  resolve(keepWhole(out).trim() || '(no output)');
@@ -1083,11 +1156,41 @@ const SLASH_COMMANDS = [
1083
1156
  { name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
1084
1157
  { name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
1085
1158
  { name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
1086
- { 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' },
1087
1160
  ];
1088
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
+
1089
1175
  const usd = (n) => (n >= 0.01 || n === 0 ? '$' + n.toFixed(2) : '$' + n.toFixed(5));
1090
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
+
1091
1194
  // BIND, DON'T PASTE — the whole point of the thing this runs on.
1092
1195
  //
1093
1196
  // Directive results get fed back to the model as a user message, verbatim. A
@@ -1215,8 +1318,8 @@ function inFlightChars(t) {
1215
1318
  */
1216
1319
  function scheduleFilesForCorpus(t, collected, opts = {}) {
1217
1320
  if (!collected?.pending?.length) return null;
1218
- const root = t ? (threads.get(rootOf(t).rootId) || t) : null;
1219
- const ctx = opts.contextId || root?.contextId || t?.contextId || null;
1321
+ const brain = t ? holobrainOf(t) : null;
1322
+ const ctx = opts.contextId || brain?.contextId || t?.contextId || null;
1220
1323
  const chars = opts.sentChars ?? inFlightChars(t);
1221
1324
  const background = chars < BIND_MIN_CHARS;
1222
1325
  const fetchImpl = opts.fetchImpl || fetch;
@@ -1233,7 +1336,7 @@ function scheduleFilesForCorpus(t, collected, opts = {}) {
1233
1336
  }).then(async (r) => {
1234
1337
  const j = await r.json().catch(() => ({}));
1235
1338
  if (j?.context_id && t) {
1236
- const live = threads.get(rootOf(t).rootId) || t;
1339
+ const live = holobrainOf(t) || t;
1237
1340
  live.contextId = j.context_id;
1238
1341
  t.contextId = j.context_id;
1239
1342
  if (Number(j.bound)) live.boundItems = (live.boundItems || 0) + Number(j.bound);
@@ -1301,9 +1404,20 @@ function emitToThread(threadId, ev) {
1301
1404
  }
1302
1405
  }
1303
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
+
1304
1416
  async function sessionStats() {
1305
- try { return await (await fetch(`${PROXY}/session`, { signal: AbortSignal.timeout(2000) })).json(); }
1306
- 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; }
1307
1421
  }
1308
1422
 
1309
1423
  function todoBlock(t) {
@@ -1328,7 +1442,7 @@ async function handleSlash(task, t) {
1328
1442
  }
1329
1443
  if (cmd === 'tools') {
1330
1444
  return 'Directives:\n'
1331
- + ' RUN: <cmd> real shell, in this thread’s dir\n'
1445
+ + ' RUN: <cmd> real shell, in this thread’s dir (never find /)\n'
1332
1446
  + ' WRITE: <path> | <content> create/overwrite a file\n'
1333
1447
  + ' EDIT: <path> | <old> ||| <new> change part of a file\n'
1334
1448
  + ' MULTIEDIT: <path> | a|||b ;; c|||d several edits, all-or-nothing\n'
@@ -1349,6 +1463,20 @@ async function handleSlash(task, t) {
1349
1463
  + 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
1350
1464
  + 'appear in one reply — four files cost one round trip, not four.';
1351
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
+ }
1352
1480
  if (cmd === 'cost' || cmd === 'tokens') {
1353
1481
  const s = await sessionStats();
1354
1482
  if (!s) return 'The local openzoo proxy isn’t reachable, so there are no real numbers to show. (Not zero — unknown.)';
@@ -1360,9 +1488,9 @@ async function handleSlash(task, t) {
1360
1488
  ` paid calls ${s.paidCalls || 0}`,
1361
1489
  ];
1362
1490
  if (spent > 0) {
1363
- const mult = direct / spent;
1364
- lines.push(` multiple ${mult >= 100 ? Math.round(mult) : mult.toFixed(2)}x`
1365
- + (mult < 1 ? ' — under 1x: small inputs cost MORE than sending them directly' : ''));
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' : ''));
1366
1494
  }
1367
1495
  return 'This session:\n' + lines.join('\n');
1368
1496
  }
@@ -1377,16 +1505,27 @@ async function handleSlash(task, t) {
1377
1505
  // tier silently overriding an explicit id would make /model a suggestion.
1378
1506
  if (cmd === 'tier') {
1379
1507
  if (!arg) {
1380
- const picks = await tierModels(t.tier || 'medium', 3);
1381
- return `This thread: ${t.tier || 'medium'}${t.tier ? '' : ' (default)'}\n`
1382
- + `Tiers: ${TIER_NAMES.join(' · ')}\n`
1383
- + `Top of ${t.tier || 'medium'} right now: ${picks.join(', ')}\n`
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`
1384
1519
  + (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so the tier is ignored until you /model default.\n` : '')
1385
1520
  + 'Switch with /tier <name> · /race <n> to ask several at once.';
1386
1521
  }
1387
1522
  const want = normalizeTier(arg);
1388
- 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).`;
1389
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
+ }
1390
1529
  const picks = await tierModels(want, 3);
1391
1530
  return `This thread now runs on the ${want} tier — ${picks.join(', ')}…`
1392
1531
  + (t.model ? `\nBut /model ${t.model} is still pinned and wins. Run /model default to let the tier take over.` : '');
@@ -1521,7 +1660,7 @@ async function handleSlash(task, t) {
1521
1660
 
1522
1661
  // Wake the room. Used to be a free last-line dump — idle children stayed
1523
1662
  // idle, and a parent reading "kid: <old reply>" thought they had acted.
1524
- // Empty extra is a nudge (AUTO_CONTINUE), not a cancel. Thinking stays
1663
+ // Empty extra is a continue wake (Claude Code on Auto), not a cancel. Thinking stays
1525
1664
  // thinking; pendingRun stays on the human. Same branch scope as /all.
1526
1665
  if (cmd === 'ping') {
1527
1666
  const crew = subtreeOf(t.id, true);
@@ -1884,6 +2023,69 @@ function isHarnessUserText(text) {
1884
2023
  || String(text || '').startsWith('AUTO_EMPTY_RETRY:');
1885
2024
  }
1886
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
+
1887
2089
  function firstUserAsk(t) {
1888
2090
  return (t?.history || []).find((m) => m.who === 'user' && !isHarnessUserText(m.text));
1889
2091
  }
@@ -1911,7 +2113,7 @@ function spawnBrief(parent, { refresh = false, child } = {}) {
1911
2113
  const cwd = child?.dir || WORKSPACE_DIR;
1912
2114
  const branch = child?.worktree?.branch;
1913
2115
  const mode = parent.runMode || 'ask';
1914
- const tier = parent.tier || 'medium';
2116
+ const tier = parent.tier || 'auto';
1915
2117
  const race = Number(parent.race) || 0;
1916
2118
  const raceNeed = Number(parent.raceNeed) || 1;
1917
2119
  const model = parent.model || '';
@@ -2622,12 +2824,141 @@ async function mcpDirective(url, tool, args) {
2622
2824
  }
2623
2825
  }
2624
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
+
2625
2955
  // onEvent (optional) gets live progress for whoever's actually watching this
2626
2956
  // call: {type:'start',name,color} when a bot begins its turn, {type:'status',
2627
2957
  // detail} while paying / waiting / racing / walking tools, {type:'race',race}
2628
2958
  // for the spectator grid (one cell per launched model + a judging beat),
2629
2959
  // {type:'delta',name,color,delta} per streamed token (replace:true swaps the
2630
- // bubble once), {type:'final',name,color,text} once its full reply (or
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
2631
2962
  // directive ack) is settled. Background turns go through kickTurn →
2632
2963
  // emitToThread, which is a no-op if nobody has the thread open.
2633
2964
  async function runTurn(threadId, userText, onEvent, images) {
@@ -2647,14 +2978,15 @@ async function runTurn(threadId, userText, onEvent, images) {
2647
2978
  if (!stillMine()) return;
2648
2979
  if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
2649
2980
  if (ev.type === 'race' && ev.race && t.status === 'thinking') t.liveRace = ev.race;
2650
- if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race') t.lastDeltaAt = Date.now();
2981
+ if (ev.type === 'delta' || ev.type === 'think' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race' || ev.type === 'tool') t.lastDeltaAt = Date.now();
2651
2982
  onEvent?.(ev);
2652
2983
  };
2653
- t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
2984
+ persistUserTurn(t, userText, images);
2654
2985
  t.lastActivityAt = Date.now();
2655
2986
  t.status = 'thinking';
2656
2987
  t.thinkingAt = Date.now();
2657
2988
  t.lastDeltaAt = Date.now();
2989
+ saveThreads();
2658
2990
  try { turnAborts.get(t)?.abort(); } catch { /* none */ }
2659
2991
  const turnAbort = new AbortController();
2660
2992
  turnAborts.set(t, turnAbort);
@@ -2665,6 +2997,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2665
2997
  t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
2666
2998
  let chained = false;
2667
2999
  let parked = false;
3000
+ let usedClaude = false;
3001
+ let claudeFallback = false;
2668
3002
  let lastReply = '';
2669
3003
  try {
2670
3004
  if (t.members) {
@@ -2680,11 +3014,15 @@ async function runTurn(threadId, userText, onEvent, images) {
2680
3014
  const emitStatus = (detail) => paint({ type: 'status', name: m.name, color: m.color, detail });
2681
3015
  try {
2682
3016
  r = onEvent
2683
- ? (await brainStream(msgs, (delta) => paint({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId, undefined, undefined, 0, 0, emitStatus)).trim()
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()
2684
3021
  : (await brain(msgs, t.contextId)).trim();
2685
3022
  } catch (e) { r = `error: ${e.message}`; }
2686
3023
  if (!stillMine()) return;
2687
- r = stripThinkTags(r);
3024
+ const memberThink = takeThink(r);
3025
+ r = memberThink.text;
2688
3026
  memberReply = r;
2689
3027
  const runCmd = parseRun(r);
2690
3028
  if (runCmd) {
@@ -2694,16 +3032,19 @@ async function runTurn(threadId, userText, onEvent, images) {
2694
3032
  const output = await execCommand(command, dirFor(t.id));
2695
3033
  noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
2696
3034
  const shown = `$ ${command}\n${output}`;
2697
- t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
2698
- paint({ type: 'final', name: m.name, color: m.color, text: shown });
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 });
2699
3040
  memberReply = shown;
2700
3041
  // this member's turn is done; the round continues to the next member
2701
3042
  continue;
2702
3043
  }
2703
3044
  const runId = randomUUID();
2704
3045
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
2705
- t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
2706
- paint({ type: 'run-pending', runId, command, name: m.name, color: m.color });
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 });
2707
3048
  // pauses the WHOLE round here — the rest of the group gets their turn
2708
3049
  // on the round that runs after the user approves/denies
2709
3050
  parked = true;
@@ -2711,8 +3052,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2711
3052
  }
2712
3053
  const ack = await tryDirective(r, t.id, paint);
2713
3054
  const finalText = ack ?? (r || '(no response)');
2714
- t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
2715
- paint({ type: 'final', name: m.name, color: m.color, text: finalText });
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 });
2716
3057
  memberReply = r;
2717
3058
  }
2718
3059
  bindThread(t).catch(() => {});
@@ -2722,6 +3063,38 @@ async function runTurn(threadId, userText, onEvent, images) {
2722
3063
  }
2723
3064
  return;
2724
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
+ }
2725
3098
  // A real message from the user resets the auto budget AND the announcement
2726
3099
  // nudge. Harness-injected hops (command output, directive result, nudge,
2727
3100
  // auto-continue) must not re-arm — that would make AUTO_MAX_STEPS a no-op
@@ -2731,7 +3104,12 @@ async function runTurn(threadId, userText, onEvent, images) {
2731
3104
  t.autoSteps = 0;
2732
3105
  delete t.autoNudged;
2733
3106
  }
2734
- 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
+ }
2735
3113
  let reply = '';
2736
3114
  paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'waiting on model…' });
2737
3115
  // Transient: the nudge is appended for THIS call only and never pushed into
@@ -2748,7 +3126,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2748
3126
  if (t.todos?.length) {
2749
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')}` });
2750
3128
  }
2751
- 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 });
2752
3131
  const callMsgs = extras.length ? [...t.messages, ...extras] : t.messages;
2753
3132
  // WHICH MODEL SERVES THIS TURN.
2754
3133
  // /model <id> pins one explicitly and always wins — an explicit choice is
@@ -2763,18 +3142,34 @@ async function runTurn(threadId, userText, onEvent, images) {
2763
3142
  thread: t, attempt, userText, messages: callMsgs,
2764
3143
  }) ?? '').trim();
2765
3144
  }
2766
- const emit = (delta, meta) => paint({
2767
- type: 'delta', name: t.name, color: t.color, delta,
2768
- ...(meta?.replace ? { replace: true } : {}),
2769
- ...(meta?.model ? { model: meta.model } : {}),
2770
- });
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
+ };
2771
3156
  const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
2772
- // Retrieval breadth scales with the PROJECT's corpus, not this thread's —
2773
- // the holobrain is shared at the root, so that is the pool being searched.
2774
- const topK = adaptiveTopK((threads.get(rootOf(t).rootId) || t).boundItems);
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);
2775
3160
  const race = Math.min(Number(t.race) || 0, 4);
2776
3161
  if (!t.model && race >= 2) {
2777
- 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);
2778
3173
  // need = how many must come BACK before judging. need 1 is a plain
2779
3174
  // first-past-the-post race; need N waits for all of them. The point of
2780
3175
  // the middle (2 of 3) is a judged answer without the slowest entrant
@@ -2789,7 +3184,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2789
3184
  })).trim();
2790
3185
  }
2791
3186
  // A retry draws a DIFFERENT model from the tier rather than the same one.
2792
- const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
3187
+ const model = t.model || 'openzoo/auto';
2793
3188
  return (onEvent
2794
3189
  ? (await brainStream(callMsgs, emit, t.contextId, model, undefined, 0, topK, emitStatus)).trim()
2795
3190
  : (await brain(callMsgs, t.contextId, model, topK)).trim());
@@ -2827,20 +3222,21 @@ async function runTurn(threadId, userText, onEvent, images) {
2827
3222
  reply = `error: ${e.message}`;
2828
3223
  }
2829
3224
  if (!stillMine()) return;
2830
- reply = stripThinkTags(reply);
3225
+ const settled = takeThink(reply);
3226
+ reply = settled.text;
2831
3227
  lastReply = reply;
2832
3228
  t.messages.push({ role: 'assistant', content: reply });
2833
3229
  const runCmd = parseRun(reply);
2834
3230
  if (runCmd) {
2835
3231
  const command = runCmd;
2836
- if (t.runMode === 'auto') {
3232
+ if (t.runMode === 'auto' && !claudeFallback) {
2837
3233
  emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
2838
3234
  const output = await execCommand(command, dirFor(t.id));
2839
3235
  noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
2840
3236
  if (!stillMine()) return;
2841
3237
  const shown = `$ ${command}\n${output}`;
2842
- t.history.push({ who: 'bot', text: shown });
2843
- paint({ type: 'final', name: t.name, color: t.color, text: shown });
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 });
2844
3240
  // FEED THE OUTPUT BACK. The 'ask' path already does this on approve, so
2845
3241
  // auto mode was strictly LESS capable than the gated one: the command
2846
3242
  // ran, the result was shown, and the model never saw it — no diagnosis,
@@ -2865,8 +3261,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2865
3261
  {
2866
3262
  const runId = randomUUID();
2867
3263
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
2868
- t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
2869
- paint({ type: 'run-pending', runId, command, name: t.name, color: t.color });
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 });
2870
3266
  }
2871
3267
  parked = true;
2872
3268
  return;
@@ -2874,8 +3270,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2874
3270
  const ack = await tryDirective(reply, t.id, paint);
2875
3271
  if (!stillMine()) return;
2876
3272
  const finalText = ack ?? (reply || '(no response)');
2877
- t.history.push({ who: 'bot', text: finalText });
2878
- paint({ type: 'final', name: t.name, color: t.color, text: finalText });
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 });
2879
3275
 
2880
3276
  // AUTO CONTINUES AFTER *ANY* DIRECTIVE, not just RUN.
2881
3277
  //
@@ -2888,7 +3284,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2888
3284
  //
2889
3285
  // Same budget as RUN (shared t.autoSteps, reset when the user speaks), so
2890
3286
  // this cannot spend more than a chained RUN loop already could.
2891
- if (t.runMode === 'auto' && ack !== null && ack !== undefined
3287
+ if (t.runMode === 'auto' && !claudeFallback && ack !== null && ack !== undefined
2892
3288
  && (isEmptyDirectiveAck(ack) || !isDoneReply(reply))) {
2893
3289
  chained = enqueueAutoHop(
2894
3290
  t, threadId,
@@ -2912,7 +3308,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2912
3308
  // NUDGE announcements (stronger than a bare continue). Plain replies used
2913
3309
  // to park here; they now fall through to AUTO_CONTINUE unless DONE: or a
2914
3310
  // real blocking question. The step cap is the wallet bound.
2915
- if (t.runMode === 'auto' && (ack === null || ack === undefined)
3311
+ if (t.runMode === 'auto' && !claudeFallback && (ack === null || ack === undefined)
2916
3312
  && (STALLED_OFFER.test(reply) || ANNOUNCEMENT.test(reply))) {
2917
3313
  // Offers and "Spawned X — working on it" with no directive must not end
2918
3314
  // the run. The old once-only autoNudged gate parked the thread after one
@@ -2924,7 +3320,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2924
3320
 
2925
3321
  // After any auto reply that is not DONE: and not waiting on approval,
2926
3322
  // kick immediately. Race/empty/error uses AUTO_RACE_RETRY.
2927
- if (shouldKeepAuto(t, reply, userText)) {
3323
+ if (!claudeFallback && shouldKeepAuto(t, reply, userText)) {
2928
3324
  chained = enqueueAutoHop(t, threadId, autoHopText(reply, userText), onEvent);
2929
3325
  return;
2930
3326
  }
@@ -2934,7 +3330,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2934
3330
  // ask mode, 402/empty-wallet, or the hard cap. Empty /(no output) is not
2935
3331
  // DONE — AUTO_EMPTY_RETRY. Otherwise kick again.
2936
3332
  if (stillMine() && !chained && !parked) {
2937
- if (shouldKeepAuto(t, lastReply, userText)) {
3333
+ if (!usedClaude && !claudeFallback && shouldKeepAuto(t, lastReply, userText)) {
2938
3334
  enqueueAutoHop(t, threadId, autoHopText(lastReply, userText), onEvent);
2939
3335
  } else if (!t.pendingRun) {
2940
3336
  t.status = 'idle';
@@ -3063,7 +3459,8 @@ function subtreeOf(id, includeSelf = false) {
3063
3459
  }
3064
3460
 
3065
3461
  function threadSummary(t) {
3066
- const last = t.history[t.history.length - 1];
3462
+ const vis = visibleHistory(t.history);
3463
+ const last = vis[vis.length - 1];
3067
3464
  return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
3068
3465
  preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
3069
3466
  createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
@@ -3095,19 +3492,19 @@ const APP_HTML = `<!doctype html>
3095
3492
  <style>
3096
3493
  :root { color-scheme: dark; }
3097
3494
  * { box-sizing: border-box; }
3098
- html, body { margin: 0; height: 100%; background: #000; }
3495
+ html, body { margin: 0; height: 100%; width: 100%; overflow: hidden; background: #000; }
3099
3496
  body { color: #ececec; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
3100
3497
  display: flex; }
3101
3498
  #dragbar { -webkit-app-region: drag; position: fixed; top: 0; left: 0; right: 0; height: 28px; z-index: 1000; }
3102
3499
  #sidebar { width: 280px; flex: 0 0 280px; border-right: 1px solid #1c1c1e; display: flex; flex-direction: column;
3103
- height: 100vh; padding-top: 28px; }
3500
+ height: 100%; padding-top: 28px; min-height: 0; position: relative; }
3104
3501
  #main { padding-top: 28px; }
3105
3502
  #sideTop { display: flex; align-items: center; gap: 4px; padding: 0 8px; }
3106
3503
  #sideTop #search { flex: 1; }
3107
3504
  #search { margin: 12px; padding: 8px 12px; background: #1c1c1e; border-radius: 10px; color: #ececec;
3108
3505
  border: none; font: inherit; }
3109
3506
  #search::placeholder { color: #8e8e93; }
3110
- #threads { flex: 1; overflow-y: auto; }
3507
+ #threads { flex: 1; min-height: 0; overflow-y: auto; }
3111
3508
  .trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
3112
3509
  margin: 0 6px 2px; }
3113
3510
  /* PROJECT HEADER. The tree indentation shows who spawned whom, but there was
@@ -3172,7 +3569,7 @@ const APP_HTML = `<!doctype html>
3172
3569
  @media (prefers-reduced-motion: reduce) {
3173
3570
  .twarn, .bot-pfp .bot-bob, .bot-pfp .bot-eyes { animation: none; }
3174
3571
  }
3175
- #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%; }
3176
3573
  #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
3177
3574
  flex-wrap: wrap; gap: 8px 10px; font-weight: 600; }
3178
3575
  #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 50%; font-size: 11px; flex: 0 0 26px; }
@@ -3299,6 +3696,21 @@ const APP_HTML = `<!doctype html>
3299
3696
  #hud { position: absolute; right: 14px; width: 270px; background: rgba(14,14,17,.94);
3300
3697
  border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
3301
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; }
3302
3714
  #hud.show { display: block; }
3303
3715
  #hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
3304
3716
  #hud .htitle.hsession { margin-top: 12px; padding-top: 10px; border-top: 1px solid #333340; color: #6f7080; }
@@ -3316,8 +3728,8 @@ const APP_HTML = `<!doctype html>
3316
3728
  color: #f0c9a8; font-size: 10.5px; line-height: 1.45; }
3317
3729
  #hud .hhint.show { display: block; }
3318
3730
  #hud .hhint b { color: #f28c4d; font-weight: 600; }
3319
- #sidebar, #main, #walletOverlay, #sitrepOverlay, #composeOverlay,
3320
- #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 {
3321
3733
  -webkit-app-region: no-drag; }
3322
3734
  #log { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 20px 24px 12px;
3323
3735
  display: flex; flex-direction: column; gap: 6px;
@@ -3399,6 +3811,18 @@ const APP_HTML = `<!doctype html>
3399
3811
  }
3400
3812
  .runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px;
3401
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; }
3402
3826
  .runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
3403
3827
  word-break: break-word; margin-bottom: 8px; }
3404
3828
  .runactions { display: flex; gap: 8px; }
@@ -3411,6 +3835,34 @@ const APP_HTML = `<!doctype html>
3411
3835
  word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
3412
3836
  .row.user .bubble { background: #57575c; }
3413
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; }
3414
3866
  .row.bot.pending .bubble { color: #8e8e93; }
3415
3867
  .dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
3416
3868
  background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
@@ -3535,6 +3987,23 @@ const APP_HTML = `<!doctype html>
3535
3987
  transition: opacity .14s ease, transform .14s ease;
3536
3988
  }
3537
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; }
3538
4007
  </style></head>
3539
4008
  <body>
3540
4009
  <div id="copiedToast" role="status" aria-live="polite">copied</div>
@@ -3547,6 +4016,13 @@ const APP_HTML = `<!doctype html>
3547
4016
  <input id="search" placeholder="Search">
3548
4017
  </div>
3549
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>
3550
4026
  </div>
3551
4027
  <div id="composeOverlay">
3552
4028
  <div id="composeBox">
@@ -3607,9 +4083,10 @@ const APP_HTML = `<!doctype html>
3607
4083
  title="Shell commands run immediately, with no approval prompt">auto</button>
3608
4084
  </div>
3609
4085
  <select class="dial" id="tierSel" data-component="model-tier" aria-label="Model tier"
3610
- title="How much to spend per turn when no model is pinned">
4086
+ title="Auto = cheapest model that clears the bar. Other tiers only apply to /race.">
4087
+ <option value="auto" selected>auto</option>
3611
4088
  <option value="cheap">cheap</option>
3612
- <option value="medium" selected>medium</option>
4089
+ <option value="medium">medium</option>
3613
4090
  <option value="expensive">expensive</option>
3614
4091
  <option value="grok4.6">grok 4.6</option>
3615
4092
  </select>
@@ -3647,6 +4124,13 @@ const APP_HTML = `<!doctype html>
3647
4124
  <div class="hfoot" id="hFoot">loading…</div>
3648
4125
  </div>
3649
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>
3650
4134
  <div id="bar">
3651
4135
  <div id="plusMenu">
3652
4136
  <div class="pop-item" id="attachBtn">
@@ -3678,6 +4162,17 @@ const APP_HTML = `<!doctype html>
3678
4162
  </div>
3679
4163
  </div>
3680
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
+ }
3681
4176
  const threadsEl = document.getElementById('threads');
3682
4177
  const chatHeader = document.getElementById('chatHeader');
3683
4178
  const log = document.getElementById('log');
@@ -4008,7 +4503,7 @@ const APP_HTML = `<!doctype html>
4008
4503
  const tierSel = document.getElementById('tierSel');
4009
4504
  const raceSel = document.getElementById('raceSel');
4010
4505
  if (!tierSel || !raceSel) return;
4011
- tierSel.value = t.tier || 'medium';
4506
+ tierSel.value = t.tier || 'auto';
4012
4507
  raceSel.value = (t.race || 0) < 2 ? '0'
4013
4508
  : ((t.raceNeed || 1) > 1 ? t.raceNeed + ' ' + t.race : String(t.race));
4014
4509
  // A pinned /model makes BOTH dials inert. Showing them live while they do
@@ -4031,15 +4526,18 @@ const APP_HTML = `<!doctype html>
4031
4526
  }
4032
4527
  }
4033
4528
 
4034
- 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) {
4035
4532
  if (!activeId) return;
4036
- // Reuses the SAME slash-command path, so there is one implementation of the
4037
- // rule rather than a second that can disagree with it.
4038
4533
  await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
4039
- body: JSON.stringify({ threadId: activeId, task: '/' + cmd + ' ' + value }) });
4534
+ body: JSON.stringify({ threadId: activeId, task: task }) });
4040
4535
  await loadThreads();
4041
4536
  await render();
4042
4537
  }
4538
+ async function setDial(cmd, value) {
4539
+ await echoSlash('/' + cmd + ' ' + value);
4540
+ }
4043
4541
  // WALLET MODAL.
4044
4542
  //
4045
4543
  // Public addresses and balances only — /wallet proxies the proxy's own
@@ -4066,7 +4564,13 @@ const APP_HTML = `<!doctype html>
4066
4564
  return row;
4067
4565
  }
4068
4566
  function isEmptyWalletPayment(text) {
4069
- return /\b(?:wallet is empty|empty wallet|wallet underfunded|underfunded)\b/i.test(String(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');
4070
4574
  }
4071
4575
  var openedPayForEmpty = false;
4072
4576
  function maybeOpenPayForEmptyWallet(text) {
@@ -4287,7 +4791,10 @@ const APP_HTML = `<!doctype html>
4287
4791
  setSubNote('Subscription key removed. Wallet/x402 is the pay method again.');
4288
4792
  await openWallet();
4289
4793
  }
4290
- document.getElementById('walletBtn').addEventListener('click', openWallet);
4794
+ document.getElementById('walletBtn').addEventListener('click', () => {
4795
+ echoSlash('/pay');
4796
+ openWallet();
4797
+ });
4291
4798
  const subKeyBtn = document.getElementById('subKeyBtn');
4292
4799
  if (subKeyBtn) subKeyBtn.addEventListener('click', savePastedSub);
4293
4800
  const subForgetBtn = document.getElementById('subForgetBtn');
@@ -4356,10 +4863,10 @@ const APP_HTML = `<!doctype html>
4356
4863
  const spent = Number(you.spentUsd) || 0;
4357
4864
  const cogs = Number(you.cogsUsd) || 0;
4358
4865
  const direct = Number(you.directUsd) || 0;
4359
- const mult = spent > 0 ? direct / spent : null;
4360
- const saved = mult == null ? '—'
4361
- : ((mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x');
4362
- const savedCls = mult == null ? '' : (mult >= 1 ? 'hlime' : 'hember');
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'));
4363
4870
  const thinking = (full && full.status === 'thinking') || t.status === 'thinking';
4364
4871
  const race = (full && full.liveRace) || null;
4365
4872
  let flight = 'idle';
@@ -4376,11 +4883,12 @@ const APP_HTML = `<!doctype html>
4376
4883
  + sitrepRow('cwd', cwd)
4377
4884
  + sitrepRow('in flight', flight)
4378
4885
  + '<div class="wlanetitle" style="margin-top:16px">this session</div>'
4379
- + sitrepRow('paid', '$' + (spent >= 0.01 || spent === 0 ? spent.toFixed(2) : spent.toFixed(5)))
4380
- + sitrepRow('cogs', '$' + (cogs >= 0.01 || cogs === 0 ? cogs.toFixed(2) : cogs.toFixed(5)))
4381
- + 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))))
4382
4890
  + sitrepRow('saved vs naked', saved, savedCls)
4383
- + sitrepRow('paid calls', String(you.paidCalls || 0))
4891
+ + sitrepRow('paid calls', proxyDown ? '—' : String(you.paidCalls || 0))
4384
4892
  + sitrepRow('prepaid', (Number(you.creditUsd) > 0) ? 'yes' : 'no');
4385
4893
  }
4386
4894
  function closeSitrep() { sitrepOverlay.classList.remove('show'); }
@@ -4408,22 +4916,44 @@ const APP_HTML = `<!doctype html>
4408
4916
  setModeButtons(mode); // optimistic: the click should feel instant
4409
4917
  // Reuses the SAME "/mode" path the chat command takes, so there is one
4410
4918
  // implementation of the rule rather than a second one that can disagree.
4411
- await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
4412
- body: JSON.stringify({ threadId: activeId, task: '/mode ' + mode }) });
4413
- await loadThreads(); // refresh runMode + the confirmation line /mode appends
4414
- await render();
4919
+ await echoSlash('/mode ' + mode);
4415
4920
  }
4416
4921
  document.getElementById('modeAsk').addEventListener('click', () => setMode('ask'));
4417
4922
  document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
4418
4923
 
4419
4924
  function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
4420
- 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) {
4421
4937
  s = String(s == null ? '' : s);
4422
- s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*?<\\/think(?:ing)?>/gi, '');
4423
- s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*$/i, '');
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
+ });
4424
4950
  s = s.replace(/<\\/think(?:ing)?>/gi, '');
4425
- 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
+ };
4426
4955
  }
4956
+ function stripThinkTags(s) { return splitThinkTags(s).visible; }
4427
4957
  function clientWorkspaceUrl(rel) {
4428
4958
  if (!workspacePort || !activeId) return '';
4429
4959
  rel = String(rel || '').replace(/^\\/+/, '');
@@ -4806,8 +5336,140 @@ const APP_HTML = `<!doctype html>
4806
5336
  }
4807
5337
 
4808
5338
  let lastSpeaker = null;
4809
- function addRow(who, text, color, name, run, images) {
4810
- 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
+ }
4811
5473
  const speakerKey = who + '|' + name;
4812
5474
  if (who === 'bot' && speakerKey !== lastSpeaker) {
4813
5475
  const hdr = document.createElement('div');
@@ -4818,17 +5480,25 @@ const APP_HTML = `<!doctype html>
4818
5480
  lastSpeaker = speakerKey;
4819
5481
  const row = document.createElement('div');
4820
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
+ }
4821
5494
  if (run) {
5495
+ const cmd = run.command || text;
5496
+ const st = run.status || 'pending';
5497
+ const pending = st === 'pending' || st === 'running';
4822
5498
  const card = document.createElement('div');
4823
- card.className = 'runcard';
4824
- const cmdEl = document.createElement('div');
4825
- cmdEl.className = 'runcmd';
4826
- cmdEl.textContent = '$ ' + text;
4827
- // Copy WITHOUT the '$ ' prompt — pasting that into a shell is a syntax
4828
- // error, and this is the single most re-run thing in the UI.
4829
- cmdEl.appendChild(copyBtn(() => text, 'copy'));
4830
- card.appendChild(cmdEl);
4831
- if (run.status === 'pending') {
5499
+ card.className = 'runcard' + (pending ? ' pending' : ' folded');
5500
+ card.appendChild(makeRunFold(cmd, run.output || '', st));
5501
+ if (run.id && st === 'pending') {
4832
5502
  const actions = document.createElement('div');
4833
5503
  actions.className = 'runactions';
4834
5504
  const approve = document.createElement('button');
@@ -4850,20 +5520,8 @@ const APP_HTML = `<!doctype html>
4850
5520
  actions.appendChild(approve);
4851
5521
  actions.appendChild(deny);
4852
5522
  card.appendChild(actions);
4853
- } else {
4854
- const status = document.createElement('div');
4855
- status.className = 'runstatus';
4856
- status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
4857
- card.appendChild(status);
4858
- if (run.output) {
4859
- const out = document.createElement('pre');
4860
- out.className = 'runoutput';
4861
- out.textContent = run.output;
4862
- out.appendChild(copyBtn(() => run.output, 'copy'));
4863
- card.appendChild(out);
4864
- }
4865
5523
  }
4866
- row.appendChild(card);
5524
+ col.appendChild(card);
4867
5525
  } else {
4868
5526
  const bubble = document.createElement('div');
4869
5527
  bubble.className = 'bubble';
@@ -4879,6 +5537,7 @@ const APP_HTML = `<!doctype html>
4879
5537
  }
4880
5538
  const textEl = document.createElement('div');
4881
5539
  textEl.innerHTML = renderMentions(text);
5540
+ if (live && (!text || text === '…')) bubble.hidden = true;
4882
5541
  if (who === 'bot') {
4883
5542
  const preview = htmlPreviewUrl(text);
4884
5543
  if (preview) textEl.appendChild(previewFrame(preview, htmlPreviewKey(text, preview)));
@@ -4893,7 +5552,7 @@ const APP_HTML = `<!doctype html>
4893
5552
  maybeOpenPayForEmptyWallet(text);
4894
5553
  }
4895
5554
  bubble.appendChild(textEl);
4896
- row.appendChild(bubble);
5555
+ col.appendChild(bubble);
4897
5556
  // Copy the message SOURCE, not rendered HTML — markdown, code fences and
4898
5557
  // directive lines are what people want back; innerText drops fences and
4899
5558
  // mangles indentation.
@@ -4909,6 +5568,7 @@ const APP_HTML = `<!doctype html>
4909
5568
  }, 'copy'));
4910
5569
  }
4911
5570
  }
5571
+ row.appendChild(col);
4912
5572
  log.appendChild(row);
4913
5573
  }
4914
5574
 
@@ -4921,10 +5581,11 @@ const APP_HTML = `<!doctype html>
4921
5581
  const full = await loadActiveMessages();
4922
5582
  if (!full || full.id !== activeId) return;
4923
5583
  const renderKey = String(workspacePort) + '|' + full.id + '|' + full.status + '|' + (full.history || []).map(function (h) {
4924
- return [h.who, h.text, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
4925
- }).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 || '');
4926
5587
  if (renderKey === lastRenderKey) {
4927
- if (streamBuf) paintStream();
5588
+ if (streamBuf || streamThink) paintStream();
4928
5589
  return;
4929
5590
  }
4930
5591
  lastRenderKey = renderKey;
@@ -4934,9 +5595,33 @@ const APP_HTML = `<!doctype html>
4934
5595
  parkedPreviews = parkPreviews();
4935
5596
  log.innerHTML = '';
4936
5597
  lastSpeaker = null;
5598
+ const seenUser = {};
4937
5599
  for (const h of full.history) {
4938
- addRow(h.who, h.text, h.color || t.color, h.name || t.name,
4939
- h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
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);
4940
5625
  }
4941
5626
  if (full.status === 'thinking') {
4942
5627
  if (full.liveStatus) streamStatus = full.liveStatus;
@@ -4946,19 +5631,21 @@ const APP_HTML = `<!doctype html>
4946
5631
  } else if (streamRaceId !== full.id) {
4947
5632
  streamRace = null;
4948
5633
  }
4949
- addRow('bot', streamBuf || '…', t.color, t.name);
5634
+ addRow('bot', streamBuf || '…', t.color, t.name, undefined, undefined, streamThink, true);
4950
5635
  // Tag the live bubble so deltas can repaint just this node instead of
4951
5636
  // re-rendering (and re-fetching) the whole thread on every token.
4952
5637
  const b = log.querySelector('.row:last-child .bubble');
4953
5638
  if (b) { b.id = 'streamBubble'; paintStream(); }
4954
5639
  }
4955
5640
  if (wasNearBottom) log.scrollTop = log.scrollHeight;
5641
+ if (findBarOpen()) applyFind(true);
4956
5642
  }
4957
5643
 
4958
5644
  // --- live token stream ---------------------------------------------------
4959
5645
  // The server has always been able to stream; /drive just never asked for it,
4960
5646
  // so a turn showed "…" for its whole duration and then arrived in one lump.
4961
5647
  let streamBuf = '';
5648
+ let streamThink = '';
4962
5649
  let streamStatus = '';
4963
5650
  let streamRace = null;
4964
5651
  let streamRaceId = '';
@@ -5012,44 +5699,117 @@ const APP_HTML = `<!doctype html>
5012
5699
  return '<div class="racewrap"><div class="racecaption">' + escapeHtml(caption) + '</div>'
5013
5700
  + '<div class="racegrid n' + n + '">' + cells + '</div>' + judge + '</div>';
5014
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
+ }
5015
5708
  function liveBubbleHtml() {
5016
5709
  if (raceIsLive(streamRace)) return raceGridHtml(streamRace);
5017
- if (streamBuf) {
5018
- const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
5019
- ? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
5020
- 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');
5021
5725
  }
5022
- const dots = '<span class="dots"><span></span><span></span><span></span></span>';
5023
- const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
5024
- 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';
5025
5764
  }
5026
5765
  function paintStream() {
5027
- const b = document.getElementById('streamBubble');
5028
- 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);
5029
5780
  if (raceIsLive(streamRace)) {
5781
+ b.hidden = false;
5030
5782
  b.classList.add('raceboard');
5031
5783
  b.innerHTML = liveBubbleHtml();
5032
- if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
5784
+ if (wasNearBottom || followLive) pinLogBottom();
5785
+ scheduleFindPaint();
5033
5786
  return;
5034
5787
  }
5035
5788
  b.classList.remove('raceboard');
5036
- // Deltas stay as text; a silent wait paints dots + one mutating status
5037
- // line so a 20–40s pay/model wait is obviously alive.
5038
- if (streamBuf && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
5039
- b.textContent = streamBuf;
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);
5040
5793
  } else {
5794
+ b.hidden = true;
5041
5795
  b.innerHTML = liveBubbleHtml();
5042
5796
  }
5043
- if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
5797
+ if (wasNearBottom || followLive) pinLogBottom();
5798
+ scheduleFindPaint();
5044
5799
  }
5045
5800
  function connectStream(id) {
5046
5801
  if (!id || esId === id) return;
5047
5802
  if (es) es.close();
5048
5803
  esId = id;
5049
5804
  streamBuf = '';
5805
+ streamThink = '';
5806
+ streamTools = [];
5050
5807
  streamStatus = '';
5051
5808
  streamRace = null;
5052
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;
5053
5813
  raceHandoff += 1;
5054
5814
  es = new EventSource('/stream/' + id); // EventSource reconnects on its own
5055
5815
  es.onmessage = (e) => {
@@ -5057,9 +5817,17 @@ const APP_HTML = `<!doctype html>
5057
5817
  try { ev = JSON.parse(e.data); } catch { return; }
5058
5818
  if (ev.type === 'start') {
5059
5819
  streamBuf = '';
5820
+ streamThink = '';
5821
+ streamTools = [];
5060
5822
  streamStatus = ev.detail || 'waiting on model…';
5061
5823
  streamRace = null;
5062
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);
5063
5831
  paintStream();
5064
5832
  }
5065
5833
  else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
@@ -5070,8 +5838,14 @@ const APP_HTML = `<!doctype html>
5070
5838
  }
5071
5839
  paintStream();
5072
5840
  }
5841
+ else if (ev.type === 'think') {
5842
+ streamThink = ev.replace ? (ev.delta || '') : streamThink + (ev.delta || '');
5843
+ paintStream();
5844
+ }
5073
5845
  else if (ev.type === 'delta') {
5074
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 = '';
5075
5849
  paintStream();
5076
5850
  }
5077
5851
  else if (ev.type === 'final' || ev.type === 'run-pending') {
@@ -5081,6 +5855,8 @@ const APP_HTML = `<!doctype html>
5081
5855
  setTimeout(function () {
5082
5856
  if (token !== raceHandoff) return;
5083
5857
  streamBuf = '';
5858
+ streamThink = '';
5859
+ streamTools = [];
5084
5860
  streamStatus = '';
5085
5861
  streamRace = null;
5086
5862
  render();
@@ -5088,6 +5864,8 @@ const APP_HTML = `<!doctype html>
5088
5864
  return;
5089
5865
  }
5090
5866
  streamBuf = '';
5867
+ streamThink = '';
5868
+ streamTools = [];
5091
5869
  streamStatus = '';
5092
5870
  streamRace = null;
5093
5871
  render();
@@ -5148,15 +5926,22 @@ const APP_HTML = `<!doctype html>
5148
5926
 
5149
5927
  async function submit() {
5150
5928
  const task = inp.value.trim();
5151
- 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 ')) {
5152
5934
  inp.value = '';
5153
5935
  send.classList.remove('show');
5154
5936
  openSitrep();
5155
5937
  return;
5156
5938
  }
5157
5939
  if ((!task && !pendingFiles.length && !pendingImages.length) || !activeId) return;
5158
- inp.value = '';
5159
- 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();
5160
5945
  let full = task;
5161
5946
  // an image with no caption still needs SOME text — an empty text block
5162
5947
  // alongside image_url content gets rejected (400) by at least one path
@@ -5167,13 +5952,39 @@ const APP_HTML = `<!doctype html>
5167
5952
  : '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
5168
5953
  }
5169
5954
  const images = pendingImages.map((i) => i.dataUrl);
5170
- pendingFiles = [];
5171
- pendingImages = [];
5172
- renderAttachChips();
5173
- await fetch(API + '/drive', {
5174
- method: 'POST', headers: { 'content-type': 'application/json' },
5175
- body: JSON.stringify({ threadId: activeId, task: full, images }),
5176
- });
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;
5177
5988
  render();
5178
5989
  }
5179
5990
 
@@ -5366,26 +6177,196 @@ const APP_HTML = `<!doctype html>
5366
6177
  // It cannot change the version baked into the IMAGE — only the site's spawn
5367
6178
  // path can — so it says restart, not update. Promising an upgrade it cannot
5368
6179
  // deliver is how a UI teaches people to distrust it.
5369
- // Cmd/Ctrl+K -> search, the shortcut every chat app trains you to expect.
5370
- // Escape returns focus to the composer instead of leaving you stranded in
5371
- // a box you just cleared. Bound on keydown at the document so it works no
5372
- // matter which pane has focus.
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.
5373
6184
  document.addEventListener('keydown', (e) => {
5374
6185
  const k = (e.key || '').toLowerCase();
5375
- if ((e.metaKey || e.ctrlKey) && k === 'k') {
6186
+ const withMod = e.metaKey || e.ctrlKey;
6187
+ if (withMod && k === 'k') {
5376
6188
  e.preventDefault();
5377
6189
  const el = document.getElementById('search');
5378
6190
  if (el) { el.focus(); el.select(); }
5379
6191
  return;
5380
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
+ }
5381
6208
  // Cmd/Ctrl+Enter sends from anywhere — useful when focus drifted into a
5382
6209
  // RUN card or a copy button mid-thought.
5383
- if ((e.metaKey || e.ctrlKey) && k === 'enter') {
6210
+ if (withMod && k === 'enter') {
5384
6211
  e.preventDefault();
5385
6212
  try { submit(); } catch (err) { /* not ready */ }
5386
6213
  }
5387
6214
  });
5388
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
+
5389
6370
  const reloadBtn = document.getElementById('reloadBtn');
5390
6371
  if (reloadBtn) {
5391
6372
  reloadBtn.addEventListener('click', async () => {
@@ -5413,6 +6394,42 @@ const APP_HTML = `<!doctype html>
5413
6394
  const top = chatHeader.getBoundingClientRect().bottom - mainEl.getBoundingClientRect().top + 8;
5414
6395
  hud.style.top = Math.max(8, Math.round(top)) + 'px';
5415
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
+ }
5416
6433
  function usd(n) {
5417
6434
  if (n === null || n === undefined) return '—';
5418
6435
  if (n === 0) return '$0';
@@ -5425,6 +6442,22 @@ const APP_HTML = `<!doctype html>
5425
6442
  // straight to localhost:8402 would work fine, but routing it through
5426
6443
  // our own backend keeps one fetch path if that ever needs to change.
5427
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
+ }
5428
6461
  const creditEl = document.getElementById('hCredit');
5429
6462
  if (creditEl) creditEl.textContent = (you.creditUsd == null) ? '—' : usd(Number(you.creditUsd) || 0);
5430
6463
  const subRow = document.getElementById('hSubRow');
@@ -5453,13 +6486,15 @@ const APP_HTML = `<!doctype html>
5453
6486
  const savedEl = document.getElementById('hYouSaved');
5454
6487
  const hintEl = document.getElementById('hHint');
5455
6488
  if (spent > 0) {
5456
- const mult = direct / spent;
6489
+ const sav = formatSavingLabel(you);
6490
+ const mult = sav.mult;
5457
6491
  // honest either way: >=1x is a real saving vs a naked direct call,
5458
6492
  // <1x means you're currently paying MORE than direct would cost —
5459
6493
  // don't dress that up as green when it isn't one.
5460
6494
  // Asking a bound corpus makes this genuinely large (the counterfactual
5461
6495
  // is shipping the WHOLE corpus), so 2dp would read as noise up there.
5462
- savedEl.textContent = (mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x';
6496
+ // Label the number: "Nx spilled" when bound, else "Nx session".
6497
+ savedEl.textContent = sav.text;
5463
6498
  savedEl.className = mult >= 1 ? 'hlime' : 'hember';
5464
6499
  // Session direct/spent (never first-call). Ember when cogs > spent —
5465
6500
  // house losing. Do not treat race_unused as a user refund.
@@ -5471,7 +6506,7 @@ const APP_HTML = `<!doctype html>
5471
6506
  hintEl.className = mult >= 1 ? 'hhint' : 'hhint show';
5472
6507
  hintEl.innerHTML = '<b>feed it more.</b> you\\'re billed on the slice actually sent, '
5473
6508
  + 'not the corpus — so the more you bind, the further ahead this gets. '
5474
- + '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.';
5475
6510
  }
5476
6511
  } else {
5477
6512
  savedEl.textContent = '—';
@@ -5483,25 +6518,45 @@ const APP_HTML = `<!doctype html>
5483
6518
  }
5484
6519
  }
5485
6520
  document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
6521
+ paintDock(you);
5486
6522
  } catch (e) {
5487
6523
  document.getElementById('hFoot').textContent = 'error: ' + e.message;
5488
6524
  }
5489
6525
  }
5490
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
+ }
5491
6538
  hudBtn.addEventListener('click', (e) => {
5492
6539
  e.stopPropagation();
5493
6540
  hud.classList.toggle('show');
5494
6541
  if (hud.classList.contains('show')) {
5495
6542
  placeHud();
5496
6543
  refreshHud();
5497
- hudTimer = setInterval(refreshHud, 30000);
5498
- } else if (hudTimer) {
5499
- clearInterval(hudTimer); hudTimer = null;
5500
6544
  }
6545
+ echoSlash('/hud');
6546
+ });
6547
+ refreshHud();
6548
+ ensureHudTick();
6549
+ window.addEventListener('resize', () => {
6550
+ if (hud.classList.contains('show')) placeHud();
6551
+ if (findBarOpen()) placeFindBar();
5501
6552
  });
5502
- window.addEventListener('resize', () => { if (hud.classList.contains('show')) placeHud(); });
5503
- if (typeof ResizeObserver !== 'undefined' && chatHeader) {
5504
- new ResizeObserver(() => { if (hud.classList.contains('show')) placeHud(); }).observe(chatHeader);
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
+ }
5505
6560
  }
5506
6561
  document.addEventListener('click', (e) => { if (!hud.contains(e.target)) hud.classList.remove('show'); });
5507
6562
  </script>
@@ -5679,9 +6734,25 @@ const server = http.createServer((req, res) => {
5679
6734
 
5680
6735
  if (req.method === 'GET' && req.url === '/hud-summary') {
5681
6736
  (async () => {
5682
- let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
5683
- try { you = { ...you, ...(await (await fetch('http://127.0.0.1:8402/v1/session', { signal: AbortSignal.timeout(2000) })).json()) }; }
5684
- 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);
5685
6756
  try {
5686
6757
  you.creditUsd = await creditBalance();
5687
6758
  } catch { /* credit is advisory */ }
@@ -5767,7 +6838,7 @@ const server = http.createServer((req, res) => {
5767
6838
  const t = threads.get(req.url.split('/')[2]);
5768
6839
  res.writeHead(200, { 'content-type': 'application/json' });
5769
6840
  res.end(t ? JSON.stringify({
5770
- id: t.id, history: t.history, status: t.status,
6841
+ id: t.id, history: visibleHistory(t.history), status: t.status,
5771
6842
  liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
5772
6843
  liveRace: t.status === 'thinking' ? (t.liveRace || null) : null,
5773
6844
  lastRaceFail: t.lastRaceFail || null,
@@ -5856,28 +6927,39 @@ const server = http.createServer((req, res) => {
5856
6927
  threadId = j.threadId; task = (j.task || '').toString();
5857
6928
  images = Array.isArray(j.images) ? j.images.filter((u) => typeof u === 'string') : [];
5858
6929
  } catch { /* ignore */ }
5859
- res.writeHead(200, { 'content-type': 'application/json' });
5860
- 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
+ };
5861
6936
  const t = threads.get(threadId);
5862
- // Every OTHER slash command. Local, free, instant — no model call, so
5863
- // checking your spend or clearing a thread never costs anything.
5864
- // /dir and /mode keep their own handlers below, untouched.
5865
- 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())) {
5866
6945
  // Drawer-only. Never dump sitrep into the transcript.
5867
- if (/^\/sitrep\b/i.test(task.trim())) return;
5868
- const handled = await handleSlash(task.trim(), t).catch((e) => `error: ${e.message}`);
5869
- if (handled !== null && handled !== undefined) {
5870
- t.history.push({ who: 'bot', text: handled });
5871
- t.lastActivityAt = Date.now();
5872
- saveThreads();
5873
- 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
+ }
5874
6956
  }
5875
6957
  }
5876
6958
  // "/dir <path>" is a LOCAL control command, not sent to the model at
5877
6959
  // all — free, instant, sets which folder this thread's WRITE/READ/SERVE
5878
6960
  // are scoped to. Respecify any time by sending it again.
5879
6961
  const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
5880
- if (dirCmd && t) {
6962
+ if (dirCmd) {
5881
6963
  const full = path.resolve(expandHome(dirCmd[1].trim()));
5882
6964
  let ok = false;
5883
6965
  try { ok = statSync(full).isDirectory(); } catch { /* not a dir / doesn't exist */ }
@@ -5888,17 +6970,29 @@ const server = http.createServer((req, res) => {
5888
6970
  t.history.push({ who: 'bot', text: `"${full}" isn't a directory that exists.` });
5889
6971
  }
5890
6972
  saveThreads();
6973
+ ack(true, { persisted: true });
5891
6974
  return;
5892
6975
  }
5893
- // "/mode auto|ask" toggles whether RUN: commands execute immediately
5894
- // 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.
5895
6978
  const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
5896
- if (modeCmd && t) {
6979
+ if (modeCmd) {
5897
6980
  t.runMode = modeCmd[1];
5898
- t.history.push({ who: 'bot', text: `Run mode set to ${modeCmd[1]}${modeCmd[1] === 'auto' ? ' — commands execute immediately, no approval.' : ' — commands wait for your approval.'}` });
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.'}` });
5899
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 });
5900
6993
  return;
5901
6994
  }
6995
+ ack(true, { persisted: true });
5902
6996
  // Stream to whoever is watching this thread. emitToThread is a no-op
5903
6997
  // when nobody is, so a spawned subagent nobody has open costs nothing.
5904
6998
  runTurn(threadId, task, (ev) => emitToThread(threadId, ev), images).catch(() => {});
@@ -5913,12 +7007,16 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
5913
7007
 
5914
7008
  export {
5915
7009
  tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
5916
- parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
5917
- handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, runTurn,
7010
+ parseRun, looksLikeMcpAsBash, stripThinkTags, takeThink, safeResolveIn, inDir, listDir,
7011
+ handleSlash, isGrokuiOwnedSlash, newThread, setRunTurnForTest, setBrainAskForTest, setClaudeRunnerForTest, runTurn,
7012
+ runAutoClaudeTurn, autoClaudePrompt,
5918
7013
  AUTO_CONTINUE, AUTO_RACE_RETRY, AUTO_EMPTY_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
5919
7014
  isDoneReply, isTransientModelFail, isPaymentFailed, isEmptyWalletPayment, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
5920
7015
  attachChildDir, finishChildDir,
5921
7016
  lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
5922
7017
  filesForCorpus, noteFileForCorpus, noteRunForCorpus, resetFilesForCorpus,
5923
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,
5924
7022
  };