openzoo 0.49.9 → 0.49.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/grokui.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  import { exec } from 'node:child_process';
9
9
  import http from 'node:http';
10
10
  import { randomUUID } from 'node:crypto';
11
- import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, writeFileSync, writeSync } from 'node:fs';
11
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
12
  import { cpus, homedir } from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES, normalizeTier } from './podagent.mjs';
@@ -36,7 +36,6 @@ import { BIND_MIN_CHARS } from './hrr.js';
36
36
  import { stripThinkTags, takeThink } from './think.js';
37
37
  import {
38
38
  runClaudeCode, setClaudeRunnerForTest, toolStatusLine, CLAUDE_MISSING,
39
- sanitizeClaudeCanvas, closeClaudeSession, GROKUI_RESERVED_SLASH, claudeModelArg,
40
39
  } from './claudecode.js';
41
40
 
42
41
  const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
@@ -432,16 +431,8 @@ const turnAborts = new WeakMap();
432
431
  function saveThreads() {
433
432
  try {
434
433
  mkdirSync(STORE_DIR, { recursive: true });
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; }
434
+ writeFileSync(STORE_FILE, JSON.stringify([...threads.values()]));
435
+ } catch { /* best effort */ }
445
436
  }
446
437
 
447
438
  function loadThreads() {
@@ -465,9 +456,6 @@ function loadThreads() {
465
456
  else delete h.thinking;
466
457
  }
467
458
  }
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);
471
459
  }
472
460
  if (Array.isArray(t.messages)) {
473
461
  for (const m of t.messages) {
@@ -683,7 +671,6 @@ function buildMemberMessages(t, member) {
683
671
  { role: 'system', content: CHAT_NOT_PROXY },
684
672
  ];
685
673
  for (const h of t.history) {
686
- if (h.who === 'user' && isHarnessUserText(h.text)) continue;
687
674
  if (h.who === 'user') msgs.push({ role: 'user', content: contentFor(h.text, h.images) });
688
675
  else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
689
676
  else msgs.push({ role: 'user', content: `[${h.name}]: ${h.text}` });
@@ -746,34 +733,6 @@ function isTransientModelFail(text) {
746
733
  if (/returned nothing \d+ times|each returned nothing/i.test(s)) return true;
747
734
  return false;
748
735
  }
749
- // Quiet Claude TUI / --model miss / chrome-only canvas. 1.5.99 PTY Auto
750
- // treated that as a finished turn, painted "(no response)" / "upstream HTTP N",
751
- // and went idle. Fall through to Ask/Auto chat-completions instead.
752
- function isClaudeFallbackReply(text) {
753
- const s = String(text || '').trim();
754
- if (!s) return true;
755
- if (s === '(no response)') return true;
756
- if (/^(?:error:\s*)?upstream HTTP \d+\s*$/i.test(s)) return true;
757
- return false;
758
- }
759
- function isVisibleBotReply(h) {
760
- if (!h || h.who !== 'bot') return false;
761
- if (!isVisibleHistoryEntry(h)) return false;
762
- const text = String(h.text || '').trim();
763
- if (!text) return false;
764
- if (isClaudeFallbackReply(text)) return false;
765
- return true;
766
- }
767
- function threadHasVisibleBotReply(t) {
768
- return (t?.history || []).some(isVisibleBotReply);
769
- }
770
- function popClaudeFallbackBot(t) {
771
- if (!t || !Array.isArray(t.history) || !t.history.length) return false;
772
- const last = t.history[t.history.length - 1];
773
- if (last.who !== 'bot' || !isClaudeFallbackReply(last.text)) return false;
774
- t.history.pop();
775
- return true;
776
- }
777
736
  function isEmptyWalletPayment(text) {
778
737
  // Empty/underfunded only — not a generic HTTP 402 handshake.
779
738
  return /\b(?:wallet is empty|empty wallet|wallet underfunded|underfunded)\b/i.test(String(text || ''));
@@ -1159,19 +1118,6 @@ const SLASH_COMMANDS = [
1159
1118
  { name: '/mode', args: 'auto|ask', help: 'Auto = Claude Code via OpenZoo; ask = chat + approve RUN' },
1160
1119
  ];
1161
1120
 
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
-
1175
1121
  const usd = (n) => (n >= 0.01 || n === 0 ? '$' + n.toFixed(2) : '$' + n.toFixed(5));
1176
1122
 
1177
1123
  /**
@@ -1432,6 +1378,18 @@ async function handleSlash(task, t) {
1432
1378
  const cmd = m[1].toLowerCase();
1433
1379
  const arg = m[2].trim();
1434
1380
 
1381
+ if (cmd === 'pay') {
1382
+ return 'Pay — card checkout or the local wallet/x402 burner.';
1383
+ }
1384
+ if (cmd === 'hud') {
1385
+ const st = await sessionStats();
1386
+ const spent = st ? usd(Number(st.spentUsd) || 0) : '—';
1387
+ const calls = st ? (st.paidCalls || 0) : 0;
1388
+ const mode = t.runMode || 'ask';
1389
+ const tier = t.tier || 'auto';
1390
+ const sav = st ? formatSavingLabel(st) : { text: '—' };
1391
+ return `Sitrep — mode ${mode} · ${tier} · paid ${spent} · ${sav.text} · ${calls} calls.`;
1392
+ }
1435
1393
  if (cmd === 'sitrep') return null; // drawer-only — never a transcript line
1436
1394
  if (cmd === 'help') {
1437
1395
  return 'Commands:\n'
@@ -2023,69 +1981,6 @@ function isHarnessUserText(text) {
2023
1981
  || String(text || '').startsWith('AUTO_EMPTY_RETRY:');
2024
1982
  }
2025
1983
 
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
-
2089
1984
  function firstUserAsk(t) {
2090
1985
  return (t?.history || []).find((m) => m.who === 'user' && !isHarnessUserText(m.text));
2091
1986
  }
@@ -2836,119 +2731,62 @@ function autoClaudePrompt(t, userText, images) {
2836
2731
  }
2837
2732
 
2838
2733
  /**
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.
2734
+ * Orange Auto = Claude Code print loop, paid through OpenZoo. Not the
2735
+ * RUN:/WRITE:/DONE: text harness (that parked on "Done." after empty mkdirs).
2842
2736
  */
2843
2737
  async function runAutoClaudeTurn(t, userText, images, paint, stillMine, turnAbort) {
2844
2738
  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
2739
  let visible = '';
2849
2740
  let thinking = '';
2850
- const toolLines = [];
2851
2741
  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
2742
  const result = await runClaudeCode({
2860
2743
  prompt: autoClaudePrompt(t, userText, images),
2861
2744
  cwd: dirFor(t.id),
2862
2745
  sessionId: t.claudeSessionId,
2863
- sessionKey: t.id,
2864
- model: claudeModelArg(t.model),
2746
+ model: t.model || undefined,
2865
2747
  env: process.env,
2866
2748
  signal: turnAbort.signal,
2867
2749
  onEvent(folded) {
2868
2750
  if (!stillMine() || !folded) return;
2869
2751
  if (folded.sessionId) t.claudeSessionId = folded.sessionId;
2870
- if (folded.kind === 'init' || folded.kind === 'partial' || folded.kind === 'tool_result') {
2871
- keepFold();
2752
+ if (folded.kind === 'init') {
2753
+ paint({ type: 'status', name: t.name, color: t.color, detail: folded.model || 'Claude Code' });
2872
2754
  return;
2873
2755
  }
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
- }
2756
+ if (folded.kind === 'think' && folded.text) {
2757
+ thinking += folded.text;
2758
+ paint({ type: 'think', name: t.name, color: t.color, delta: folded.text });
2885
2759
  return;
2886
2760
  }
2887
2761
  if (folded.kind === 'text' && folded.text) {
2888
- const clean = sanitizeClaudeCanvas(folded.text);
2889
- if (!clean) {
2890
- keepFold();
2891
- return;
2892
- }
2893
2762
  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);
2763
+ visible += folded.text;
2764
+ paint({ type: 'delta', name: t.name, color: t.color, delta: folded.text });
2913
2765
  return;
2914
2766
  }
2915
2767
  if (folded.kind !== 'assistant') return;
2916
2768
  if (folded.thinking && !thinking) {
2917
2769
  thinking = folded.thinking;
2918
2770
  paint({ type: 'think', name: t.name, color: t.color, delta: folded.thinking });
2919
- } else {
2920
- keepFold();
2921
2771
  }
2922
2772
  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
- }
2773
+ visible = folded.text;
2774
+ paint({ type: 'delta', name: t.name, color: t.color, delta: folded.text, replace: true });
2775
+ }
2776
+ for (const tool of folded.tools || []) {
2777
+ paint({ type: 'status', name: t.name, color: t.color, detail: toolStatusLine(tool.name, tool.input) });
2928
2778
  }
2929
- for (const tool of folded.tools || []) noteTool(tool.name, tool.input);
2930
2779
  },
2931
2780
  });
2932
- if (!stillMine()) {
2933
- return sanitizeClaudeCanvas(result.paymentFailed || result.text || visible || '', { error: result.error });
2934
- }
2781
+ if (!stillMine()) return result.paymentFailed || result.text || visible || '';
2935
2782
  if (result.sessionId) t.claudeSessionId = result.sessionId;
2936
- const thinkAll = [thinking, ...toolLines].filter(Boolean).join('\n');
2937
- let finalText = result.paymentFailed
2783
+ const finalText = result.paymentFailed
2938
2784
  || result.text
2939
2785
  || 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 });
2786
+ || (result.missing ? CLAUDE_MISSING : '')
2787
+ || '(no response)';
2788
+ t.history.push({ who: 'bot', text: finalText, thinking: thinking || undefined });
2789
+ paint({ type: 'final', name: t.name, color: t.color, text: finalText, thinking: thinking || undefined });
2952
2790
  return finalText;
2953
2791
  }
2954
2792
 
@@ -2978,15 +2816,14 @@ async function runTurn(threadId, userText, onEvent, images) {
2978
2816
  if (!stillMine()) return;
2979
2817
  if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
2980
2818
  if (ev.type === 'race' && ev.race && t.status === 'thinking') t.liveRace = ev.race;
2981
- if (ev.type === 'delta' || ev.type === 'think' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race' || ev.type === 'tool') t.lastDeltaAt = Date.now();
2819
+ if (ev.type === 'delta' || ev.type === 'think' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race') t.lastDeltaAt = Date.now();
2982
2820
  onEvent?.(ev);
2983
2821
  };
2984
- persistUserTurn(t, userText, images);
2822
+ t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
2985
2823
  t.lastActivityAt = Date.now();
2986
2824
  t.status = 'thinking';
2987
2825
  t.thinkingAt = Date.now();
2988
2826
  t.lastDeltaAt = Date.now();
2989
- saveThreads();
2990
2827
  try { turnAborts.get(t)?.abort(); } catch { /* none */ }
2991
2828
  const turnAbort = new AbortController();
2992
2829
  turnAborts.set(t, turnAbort);
@@ -2998,7 +2835,6 @@ async function runTurn(threadId, userText, onEvent, images) {
2998
2835
  let chained = false;
2999
2836
  let parked = false;
3000
2837
  let usedClaude = false;
3001
- let claudeFallback = false;
3002
2838
  let lastReply = '';
3003
2839
  try {
3004
2840
  if (t.members) {
@@ -3063,37 +2899,13 @@ async function runTurn(threadId, userText, onEvent, images) {
3063
2899
  }
3064
2900
  return;
3065
2901
  }
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.
2902
+ // Orange Auto is Claude Code (`openzoo claude` env), not the RUN:/WRITE:
2903
+ // text loop. Ask (and group members above) still use chat/completions.
3068
2904
  if (t.runMode === 'auto') {
3069
2905
  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
- }
2906
+ lastReply = await runAutoClaudeTurn(t, userText, images, paint, stillMine, turnAbort);
2907
+ if (stillMine()) bindThread(t).catch(() => {});
2908
+ return;
3097
2909
  }
3098
2910
  // A real message from the user resets the auto budget AND the announcement
3099
2911
  // nudge. Harness-injected hops (command output, directive result, nudge,
@@ -3104,12 +2916,7 @@ async function runTurn(threadId, userText, onEvent, images) {
3104
2916
  t.autoSteps = 0;
3105
2917
  delete t.autoNudged;
3106
2918
  }
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
- }
2919
+ t.messages.push({ role: 'user', content: contentFor(userText, images) });
3113
2920
  let reply = '';
3114
2921
  paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'waiting on model…' });
3115
2922
  // Transient: the nudge is appended for THIS call only and never pushed into
@@ -3127,7 +2934,7 @@ async function runTurn(threadId, userText, onEvent, images) {
3127
2934
  extras.push({ role: 'system', content: `Current checklist (TODO: done <n> to tick one off):\n${t.todos.map((x, i) => `${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')}` });
3128
2935
  }
3129
2936
  extras.push({ role: 'system', content: CHAT_NOT_PROXY });
3130
- if (t.runMode === 'auto' && !claudeFallback) extras.push({ role: 'system', content: AUTO_DIRECTIVE });
2937
+ if (t.runMode === 'auto') extras.push({ role: 'system', content: AUTO_DIRECTIVE });
3131
2938
  const callMsgs = extras.length ? [...t.messages, ...extras] : t.messages;
3132
2939
  // WHICH MODEL SERVES THIS TURN.
3133
2940
  // /model <id> pins one explicitly and always wins — an explicit choice is
@@ -3229,7 +3036,7 @@ async function runTurn(threadId, userText, onEvent, images) {
3229
3036
  const runCmd = parseRun(reply);
3230
3037
  if (runCmd) {
3231
3038
  const command = runCmd;
3232
- if (t.runMode === 'auto' && !claudeFallback) {
3039
+ if (t.runMode === 'auto') {
3233
3040
  emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
3234
3041
  const output = await execCommand(command, dirFor(t.id));
3235
3042
  noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
@@ -3284,7 +3091,7 @@ async function runTurn(threadId, userText, onEvent, images) {
3284
3091
  //
3285
3092
  // Same budget as RUN (shared t.autoSteps, reset when the user speaks), so
3286
3093
  // this cannot spend more than a chained RUN loop already could.
3287
- if (t.runMode === 'auto' && !claudeFallback && ack !== null && ack !== undefined
3094
+ if (t.runMode === 'auto' && ack !== null && ack !== undefined
3288
3095
  && (isEmptyDirectiveAck(ack) || !isDoneReply(reply))) {
3289
3096
  chained = enqueueAutoHop(
3290
3097
  t, threadId,
@@ -3308,7 +3115,7 @@ async function runTurn(threadId, userText, onEvent, images) {
3308
3115
  // NUDGE announcements (stronger than a bare continue). Plain replies used
3309
3116
  // to park here; they now fall through to AUTO_CONTINUE unless DONE: or a
3310
3117
  // real blocking question. The step cap is the wallet bound.
3311
- if (t.runMode === 'auto' && !claudeFallback && (ack === null || ack === undefined)
3118
+ if (t.runMode === 'auto' && (ack === null || ack === undefined)
3312
3119
  && (STALLED_OFFER.test(reply) || ANNOUNCEMENT.test(reply))) {
3313
3120
  // Offers and "Spawned X — working on it" with no directive must not end
3314
3121
  // the run. The old once-only autoNudged gate parked the thread after one
@@ -3320,7 +3127,7 @@ async function runTurn(threadId, userText, onEvent, images) {
3320
3127
 
3321
3128
  // After any auto reply that is not DONE: and not waiting on approval,
3322
3129
  // kick immediately. Race/empty/error uses AUTO_RACE_RETRY.
3323
- if (!claudeFallback && shouldKeepAuto(t, reply, userText)) {
3130
+ if (shouldKeepAuto(t, reply, userText)) {
3324
3131
  chained = enqueueAutoHop(t, threadId, autoHopText(reply, userText), onEvent);
3325
3132
  return;
3326
3133
  }
@@ -3330,7 +3137,7 @@ async function runTurn(threadId, userText, onEvent, images) {
3330
3137
  // ask mode, 402/empty-wallet, or the hard cap. Empty /(no output) is not
3331
3138
  // DONE — AUTO_EMPTY_RETRY. Otherwise kick again.
3332
3139
  if (stillMine() && !chained && !parked) {
3333
- if (!usedClaude && !claudeFallback && shouldKeepAuto(t, lastReply, userText)) {
3140
+ if (!usedClaude && shouldKeepAuto(t, lastReply, userText)) {
3334
3141
  enqueueAutoHop(t, threadId, autoHopText(lastReply, userText), onEvent);
3335
3142
  } else if (!t.pendingRun) {
3336
3143
  t.status = 'idle';
@@ -3459,8 +3266,7 @@ function subtreeOf(id, includeSelf = false) {
3459
3266
  }
3460
3267
 
3461
3268
  function threadSummary(t) {
3462
- const vis = visibleHistory(t.history);
3463
- const last = vis[vis.length - 1];
3269
+ const last = t.history[t.history.length - 1];
3464
3270
  return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
3465
3271
  preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
3466
3272
  createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
@@ -3837,7 +3643,7 @@ const APP_HTML = `<!doctype html>
3837
3643
  .row.bot .bubble { background: #262626; color: #ececec; }
3838
3644
  /* Folded chain-of-thought. Not the Auto run-mode chip — that is /mode auto.
3839
3645
  Default collapsed; click the label to unfurl, click again to furl.
3840
- Live Auto turns always show thinking… while the Claude child is alive. */
3646
+ No empty chip: the fold is omitted when the model did not reason. */
3841
3647
  .msgcol { display: flex; flex-direction: column; gap: 6px; min-width: 0; max-width: 100%; flex: 1; }
3842
3648
  .thinkfold { align-self: flex-start; max-width: 100%; min-width: 0; }
3843
3649
  .thinkchip {
@@ -3854,15 +3660,6 @@ const APP_HTML = `<!doctype html>
3854
3660
  border-left: 2px solid #3a3a3c; max-height: 240px; overflow-y: auto;
3855
3661
  }
3856
3662
  .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; }
3866
3663
  .row.bot.pending .bubble { color: #8e8e93; }
3867
3664
  .dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
3868
3665
  background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
@@ -4863,10 +4660,9 @@ const APP_HTML = `<!doctype html>
4863
4660
  const spent = Number(you.spentUsd) || 0;
4864
4661
  const cogs = Number(you.cogsUsd) || 0;
4865
4662
  const direct = Number(you.directUsd) || 0;
4866
- const proxyDown = you.proxyReachable === false;
4867
4663
  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'));
4664
+ const saved = sav.text;
4665
+ const savedCls = sav.mult == null ? '' : (sav.mult >= 1 ? 'hlime' : 'hember');
4870
4666
  const thinking = (full && full.status === 'thinking') || t.status === 'thinking';
4871
4667
  const race = (full && full.liveRace) || null;
4872
4668
  let flight = 'idle';
@@ -4883,12 +4679,11 @@ const APP_HTML = `<!doctype html>
4883
4679
  + sitrepRow('cwd', cwd)
4884
4680
  + sitrepRow('in flight', flight)
4885
4681
  + '<div class="wlanetitle" style="margin-top:16px">this session</div>'
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))))
4682
+ + sitrepRow('paid', '$' + (spent >= 0.01 || spent === 0 ? spent.toFixed(2) : spent.toFixed(5)))
4683
+ + sitrepRow('cogs', '$' + (cogs >= 0.01 || cogs === 0 ? cogs.toFixed(2) : cogs.toFixed(5)))
4684
+ + sitrepRow('direct', '$' + (direct >= 0.01 || direct === 0 ? direct.toFixed(2) : direct.toFixed(5)))
4890
4685
  + sitrepRow('saved vs naked', saved, savedCls)
4891
- + sitrepRow('paid calls', proxyDown ? '—' : String(you.paidCalls || 0))
4686
+ + sitrepRow('paid calls', String(you.paidCalls || 0))
4892
4687
  + sitrepRow('prepaid', (Number(you.creditUsd) > 0) ? 'yes' : 'no');
4893
4688
  }
4894
4689
  function closeSitrep() { sitrepOverlay.classList.remove('show'); }
@@ -4922,17 +4717,6 @@ const APP_HTML = `<!doctype html>
4922
4717
  document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
4923
4718
 
4924
4719
  function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
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
4720
  function splitThinkTags(s) {
4937
4721
  s = String(s == null ? '' : s);
4938
4722
  var bits = [];
@@ -5337,48 +5121,7 @@ const APP_HTML = `<!doctype html>
5337
5121
 
5338
5122
  let lastSpeaker = null;
5339
5123
  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
5124
  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
5125
  function makeThinkFold(text, live, open) {
5383
5126
  const fold = document.createElement('div');
5384
5127
  fold.className = 'thinkfold' + (open ? ' open' : '');
@@ -5465,7 +5208,6 @@ const APP_HTML = `<!doctype html>
5465
5208
  }
5466
5209
  function addRow(who, text, color, name, run, images, thinking, live) {
5467
5210
  if (who === 'bot') {
5468
- text = scrubBotText(text);
5469
5211
  const parts = splitThinkTags(text);
5470
5212
  text = parts.visible;
5471
5213
  if (!thinking) thinking = parts.thinking;
@@ -5484,9 +5226,9 @@ const APP_HTML = `<!doctype html>
5484
5226
  col.className = 'msgcol';
5485
5227
  const thinkText = (who === 'bot' && thinking) ? String(thinking).trim() : '';
5486
5228
  if (live) {
5487
- const fold = makeThinkFold(foldBodyText(thinkText), true, !!liveThinkOpen);
5229
+ const fold = makeThinkFold(thinkText, true, liveThinkOpen && !!thinkText);
5488
5230
  fold.id = 'streamThink';
5489
- fold.hidden = false;
5231
+ if (!thinkText) fold.hidden = true;
5490
5232
  col.appendChild(fold);
5491
5233
  } else if (thinkText) {
5492
5234
  col.appendChild(makeThinkFold(thinkText, false, false));
@@ -5537,7 +5279,6 @@ const APP_HTML = `<!doctype html>
5537
5279
  }
5538
5280
  const textEl = document.createElement('div');
5539
5281
  textEl.innerHTML = renderMentions(text);
5540
- if (live && (!text || text === '…')) bubble.hidden = true;
5541
5282
  if (who === 'bot') {
5542
5283
  const preview = htmlPreviewUrl(text);
5543
5284
  if (preview) textEl.appendChild(previewFrame(preview, htmlPreviewKey(text, preview)));
@@ -5582,8 +5323,7 @@ const APP_HTML = `<!doctype html>
5582
5323
  if (!full || full.id !== activeId) return;
5583
5324
  const renderKey = String(workspacePort) + '|' + full.id + '|' + full.status + '|' + (full.history || []).map(function (h) {
5584
5325
  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 || '');
5326
+ }).join('||');
5587
5327
  if (renderKey === lastRenderKey) {
5588
5328
  if (streamBuf || streamThink) paintStream();
5589
5329
  return;
@@ -5595,10 +5335,7 @@ const APP_HTML = `<!doctype html>
5595
5335
  parkedPreviews = parkPreviews();
5596
5336
  log.innerHTML = '';
5597
5337
  lastSpeaker = null;
5598
- const seenUser = {};
5599
5338
  for (const h of full.history) {
5600
- if (h.who === 'user' && isHarnessUserText(h.text)) continue;
5601
- if (h.who === 'user') seenUser[h.text] = true;
5602
5339
  let run;
5603
5340
  if (h.runId || h.runStatus) {
5604
5341
  run = { id: h.runId, status: h.runStatus, output: h.runOutput, command: h.text };
@@ -5608,21 +5345,6 @@ const APP_HTML = `<!doctype html>
5608
5345
  }
5609
5346
  addRow(h.who, h.text, h.color || t.color, h.name || t.name, run, h.images, h.thinking);
5610
5347
  }
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);
5625
- }
5626
5348
  if (full.status === 'thinking') {
5627
5349
  if (full.liveStatus) streamStatus = full.liveStatus;
5628
5350
  if (full.liveRace && full.liveRace.racers && full.liveRace.racers.length >= 2) {
@@ -5708,93 +5430,55 @@ const APP_HTML = `<!doctype html>
5708
5430
  function liveBubbleHtml() {
5709
5431
  if (raceIsLive(streamRace)) return raceGridHtml(streamRace);
5710
5432
  const vis = streamParts().visible;
5711
- return vis ? escapeHtml(vis) : '';
5433
+ if (vis) {
5434
+ const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
5435
+ ? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
5436
+ return escapeHtml(vis) + trail;
5437
+ }
5438
+ const dots = '<span class="dots"><span></span><span></span><span></span></span>';
5439
+ const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
5440
+ return dots + (st ? ' ' + st : '');
5712
5441
  }
5713
5442
  function paintThinkFoldLive(think) {
5714
5443
  const fold = document.getElementById('streamThink');
5715
5444
  if (!fold) return;
5716
5445
  const chip = fold.querySelector('.thinkchip');
5717
5446
  const body = fold.querySelector('.thinkbody');
5718
- const text = foldBodyText(think);
5719
- fold.hidden = false;
5720
- fold.setAttribute('data-think', text);
5447
+ const has = !!(think && String(think).trim());
5448
+ fold.hidden = !has;
5449
+ if (!has) {
5450
+ if (body) body.textContent = '';
5451
+ return;
5452
+ }
5453
+ fold.setAttribute('data-think', think);
5721
5454
  fold.classList.toggle('open', !!liveThinkOpen);
5722
5455
  if (chip) {
5723
5456
  chip.textContent = 'thinking...';
5724
5457
  chip.setAttribute('aria-expanded', liveThinkOpen ? 'true' : 'false');
5725
5458
  }
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';
5459
+ if (body) body.textContent = liveThinkOpen ? think : '';
5764
5460
  }
5765
5461
  function paintStream() {
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;
5462
+ const b = document.getElementById('streamBubble');
5463
+ if (!b) { render(); return; }
5778
5464
  const parts = streamParts();
5779
5465
  paintThinkFoldLive(parts.think);
5780
5466
  if (raceIsLive(streamRace)) {
5781
- b.hidden = false;
5782
5467
  b.classList.add('raceboard');
5783
5468
  b.innerHTML = liveBubbleHtml();
5784
- if (wasNearBottom || followLive) pinLogBottom();
5469
+ if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
5785
5470
  scheduleFindPaint();
5786
5471
  return;
5787
5472
  }
5788
5473
  b.classList.remove('raceboard');
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);
5474
+ // Visible tokens only. Chain-of-thought stays in the fold — and only
5475
+ // paints into the fold body when the user has unfurled it.
5476
+ if (parts.visible && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
5477
+ b.textContent = parts.visible;
5793
5478
  } else {
5794
- b.hidden = true;
5795
5479
  b.innerHTML = liveBubbleHtml();
5796
5480
  }
5797
- if (wasNearBottom || followLive) pinLogBottom();
5481
+ if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
5798
5482
  scheduleFindPaint();
5799
5483
  }
5800
5484
  function connectStream(id) {
@@ -5803,13 +5487,10 @@ const APP_HTML = `<!doctype html>
5803
5487
  esId = id;
5804
5488
  streamBuf = '';
5805
5489
  streamThink = '';
5806
- streamTools = [];
5807
5490
  streamStatus = '';
5808
5491
  streamRace = null;
5809
5492
  streamRaceId = id;
5810
5493
  liveThinkOpen = false;
5811
- // New thread: do not inherit follow from the previous log position.
5812
- followLive = log.scrollHeight - log.scrollTop - log.clientHeight < 140;
5813
5494
  raceHandoff += 1;
5814
5495
  es = new EventSource('/stream/' + id); // EventSource reconnects on its own
5815
5496
  es.onmessage = (e) => {
@@ -5818,18 +5499,12 @@ const APP_HTML = `<!doctype html>
5818
5499
  if (ev.type === 'start') {
5819
5500
  streamBuf = '';
5820
5501
  streamThink = '';
5821
- streamTools = [];
5822
5502
  streamStatus = ev.detail || 'waiting on model…';
5823
5503
  streamRace = null;
5824
5504
  streamRaceId = id;
5825
5505
  liveThinkOpen = false;
5826
5506
  paintStream();
5827
5507
  }
5828
- else if (ev.type === 'tool') {
5829
- const line = ev.detail || '';
5830
- if (line && streamTools[streamTools.length - 1] !== line) streamTools.push(line);
5831
- paintStream();
5832
- }
5833
5508
  else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
5834
5509
  else if (ev.type === 'race') {
5835
5510
  if (ev.race && ev.race.racers && ev.race.racers.length >= 2) {
@@ -5839,13 +5514,12 @@ const APP_HTML = `<!doctype html>
5839
5514
  paintStream();
5840
5515
  }
5841
5516
  else if (ev.type === 'think') {
5842
- streamThink = ev.replace ? (ev.delta || '') : streamThink + (ev.delta || '');
5517
+ streamThink += ev.delta || '';
5843
5518
  paintStream();
5844
5519
  }
5845
5520
  else if (ev.type === 'delta') {
5846
5521
  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 = '';
5522
+ if (ev.replace) streamThink = '';
5849
5523
  paintStream();
5850
5524
  }
5851
5525
  else if (ev.type === 'final' || ev.type === 'run-pending') {
@@ -5856,7 +5530,6 @@ const APP_HTML = `<!doctype html>
5856
5530
  if (token !== raceHandoff) return;
5857
5531
  streamBuf = '';
5858
5532
  streamThink = '';
5859
- streamTools = [];
5860
5533
  streamStatus = '';
5861
5534
  streamRace = null;
5862
5535
  render();
@@ -5865,7 +5538,6 @@ const APP_HTML = `<!doctype html>
5865
5538
  }
5866
5539
  streamBuf = '';
5867
5540
  streamThink = '';
5868
- streamTools = [];
5869
5541
  streamStatus = '';
5870
5542
  streamRace = null;
5871
5543
  render();
@@ -5937,11 +5609,8 @@ const APP_HTML = `<!doctype html>
5937
5609
  return;
5938
5610
  }
5939
5611
  if ((!task && !pendingFiles.length && !pendingImages.length) || !activeId) return;
5940
- if (sending) return;
5941
- sending = true;
5942
- const draft = inp.value;
5943
- const hadFiles = pendingFiles.slice();
5944
- const hadImages = pendingImages.slice();
5612
+ inp.value = '';
5613
+ send.classList.remove('show');
5945
5614
  let full = task;
5946
5615
  // an image with no caption still needs SOME text — an empty text block
5947
5616
  // alongside image_url content gets rejected (400) by at least one path
@@ -5952,39 +5621,13 @@ const APP_HTML = `<!doctype html>
5952
5621
  : '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
5953
5622
  }
5954
5623
  const images = pendingImages.map((i) => i.dataUrl);
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;
5624
+ pendingFiles = [];
5625
+ pendingImages = [];
5626
+ renderAttachChips();
5627
+ await fetch(API + '/drive', {
5628
+ method: 'POST', headers: { 'content-type': 'application/json' },
5629
+ body: JSON.stringify({ threadId: activeId, task: full, images }),
5630
+ });
5988
5631
  render();
5989
5632
  }
5990
5633
 
@@ -6405,16 +6048,6 @@ const APP_HTML = `<!doctype html>
6405
6048
  const bindEl = document.getElementById('dockBind');
6406
6049
  const callsEl = document.getElementById('dockCalls');
6407
6050
  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
6051
  const spent = Number(you && you.spentUsd) || 0;
6419
6052
  const direct = Number(you && you.directUsd) || 0;
6420
6053
  const spillX = Number(you && you.spilled && you.spilled.savingX);
@@ -6442,22 +6075,6 @@ const APP_HTML = `<!doctype html>
6442
6075
  // straight to localhost:8402 would work fine, but routing it through
6443
6076
  // our own backend keeps one fetch path if that ever needs to change.
6444
6077
  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
- }
6461
6078
  const creditEl = document.getElementById('hCredit');
6462
6079
  if (creditEl) creditEl.textContent = (you.creditUsd == null) ? '—' : usd(Number(you.creditUsd) || 0);
6463
6080
  const subRow = document.getElementById('hSubRow');
@@ -6524,16 +6141,9 @@ const APP_HTML = `<!doctype html>
6524
6141
  }
6525
6142
  }
6526
6143
  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
6144
  function ensureHudTick() {
6535
6145
  if (hudTimer) return;
6536
- hudTimer = setInterval(refreshHud, hudTickMs);
6146
+ hudTimer = setInterval(refreshHud, 30000);
6537
6147
  }
6538
6148
  hudBtn.addEventListener('click', (e) => {
6539
6149
  e.stopPropagation();
@@ -6734,24 +6344,9 @@ const server = http.createServer((req, res) => {
6734
6344
 
6735
6345
  if (req.method === 'GET' && req.url === '/hud-summary') {
6736
6346
  (async () => {
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
- }
6347
+ let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
6348
+ try { you = { ...you, ...(await (await fetch('http://127.0.0.1:8402/v1/session', { signal: AbortSignal.timeout(2000) })).json()) }; }
6349
+ catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
6755
6350
  await attachSpilled(you);
6756
6351
  try {
6757
6352
  you.creditUsd = await creditBalance();
@@ -6838,7 +6433,7 @@ const server = http.createServer((req, res) => {
6838
6433
  const t = threads.get(req.url.split('/')[2]);
6839
6434
  res.writeHead(200, { 'content-type': 'application/json' });
6840
6435
  res.end(t ? JSON.stringify({
6841
- id: t.id, history: visibleHistory(t.history), status: t.status,
6436
+ id: t.id, history: t.history, status: t.status,
6842
6437
  liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
6843
6438
  liveRace: t.status === 'thinking' ? (t.liveRace || null) : null,
6844
6439
  lastRaceFail: t.lastRaceFail || null,
@@ -6927,39 +6522,32 @@ const server = http.createServer((req, res) => {
6927
6522
  threadId = j.threadId; task = (j.task || '').toString();
6928
6523
  images = Array.isArray(j.images) ? j.images.filter((u) => typeof u === 'string') : [];
6929
6524
  } catch { /* ignore */ }
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
- };
6936
6525
  const t = threads.get(threadId);
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())) {
6526
+ const driveOk = () => {
6527
+ if (res.headersSent) return;
6528
+ res.writeHead(200, { 'content-type': 'application/json' });
6529
+ res.end(JSON.stringify({ ok: true }));
6530
+ };
6531
+ // Every OTHER slash command. Local, free, instant — no model call, so
6532
+ // checking your spend or clearing a thread never costs anything.
6533
+ // /dir and /mode keep their own handlers below, untouched.
6534
+ if (t && /^\//.test(task.trim())) {
6945
6535
  // Drawer-only. Never dump sitrep into the transcript.
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
- }
6536
+ if (/^\/sitrep\b/i.test(task.trim())) { driveOk(); return; }
6537
+ const handled = await handleSlash(task.trim(), t).catch((e) => `error: ${e.message}`);
6538
+ if (handled !== null && handled !== undefined) {
6539
+ t.history.push({ who: 'bot', text: handled });
6540
+ t.lastActivityAt = Date.now();
6541
+ saveThreads();
6542
+ driveOk();
6543
+ return;
6956
6544
  }
6957
6545
  }
6958
6546
  // "/dir <path>" is a LOCAL control command, not sent to the model at
6959
6547
  // all — free, instant, sets which folder this thread's WRITE/READ/SERVE
6960
6548
  // are scoped to. Respecify any time by sending it again.
6961
6549
  const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
6962
- if (dirCmd) {
6550
+ if (dirCmd && t) {
6963
6551
  const full = path.resolve(expandHome(dirCmd[1].trim()));
6964
6552
  let ok = false;
6965
6553
  try { ok = statSync(full).isDirectory(); } catch { /* not a dir / doesn't exist */ }
@@ -6970,31 +6558,22 @@ const server = http.createServer((req, res) => {
6970
6558
  t.history.push({ who: 'bot', text: `"${full}" isn't a directory that exists.` });
6971
6559
  }
6972
6560
  saveThreads();
6973
- ack(true, { persisted: true });
6561
+ driveOk();
6974
6562
  return;
6975
6563
  }
6976
6564
  // "/mode auto|ask" — Auto is the Claude Code harness (openzoo claude
6977
6565
  // env). Ask stays a chat completion; RUN: waits for approve/deny.
6978
6566
  const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
6979
- if (modeCmd) {
6567
+ if (modeCmd && t) {
6980
6568
  t.runMode = modeCmd[1];
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.'}` });
6569
+ t.history.push({ who: 'bot', text: `Run mode set to ${modeCmd[1]}${modeCmd[1] === 'auto' ? ' — Claude Code via OpenZoo (x402). Native tools, not RUN: text.' : ' — chat completion; RUN: waits for your approval.'}` });
6983
6570
  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 });
6571
+ driveOk();
6993
6572
  return;
6994
6573
  }
6995
- ack(true, { persisted: true });
6996
6574
  // Stream to whoever is watching this thread. emitToThread is a no-op
6997
6575
  // when nobody is, so a spawned subagent nobody has open costs nothing.
6576
+ driveOk();
6998
6577
  runTurn(threadId, task, (ev) => emitToThread(threadId, ev), images).catch(() => {});
6999
6578
  });
7000
6579
  return;
@@ -7008,7 +6587,7 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
7008
6587
  export {
7009
6588
  tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
7010
6589
  parseRun, looksLikeMcpAsBash, stripThinkTags, takeThink, safeResolveIn, inDir, listDir,
7011
- handleSlash, isGrokuiOwnedSlash, newThread, setRunTurnForTest, setBrainAskForTest, setClaudeRunnerForTest, runTurn,
6590
+ handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, setClaudeRunnerForTest, runTurn,
7012
6591
  runAutoClaudeTurn, autoClaudePrompt,
7013
6592
  AUTO_CONTINUE, AUTO_RACE_RETRY, AUTO_EMPTY_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
7014
6593
  isDoneReply, isTransientModelFail, isPaymentFailed, isEmptyWalletPayment, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
@@ -7017,6 +6596,4 @@ export {
7017
6596
  filesForCorpus, noteFileForCorpus, noteRunForCorpus, resetFilesForCorpus,
7018
6597
  filesForCorpusKeys, scheduleFilesForCorpus, inFlightChars, BIND_MIN_CHARS, KEEP_MAX,
7019
6598
  formatSavingLabel, holobrainOf, looksLikeProxyShell, CHAT_NOT_PROXY, PROXY_SHELL_REFUSE,
7020
- persistUserTurn, isHarnessUserText, visibleHistory, isVisibleHistoryEntry, saveThreads,
7021
- isClaudeFallbackReply, popClaudeFallbackBot, threadHasVisibleBotReply,
7022
6599
  };