openzoo 0.48.87 → 0.48.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/grokui.mjs CHANGED
@@ -12,6 +12,8 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSyn
12
12
  import { cpus, homedir } from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES } from './podagent.mjs';
15
+ import { peekDirectiveStatus, STALE_THINKING_MS } from './livestatus.js';
16
+ import { creditBalance } from './info.js';
15
17
 
16
18
  const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
17
19
  // BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
@@ -54,6 +56,28 @@ const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'applic
54
56
  mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
55
57
  jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
56
58
  let workspacePort = null;
59
+ let workspacePortResolve = () => {};
60
+ let workspaceBinding = false;
61
+ const workspacePortReady = new Promise((resolve) => { workspacePortResolve = resolve; });
62
+ function bindWorkspaceServer() {
63
+ if (workspaceServer.listening) {
64
+ workspacePort = workspaceServer.address().port;
65
+ workspacePortResolve(workspacePort);
66
+ return;
67
+ }
68
+ if (workspaceBinding) return;
69
+ workspaceBinding = true;
70
+ try {
71
+ workspaceServer.listen(0, '127.0.0.1', () => {
72
+ workspacePort = workspaceServer.address().port;
73
+ workspacePortResolve(workspacePort);
74
+ });
75
+ } catch (err) {
76
+ workspaceBinding = false;
77
+ console.error('[grokui] workspace server:', err.message);
78
+ setTimeout(bindWorkspaceServer, 250);
79
+ }
80
+ }
57
81
  // route: /<threadId>/<relpath...> — each thread is served from ITS OWN dir
58
82
  const workspaceServer = http.createServer((req, res) => {
59
83
  try {
@@ -65,14 +89,65 @@ const workspaceServer = http.createServer((req, res) => {
65
89
  const full = safeResolveIn(dirFor(threadId), rel);
66
90
  const data = readFileSync(full);
67
91
  const ext = full.split('.').pop();
68
- res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' });
92
+ res.writeHead(200, {
93
+ 'content-type': MIME[ext] || 'application/octet-stream',
94
+ // EDIT of the same html must not be served from a cached first write.
95
+ 'cache-control': 'no-store',
96
+ });
69
97
  res.end(data);
70
98
  } catch {
71
99
  res.writeHead(404, { 'content-type': 'text/plain' });
72
100
  res.end('not found');
73
101
  }
74
102
  });
75
- workspaceServer.listen(0, '127.0.0.1', () => { workspacePort = workspaceServer.address().port; });
103
+ workspaceServer.on('error', (err) => {
104
+ workspaceBinding = false;
105
+ console.error('[grokui] workspace server:', err.message);
106
+ setTimeout(bindWorkspaceServer, 250);
107
+ });
108
+ bindWorkspaceServer();
109
+
110
+ function isPreviewableRel(rel) {
111
+ const base = path.basename(String(rel || '').split('?')[0]).toLowerCase();
112
+ return base.endsWith('.html') || base.endsWith('.htm');
113
+ }
114
+
115
+ async function ensureWorkspacePort(ms = 4000) {
116
+ if (workspacePort) return workspacePort;
117
+ if (!workspaceServer.listening) bindWorkspaceServer();
118
+ let timer;
119
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); });
120
+ const port = await Promise.race([workspacePortReady, timeout]);
121
+ clearTimeout(timer);
122
+ return port || workspacePort;
123
+ }
124
+
125
+ function workspaceFileUrl(originId, rel) {
126
+ const clean = String(rel || '').replace(/^\/+/, '');
127
+ return `http://localhost:${workspacePort}/${originId}/${clean}`;
128
+ }
129
+
130
+ // WRITE/EDIT of a playable page must ack a real http:// URL (same shape as
131
+ // SERVE), not a dead disk path. Wait for the static server rather than
132
+ // telling the user to try again after they just wrote a game.
133
+ async function previewAck(originId, rel) {
134
+ if (!isPreviewableRel(rel)) return '';
135
+ const port = await ensureWorkspacePort();
136
+ if (!port) {
137
+ return `\nPreview: the workspace server is binding; the page is ${rel} and will be at `
138
+ + `http://localhost/<port>/${originId}/${String(rel).replace(/^\/+/, '')}.`;
139
+ }
140
+ return `\nPreview: ${workspaceFileUrl(originId, rel)}`;
141
+ }
142
+
143
+ const HTML_PREVIEW_RULE = `
144
+ PREVIEW IS AUTOMATIC. After you WRITE or EDIT a .html / .htm file (including index.html),
145
+ the harness already served it — the WRITE ack includes a real http://localhost URL and the
146
+ chat bubble shows a live iframe. The harness will preview. Do not tell the user you
147
+ "can't preview", cannot open a browser, or dump a raw disk path (/Users/..., ~/.openzoo/...)
148
+ as the punchline. The page is already on screen. If you mention a location, use that
149
+ http://localhost link, never a filesystem path.
150
+ `;
76
151
 
77
152
  const PALETTE = ['#e91e8c', '#34c759', '#ff9500', '#5e5ce6', '#ff3b30', '#0a84ff', '#00c7be'];
78
153
  function colorFor(name) {
@@ -117,11 +192,15 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
117
192
  SERVE: <relative path, or blank for the dir root> get a real http:// URL for a file —
118
193
  use this instead of claiming you
119
194
  "can't expose a port": you can serve
120
- static files, just not run a process
195
+ static files, just not run a process.
196
+ HTML writes are auto-served — you do
197
+ not need a separate SERVE for a
198
+ playable page.
121
199
  FETCH: <url> actually fetch and read a page's real
122
200
  text — web search only gives you short
123
201
  snippets; use FETCH when asked to
124
202
  "read" or quote something specific
203
+ ${HTML_PREVIEW_RULE}
125
204
 
126
205
  HOW YOUR SITE ACTUALLY GETS A URL — read this before writing web files.
127
206
 
@@ -296,7 +375,14 @@ function loadThreads() {
296
375
  if (!existsSync(STORE_FILE)) return false;
297
376
  const arr = JSON.parse(readFileSync(STORE_FILE, 'utf8'));
298
377
  if (!Array.isArray(arr) || !arr.length) return false;
299
- for (const t of arr) threads.set(t.id, t);
378
+ for (const t of arr) {
379
+ // A crash mid-turn persisted status=thinking. Do not reload into mute "…".
380
+ if (t.status === 'thinking') {
381
+ t.status = 'idle';
382
+ t.liveStatus = '';
383
+ }
384
+ threads.set(t.id, t);
385
+ }
300
386
  return true;
301
387
  } catch { return false; }
302
388
  }
@@ -367,9 +453,12 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
367
453
  READ: <relative path> read a file back
368
454
  SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
369
455
  this instead of saying you can't
370
- expose a port
456
+ expose a port. HTML writes are
457
+ auto-served; you do not need SERVE
458
+ just to preview a playable page.
371
459
  FETCH: <url> actually fetch and read a page's real
372
460
  text — web search only gives snippets
461
+ ${HTML_PREVIEW_RULE}
373
462
  RUN: <shell command> run a REAL shell command in this
374
463
  group's shared directory — pauses the
375
464
  WHOLE round for the user's approval
@@ -1106,6 +1195,24 @@ setInterval(() => {
1106
1195
  }
1107
1196
  }, 15000).unref();
1108
1197
 
1198
+ // A turn that is thinking with no deltas/status for too long is dead — the
1199
+ // stream reader used to hang forever and block the next user prompt behind
1200
+ // mute dots. Bump turnSeq so the in-flight runTurn bails, then idle.
1201
+ setInterval(() => {
1202
+ const now = Date.now();
1203
+ let dirty = false;
1204
+ for (const t of threads.values()) {
1205
+ if (t.status !== 'thinking') continue;
1206
+ const last = t.lastDeltaAt || t.thinkingAt || 0;
1207
+ if (!last || now - last < STALE_THINKING_MS) continue;
1208
+ t.turnSeq = (t.turnSeq || 0) + 1;
1209
+ t.status = 'idle';
1210
+ t.liveStatus = '';
1211
+ dirty = true;
1212
+ }
1213
+ if (dirty) saveThreads();
1214
+ }, 5000).unref();
1215
+
1109
1216
  // Directives that only READ. These are safe to run at the same time, so a
1110
1217
  // reply carrying several of them costs one round trip instead of N — a model
1111
1218
  // that wants four files currently spends four full turns (and four payments)
@@ -1487,7 +1594,19 @@ function nameAddressedToSend(reply, originId) {
1487
1594
  }).join('\n');
1488
1595
  }
1489
1596
 
1490
- async function tryDirective(reply, originId) {
1597
+ function emitLiveStatus(originId, onEvent, detail) {
1598
+ if (!detail) return;
1599
+ const t = threads.get(originId);
1600
+ if (t) {
1601
+ t.liveStatus = detail;
1602
+ t.lastDeltaAt = Date.now();
1603
+ }
1604
+ onEvent?.({ type: 'status', name: t?.name, color: t?.color, detail });
1605
+ }
1606
+
1607
+ async function tryDirective(reply, originId, onEvent) {
1608
+ const trail = peekDirectiveStatus(reply);
1609
+ if (trail) emitLiveStatus(originId, onEvent, trail);
1491
1610
  // Foreign envelope in, our directives out — before any matching runs, so
1492
1611
  // every branch below sees the shape it was written for.
1493
1612
  const translated = translateForeignToolCall(reply);
@@ -1512,7 +1631,7 @@ async function tryDirective(reply, originId) {
1512
1631
  const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
1513
1632
  if (batch.length > 1) {
1514
1633
  const results = await Promise.all(
1515
- batch.map((m) => tryDirective(m[0].replace(/^[ \t>*-]*/, ''), originId)
1634
+ batch.map((m) => tryDirective(m[0].replace(/^[ \t>*-]*/, ''), originId, onEvent)
1516
1635
  .catch((e) => `${m[1]}: ${e.message}`)),
1517
1636
  );
1518
1637
  return results.filter(Boolean).join('\n\n');
@@ -1613,7 +1732,7 @@ async function tryDirective(reply, originId) {
1613
1732
  const sendAll = directiveLines(reply, 'SEND');
1614
1733
  if (sendAll.length > 1) {
1615
1734
  const out = [];
1616
- for (const line of sendAll) out.push(await tryDirective('SEND: ' + line, originId));
1735
+ for (const line of sendAll) out.push(await tryDirective('SEND: ' + line, originId, onEvent));
1617
1736
  return out.filter(Boolean).join('\n');
1618
1737
  }
1619
1738
  const sendM = sendAll.length === 1 ? /^([^|]+)\|([\s\S]+)/.exec(sendAll[0]) : null;
@@ -1642,7 +1761,7 @@ async function tryDirective(reply, originId) {
1642
1761
  const pingAll = directiveLines(reply, 'PING');
1643
1762
  if (pingAll.length > 1) {
1644
1763
  const out = [];
1645
- for (const line of pingAll) out.push(await tryDirective('PING: ' + line, originId));
1764
+ for (const line of pingAll) out.push(await tryDirective('PING: ' + line, originId, onEvent));
1646
1765
  return out.filter(Boolean).join('\n');
1647
1766
  }
1648
1767
  const ping = pingAll.length === 1 ? [null, pingAll[0]] : null;
@@ -1687,7 +1806,7 @@ async function tryDirective(reply, originId) {
1687
1806
  const full = safeResolveIn(dirFor(originId), rel);
1688
1807
  mkdirSync(path.dirname(full), { recursive: true });
1689
1808
  writeFileSync(full, content);
1690
- return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.`;
1809
+ return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.${await previewAck(originId, rel)}`;
1691
1810
  } catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
1692
1811
  }
1693
1812
  const readD = /^[ \t>*-]*READ:\s*(.+)/m.exec(reply);
@@ -1713,7 +1832,7 @@ async function tryDirective(reply, originId) {
1713
1832
  if (hits === 0) return `EDIT ${rel}: that exact text isn't in the file — READ it first, the copy must match byte for byte.`;
1714
1833
  if (hits > 1) return `EDIT ${rel}: that text appears ${hits} times — include more surrounding context so it matches exactly once.`;
1715
1834
  writeFileSync(full, before.replace(oldStr, newStr));
1716
- return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes).`;
1835
+ return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes).${await previewAck(originId, rel)}`;
1717
1836
  } catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
1718
1837
  }
1719
1838
 
@@ -1739,7 +1858,7 @@ async function tryDirective(reply, originId) {
1739
1858
  applied.push(o.slice(0, 40));
1740
1859
  }
1741
1860
  writeFileSync(full, next);
1742
- return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes).`;
1861
+ return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes).${await previewAck(originId, rel)}`;
1743
1862
  } catch (e) { return `Couldn't multiedit ${rel}: ${e.message}`; }
1744
1863
  }
1745
1864
 
@@ -1888,8 +2007,11 @@ async function tryDirective(reply, originId) {
1888
2007
  const serve = /^[ \t>*-]*SERVE:\s*(.*)$/m.exec(reply);
1889
2008
  if (serve) {
1890
2009
  const rel = serve[1].trim();
1891
- if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
1892
- return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
2010
+ const port = await ensureWorkspacePort();
2011
+ if (!port) {
2012
+ return `Serving ${rel || 'index.html'} from ${dirFor(originId)} — waiting for the workspace port to bind.`;
2013
+ }
2014
+ return `Serving at http://localhost:${port}/${originId}/${rel}`;
1893
2015
  }
1894
2016
  const fetchD = /^[ \t>*-]*FETCH:\s*(\S+)/m.exec(reply);
1895
2017
  if (fetchD) {
@@ -2026,59 +2148,78 @@ async function mcpDirective(url, tool, args) {
2026
2148
  }
2027
2149
 
2028
2150
  // onEvent (optional) gets live progress for whoever's actually watching this
2029
- // call: {type:'start',name,color} when a bot begins its turn, {type:'delta',
2030
- // name,color,delta} per streamed token, {type:'final',name,color,text} once
2031
- // its full reply (or directive ack) is settled. Background turns — a SPAWNed
2151
+ // call: {type:'start',name,color} when a bot begins its turn, {type:'status',
2152
+ // detail} while paying / waiting / walking tools, {type:'delta',name,color,
2153
+ // delta} per streamed token, {type:'final',name,color,text} once its full
2154
+ // reply (or directive ack) is settled. Background turns — a SPAWNed
2032
2155
  // subagent nobody's looking at yet — run with onEvent omitted and just use
2033
2156
  // the plain non-streaming brain(), which is cheaper when nothing renders it.
2034
2157
  async function runTurn(threadId, userText, onEvent, images) {
2035
2158
  const t = threads.get(threadId);
2036
2159
  if (!t) return;
2160
+ // A new user prompt must not sit behind a dead auto-continue. Bump the
2161
+ // generation so the previous runTurn's awaits bail instead of keeping
2162
+ // status=thinking and the UI on "…".
2163
+ if (!isHarnessUserText(userText)) t.turnSeq = (t.turnSeq || 0) + 1;
2164
+ const seq = t.turnSeq || 0;
2165
+ const stillMine = () => threads.get(threadId)?.turnSeq === seq;
2166
+ const paint = (ev) => {
2167
+ if (!stillMine()) return;
2168
+ if (ev.type === 'status' && ev.detail) t.liveStatus = ev.detail;
2169
+ if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start') t.lastDeltaAt = Date.now();
2170
+ onEvent?.(ev);
2171
+ };
2037
2172
  t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
2038
2173
  t.lastActivityAt = Date.now();
2174
+ t.status = 'thinking';
2175
+ t.thinkingAt = Date.now();
2176
+ t.lastDeltaAt = Date.now();
2177
+ t.liveStatus = 'waiting on model…';
2178
+ let chained = false;
2179
+ let parked = false;
2180
+ try {
2039
2181
  if (t.members) {
2040
- t.status = 'thinking';
2041
2182
  // sequential, not parallel: each member's context is rebuilt from
2042
2183
  // t.history right before its turn, so it sees every reply (including
2043
2184
  // spawns/sends) the earlier members in THIS round already made
2044
2185
  for (const m of t.members) {
2186
+ if (!stillMine()) return;
2045
2187
  const msgs = buildMemberMessages(t, m);
2046
2188
  let r = '';
2047
- onEvent?.({ type: 'start', name: m.name, color: m.color });
2189
+ paint({ type: 'start', name: m.name, color: m.color, detail: 'waiting on model…' });
2190
+ const emitStatus = (detail) => paint({ type: 'status', name: m.name, color: m.color, detail });
2048
2191
  try {
2049
2192
  r = onEvent
2050
- ? (await brainStream(msgs, (delta) => onEvent({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId)).trim()
2193
+ ? (await brainStream(msgs, (delta) => paint({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId, undefined, undefined, 0, 0, emitStatus)).trim()
2051
2194
  : (await brain(msgs, t.contextId)).trim();
2052
2195
  } catch (e) { r = `error: ${e.message}`; }
2196
+ if (!stillMine()) return;
2053
2197
  const runCmd = parseRun(r);
2054
2198
  if (runCmd) {
2055
2199
  const command = runCmd;
2056
2200
  if (t.runMode === 'auto') {
2201
+ emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
2057
2202
  const output = await execCommand(command, dirFor(t.id));
2058
2203
  const shown = `$ ${command}\n${output}`;
2059
2204
  t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
2060
- onEvent?.({ type: 'final', name: m.name, color: m.color, text: shown });
2205
+ paint({ type: 'final', name: m.name, color: m.color, text: shown });
2061
2206
  // this member's turn is done; the round continues to the next member
2062
2207
  continue;
2063
2208
  }
2064
2209
  const runId = randomUUID();
2065
2210
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
2066
2211
  t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
2067
- onEvent?.({ type: 'run-pending', runId, command, name: m.name, color: m.color });
2212
+ paint({ type: 'run-pending', runId, command, name: m.name, color: m.color });
2068
2213
  // pauses the WHOLE round here — the rest of the group gets their turn
2069
2214
  // on the round that runs after the user approves/denies
2070
- t.status = 'idle';
2071
- t.lastActivityAt = Date.now();
2072
- saveThreads();
2215
+ parked = true;
2073
2216
  return;
2074
2217
  }
2075
- const ack = await tryDirective(r, t.id);
2218
+ const ack = await tryDirective(r, t.id, paint);
2076
2219
  const finalText = ack ?? (r || '(no response)');
2077
2220
  t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
2078
- onEvent?.({ type: 'final', name: m.name, color: m.color, text: finalText });
2221
+ paint({ type: 'final', name: m.name, color: m.color, text: finalText });
2079
2222
  }
2080
- t.status = 'idle';
2081
- saveThreads();
2082
2223
  bindThread(t).catch(() => {});
2083
2224
  return;
2084
2225
  }
@@ -2092,9 +2233,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2092
2233
  delete t.autoNudged;
2093
2234
  }
2094
2235
  t.messages.push({ role: 'user', content: contentFor(userText, images) });
2095
- t.status = 'thinking';
2096
2236
  let reply = '';
2097
- onEvent?.({ type: 'start', name: t.name, color: t.color });
2237
+ paint({ type: 'start', name: t.name, color: t.color, detail: 'waiting on model…' });
2098
2238
  // Transient: the nudge is appended for THIS call only and never pushed into
2099
2239
  // t.messages, so it can't accumulate across a chained auto run or get bound
2100
2240
  // into the thread's context.
@@ -2119,7 +2259,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2119
2259
  // `attempt` exists because a retry must be allowed to land somewhere else:
2120
2260
  // see the empty-completion loop below.
2121
2261
  const ask = async (attempt = 0) => {
2122
- const emit = (delta) => onEvent && onEvent({ type: 'delta', name: t.name, color: t.color, delta });
2262
+ const emit = (delta) => paint({ type: 'delta', name: t.name, color: t.color, delta });
2263
+ const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
2123
2264
  // Retrieval breadth scales with the PROJECT's corpus, not this thread's —
2124
2265
  // the holobrain is shared at the root, so that is the pool being searched.
2125
2266
  const topK = adaptiveTopK((threads.get(rootOf(t).rootId) || t).boundItems);
@@ -2131,16 +2272,18 @@ async function runTurn(threadId, userText, onEvent, images) {
2131
2272
  // the middle (2 of 3) is a judged answer without the slowest entrant
2132
2273
  // setting the latency.
2133
2274
  const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
2275
+ emitStatus('waiting on model…');
2134
2276
  return (await brainRace(callMsgs, emit, t.contextId, models, need)).trim();
2135
2277
  }
2136
2278
  // A retry draws a DIFFERENT model from the tier rather than the same one.
2137
2279
  const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
2138
2280
  return (onEvent
2139
- ? (await brainStream(callMsgs, emit, t.contextId, model)).trim()
2281
+ ? (await brainStream(callMsgs, emit, t.contextId, model, undefined, 0, topK, emitStatus)).trim()
2140
2282
  : (await brain(callMsgs, t.contextId, model, topK)).trim());
2141
2283
  };
2142
2284
  try {
2143
2285
  reply = await ask();
2286
+ if (!stillMine()) return;
2144
2287
  // An EMPTY completion is transient far more often than it is meaningful —
2145
2288
  // it showed up repeatedly as a dead "(no response)" bubble that cost the
2146
2289
  // user a turn and told them nothing. Retry once before giving up, and if
@@ -2157,7 +2300,9 @@ async function runTurn(threadId, userText, onEvent, images) {
2157
2300
  // fix. A thread pinned with /model stays pinned; that was an explicit
2158
2301
  // choice and silently answering as something else would be worse.
2159
2302
  for (let i = 0; !reply && i < AUTO_EMPTY_RETRIES; i++) {
2303
+ paint({ type: 'status', name: t.name, color: t.color, detail: 'retrying…' });
2160
2304
  await new Promise((r) => setTimeout(r, 400 * (i + 1)));
2305
+ if (!stillMine()) return;
2161
2306
  reply = await ask(t.model ? 0 : i + 1);
2162
2307
  }
2163
2308
  if (!reply) {
@@ -2168,15 +2313,18 @@ async function runTurn(threadId, userText, onEvent, images) {
2168
2313
  } catch (e) {
2169
2314
  reply = `error: ${e.message}`;
2170
2315
  }
2316
+ if (!stillMine()) return;
2171
2317
  t.messages.push({ role: 'assistant', content: reply });
2172
2318
  const runCmd = parseRun(reply);
2173
2319
  if (runCmd) {
2174
2320
  const command = runCmd;
2175
2321
  if (t.runMode === 'auto') {
2322
+ emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
2176
2323
  const output = await execCommand(command, dirFor(t.id));
2324
+ if (!stillMine()) return;
2177
2325
  const shown = `$ ${command}\n${output}`;
2178
2326
  t.history.push({ who: 'bot', text: shown });
2179
- onEvent?.({ type: 'final', name: t.name, color: t.color, text: shown });
2327
+ paint({ type: 'final', name: t.name, color: t.color, text: shown });
2180
2328
  // FEED THE OUTPUT BACK. The 'ask' path already does this on approve, so
2181
2329
  // auto mode was strictly LESS capable than the gated one: the command
2182
2330
  // ran, the result was shown, and the model never saw it — no diagnosis,
@@ -2186,9 +2334,6 @@ async function runTurn(threadId, userText, onEvent, images) {
2186
2334
  // AUTO_MAX_STEPS chained commands per user message, reset whenever the
2187
2335
  // user speaks again.
2188
2336
  t.autoSteps = (t.autoSteps || 0) + 1;
2189
- t.status = 'idle';
2190
- t.lastActivityAt = Date.now();
2191
- saveThreads();
2192
2337
  if (t.autoSteps < AUTO_MAX_STEPS) {
2193
2338
  // BIND BEFORE CHAINING. bindThread only ran at the end of a normal
2194
2339
  // turn, and both auto paths return before reaching it — so in auto
@@ -2196,12 +2341,14 @@ async function runTurn(threadId, userText, onEvent, images) {
2196
2341
  // most material (command output, GLOB results, MCP tool lists). The
2197
2342
  // holographic context stopped growing precisely when it mattered.
2198
2343
  bindThread(t).catch(() => {});
2344
+ chained = true;
2199
2345
  runTurn(threadId, condense('(command output)', output), onEvent).catch(() => {});
2200
2346
  } else {
2201
2347
  // Do not park. A "say continue" note is the failure mode auto exists
2202
2348
  // to avoid — inject continue and keep going until DONE or ask.
2203
2349
  t.autoSteps = 0;
2204
2350
  bindThread(t).catch(() => {});
2351
+ chained = true;
2205
2352
  runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
2206
2353
  }
2207
2354
  return;
@@ -2210,20 +2357,16 @@ async function runTurn(threadId, userText, onEvent, images) {
2210
2357
  const runId = randomUUID();
2211
2358
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
2212
2359
  t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
2213
- onEvent?.({ type: 'run-pending', runId, command, name: t.name, color: t.color });
2360
+ paint({ type: 'run-pending', runId, command, name: t.name, color: t.color });
2214
2361
  }
2215
- t.status = 'idle';
2216
- t.lastActivityAt = Date.now();
2217
- saveThreads();
2362
+ parked = true;
2218
2363
  return;
2219
2364
  }
2220
- const ack = await tryDirective(reply, t.id);
2365
+ const ack = await tryDirective(reply, t.id, paint);
2366
+ if (!stillMine()) return;
2221
2367
  const finalText = ack ?? (reply || '(no response)');
2222
2368
  t.history.push({ who: 'bot', text: finalText });
2223
- onEvent?.({ type: 'final', name: t.name, color: t.color, text: finalText });
2224
- t.status = 'idle';
2225
- t.lastActivityAt = Date.now();
2226
- saveThreads();
2369
+ paint({ type: 'final', name: t.name, color: t.color, text: finalText });
2227
2370
 
2228
2371
  // AUTO CONTINUES AFTER *ANY* DIRECTIVE, not just RUN.
2229
2372
  //
@@ -2240,6 +2383,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2240
2383
  && !/^[ \t>*-]*DONE:/m.test(reply)) {
2241
2384
  t.autoSteps = (t.autoSteps || 0) + 1;
2242
2385
  bindThread(t).catch(() => {}); // bind every hop, not just the last one
2386
+ chained = true;
2243
2387
  if (t.autoSteps < AUTO_MAX_STEPS) {
2244
2388
  runTurn(threadId, condense('(directive result)', ack), onEvent).catch(() => {});
2245
2389
  } else {
@@ -2273,8 +2417,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2273
2417
  // another continue rather than stopping.
2274
2418
  t.autoNudged = true;
2275
2419
  t.autoSteps = (t.autoSteps || 0) + 1;
2276
- saveThreads();
2277
2420
  bindThread(t).catch(() => {});
2421
+ chained = true;
2278
2422
  if (t.autoSteps < AUTO_MAX_STEPS) {
2279
2423
  runTurn(threadId, NUDGE, onEvent).catch(() => {});
2280
2424
  } else {
@@ -2284,6 +2428,17 @@ async function runTurn(threadId, userText, onEvent, images) {
2284
2428
  return;
2285
2429
  }
2286
2430
  bindThread(t).catch(() => {});
2431
+ } finally {
2432
+ // A hung or thrown brainStream used to leave t.status = 'thinking' and the
2433
+ // UI on mute "…" forever. Always idle unless this hop chained or parked
2434
+ // on an approval — the next runTurn sets thinking again itself.
2435
+ if (stillMine() && !chained && !parked && !t.pendingRun) {
2436
+ t.status = 'idle';
2437
+ t.liveStatus = '';
2438
+ }
2439
+ t.lastActivityAt = Date.now();
2440
+ saveThreads();
2441
+ }
2287
2442
  }
2288
2443
 
2289
2444
  // Said it would, without a directive line. "Spawned X" and "working on it" are
@@ -2418,6 +2573,7 @@ function threadSummary(t) {
2418
2573
  // an approval nobody knew it wanted. The blue dot means "working"; this
2419
2574
  // means "your move".
2420
2575
  awaitingUser: Boolean(t.pendingRun),
2576
+ liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
2421
2577
  rootId: rootOf(t).rootId, depth: rootOf(t).depth,
2422
2578
  rootName: (threads.get(rootOf(t).rootId) || t).name,
2423
2579
  // The spend dial, so the header can show it without a round trip per
@@ -2425,7 +2581,8 @@ function threadSummary(t) {
2425
2581
  tier: t.tier || 'medium', race: Number(t.race) || 0, raceNeed: Number(t.raceNeed) || 1, model: t.model || '',
2426
2582
  // How many bots sit BELOW this one. The ping-all affordance belongs on
2427
2583
  // anyone with a crew, not only on a project root.
2428
- kids: subtreeOf(t.id).length };
2584
+ kids: subtreeOf(t.id).length,
2585
+ workspacePort: workspacePort || 0 };
2429
2586
  }
2430
2587
 
2431
2588
  const APP_HTML = `<!doctype html>
@@ -2557,6 +2714,10 @@ const APP_HTML = `<!doctype html>
2557
2714
  .wrow .wcopy { flex: 0 0 auto; color: #6f7080; font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
2558
2715
  user-select: none; }
2559
2716
  .wrow:hover .wcopy { color: #b8f240; }
2717
+ .wcredit { border: 1px solid #2a3a18; background: rgba(184,242,64,.08); border-radius: 12px;
2718
+ padding: 12px 14px; margin-bottom: 12px; }
2719
+ .wcredit .wbig { color: #b8f240; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; }
2720
+ .wcredit .wlab2 { color: #8e8e93; font-size: 11px; margin-top: 2px; }
2560
2721
  .wbal { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
2561
2722
  font-size: 12px; color: #ececec; line-height: 1.7; word-break: break-word; }
2562
2723
  .wnote { color: #6f7080; font-size: 11px; line-height: 1.6; margin-top: 12px; }
@@ -2579,11 +2740,14 @@ const APP_HTML = `<!doctype html>
2579
2740
  inside the dials (race / wallet / ◎) once they take a second row. top is
2580
2741
  set from the header's live bottom in placeHud(). z-index stays high; the
2581
2742
  bug was geometry, not stacking. */
2582
- #hud { position: absolute; right: 14px; width: 250px; background: rgba(14,14,17,.94);
2743
+ #hud { position: absolute; right: 14px; width: 270px; background: rgba(14,14,17,.94);
2583
2744
  border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
2584
2745
  display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
2585
2746
  #hud.show { display: block; }
2586
2747
  #hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
2748
+ #hud .htitle.hsession { margin-top: 12px; padding-top: 10px; border-top: 1px solid #333340; color: #6f7080; }
2749
+ #hud .hcredit { color: #b8f240; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.15; }
2750
+ #hud .hcreditlab { color: #999aa8; font-size: 10px; margin: 2px 0 8px; }
2587
2751
  #hud .hrow { display: flex; justify-content: space-between; margin: 6px 0; color: #f0f0eb; font-size: 12px; }
2588
2752
  #hud .hrow span:first-child { color: #999aa8; font-size: 10.5px; }
2589
2753
  #hud .hlime { color: #b8f240; }
@@ -2621,7 +2785,13 @@ const APP_HTML = `<!doctype html>
2621
2785
  /* rendered markdown. The bubble is pre-wrap for plain text, but block
2622
2786
  elements carry their own spacing — leaving pre-wrap on would add the
2623
2787
  source newlines back on top of it and double every gap. */
2624
- .bubble:has(> p, > .md-h, > .md-table, > .md-list, > .md-pre) { white-space: normal; }
2788
+ .bubble:has(> p, > .md-h, > .md-table, > .md-list, > .md-pre, > .html-preview-wrap) { white-space: normal; }
2789
+ .row:has(.html-preview) { max-width: 92%; }
2790
+ .html-preview-wrap { margin-top: 10px; }
2791
+ .html-preview-open { display: inline-block; margin-bottom: 6px; font-size: 12px; color: #6ab0ff;
2792
+ text-decoration: underline; cursor: pointer; }
2793
+ .html-preview { display: block; width: 100%; height: 420px; border: 1px solid #3a3a3c;
2794
+ border-radius: 12px; background: #111; }
2625
2795
  .bubble > p { margin: 0 0 10px; }
2626
2796
  .bubble > p:last-child { margin-bottom: 0; }
2627
2797
  .md-h { margin: 14px 0 8px; font-size: 15px; font-weight: 600; line-height: 1.3; }
@@ -2690,6 +2860,8 @@ const APP_HTML = `<!doctype html>
2690
2860
  background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
2691
2861
  .dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
2692
2862
  @keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
2863
+ .tstatus { color: #8e8e93; font-size: 13px; margin-left: 6px; }
2864
+ .ttrail { display: block; color: #8e8e93; font-size: 12.5px; margin-top: 8px; }
2693
2865
  #bar { padding: 10px 16px 18px; position: relative; }
2694
2866
  #row-input { display: flex; align-items: center; gap: 8px; }
2695
2867
  #plusMenu { position: absolute; bottom: 62px; left: 16px; background: #1c1c1e; border-radius: 14px;
@@ -2904,6 +3076,7 @@ const APP_HTML = `<!doctype html>
2904
3076
  : '';
2905
3077
  let activeId = null;
2906
3078
  let knownThreads = [];
3079
+ let workspacePort = 0;
2907
3080
 
2908
3081
  function initials(name) { return name.slice(0, 2).toUpperCase(); }
2909
3082
 
@@ -2937,6 +3110,7 @@ const APP_HTML = `<!doctype html>
2937
3110
  // seeing WHY something matched, not just that it did.
2938
3111
  const hitById = searchHits ? new Map(searchHits.map((h) => [h.id, h])) : null;
2939
3112
  knownThreads = list;
3113
+ if (list[0] && list[0].workspacePort) workspacePort = Number(list[0].workspacePort) || workspacePort;
2940
3114
  if (!activeId && list.length) activeId = list[0].id;
2941
3115
  threadsEl.innerHTML = '';
2942
3116
  const shown = hitById
@@ -2983,7 +3157,7 @@ const APP_HTML = `<!doctype html>
2983
3157
  '<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
2984
3158
  (hitById && hitById.get(t.id) && hitById.get(t.id).snippet
2985
3159
  ? hitById.get(t.id).snippet
2986
- : t.awaitingUser ? 'waiting for you' : t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
3160
+ : t.awaitingUser ? 'waiting for you' : t.status === 'thinking' ? escapeHtml(t.liveStatus || 'typing…') : (t.preview || '')) + '</div></div>' +
2987
3161
  // awaitingUser WINS over thinking: a thread blocked on an approval is
2988
3162
  // NOT working, and showing a working indicator there is a lie that
2989
3163
  // quietly costs you a subagent nobody knows is stuck.
@@ -3039,7 +3213,9 @@ const APP_HTML = `<!doctype html>
3039
3213
 
3040
3214
  async function loadActiveMessages() {
3041
3215
  if (!activeId) return null;
3042
- return await (await fetch(API + '/threads/' + activeId)).json();
3216
+ const data = await (await fetch(API + '/threads/' + activeId)).json();
3217
+ if (data && data.workspacePort) workspacePort = Number(data.workspacePort) || workspacePort;
3218
+ return data;
3043
3219
  }
3044
3220
 
3045
3221
  function renderHeader(t) {
@@ -3126,7 +3302,7 @@ const APP_HTML = `<!doctype html>
3126
3302
  w = r.ok ? await r.json() : null;
3127
3303
  } catch (e) { w = null; }
3128
3304
  walletBody.innerHTML = '';
3129
- if (!w || (!w.solana && !w.evm)) {
3305
+ if (!w || (!w.solana && !w.evm && w.creditUsd == null)) {
3130
3306
  const p = document.createElement('div');
3131
3307
  p.className = 'wnote wempty';
3132
3308
  p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds.';
@@ -3144,7 +3320,17 @@ const APP_HTML = `<!doctype html>
3144
3320
  }
3145
3321
  if (w.solana) walletBody.appendChild(walletRow('Solana', w.solana));
3146
3322
  if (w.evm) walletBody.appendChild(walletRow('Base / RH', w.evm));
3147
- if (w.balances) {
3323
+ if (Array.isArray(w.holdings) && w.holdings.length) {
3324
+ const b = document.createElement('div');
3325
+ b.className = 'wbal';
3326
+ b.textContent = w.holdings.filter((h) => h.chain === 'solana' || Number(h.ui) > 0).map((h) => {
3327
+ const qty = (h.ui) + ' ' + h.symbol + (h.chain && h.chain !== 'solana' ? ' (' + h.chain + ')' : '');
3328
+ if (h.usd == null || !isFinite(Number(h.usd))) return qty;
3329
+ const n = Number(h.usd);
3330
+ return qty + ' ($' + (n >= 0.01 || n === 0 ? n.toFixed(2) : n.toFixed(4)) + ')';
3331
+ }).join('\\n');
3332
+ walletBody.appendChild(b);
3333
+ } else if (w.balances) {
3148
3334
  const b = document.createElement('div');
3149
3335
  b.className = 'wbal';
3150
3336
  b.textContent = w.balances;
@@ -3217,6 +3403,114 @@ const APP_HTML = `<!doctype html>
3217
3403
  document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
3218
3404
 
3219
3405
  function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
3406
+ function clientWorkspaceUrl(rel) {
3407
+ if (!workspacePort || !activeId) return '';
3408
+ rel = String(rel || '').replace(/^\\/+/, '');
3409
+ return 'http://localhost:' + workspacePort + '/' + activeId + '/' + rel;
3410
+ }
3411
+ function relFromDiskPath(p) {
3412
+ p = String(p || '');
3413
+ if (p.indexOf('file://') === 0) p = decodeURIComponent(p.slice(7));
3414
+ const t = knownThreads.find(function (x) { return x.id === activeId; });
3415
+ const dir = (t && t.dir) || '';
3416
+ if (dir && (p === dir || p.indexOf(dir + '/') === 0)) {
3417
+ return p.slice(dir.length).replace(/^\\/+/, '');
3418
+ }
3419
+ const marker = '/grokui-workspace/';
3420
+ const i = p.indexOf(marker);
3421
+ if (i >= 0) return p.slice(i + marker.length);
3422
+ const base = p.split('/').pop() || '';
3423
+ return /\\.(html?|HTML?)$/.test(base) ? base : '';
3424
+ }
3425
+ function servedHrefForPath(p) {
3426
+ p = String(p || '');
3427
+ const t = knownThreads.find(function (x) { return x.id === activeId; });
3428
+ const dir = (t && t.dir) || '';
3429
+ if (dir && p === dir) return clientWorkspaceUrl('');
3430
+ if (/\\.(html?|HTML?)$/.test(p) || p.indexOf('grokui-workspace') >= 0 || (dir && p.indexOf(dir) === 0)) {
3431
+ return clientWorkspaceUrl(relFromDiskPath(p));
3432
+ }
3433
+ return '';
3434
+ }
3435
+ // Turn "Wrote foo.html" and /Users/.../foo.html into the live localhost URL
3436
+ // the workspace server already exposes — never a file:// Electron blocks.
3437
+ function linkWorkspacePaths(o) {
3438
+ o = o.replace(/\\b(Wrote|Edited)\\s+([^\\n<]+?)\\s+\\(/g, function (m, verb, file) {
3439
+ const f = file.trim();
3440
+ if (!/\\.(html?|HTML?)$/.test(f)) return m;
3441
+ const url = clientWorkspaceUrl(f);
3442
+ if (!url) return m;
3443
+ return verb + ' <a href="' + url + '" target="_blank" rel="noopener">' + f + '</a> (';
3444
+ });
3445
+ o = o.replace(/\\b(MULTIEDIT)\\s+([^\\s:<]+\\.(?:html?|HTML?))/g, function (m, verb, file) {
3446
+ const url = clientWorkspaceUrl(file);
3447
+ if (!url) return m;
3448
+ return verb + ' <a href="' + url + '" target="_blank" rel="noopener">' + file + '</a>';
3449
+ });
3450
+ o = o.replace(/file:\\/\\/([^\\s<)]+)/g, function (m, raw) {
3451
+ return servedHrefForPath(decodeURIComponent(raw)) || m;
3452
+ });
3453
+ o = o.replace(/(^|[\\s(])((?:\\/Users\\/|\\/home\\/|\\/opt\\/|\\/workspace\\/|\\/tmp\\/|\\/var\\/|~\\/)[^\\s<)]+)/g, function (m, pre, raw) {
3454
+ const punct = /[.,;:]+$/.exec(raw);
3455
+ const p = punct ? raw.slice(0, -punct[0].length) : raw;
3456
+ const href = servedHrefForPath(p);
3457
+ if (!href) return m;
3458
+ return pre + '<a href="' + href + '" target="_blank" rel="noopener">' + p + '</a>' + (punct ? punct[0] : '');
3459
+ });
3460
+ return o;
3461
+ }
3462
+ function htmlPreviewUrl(text) {
3463
+ const s = String(text || '');
3464
+ let m = /https?:\\/\\/localhost:\\d+\\/\\S+\\.(?:html?|HTML?)/.exec(s);
3465
+ if (m) return m[0].replace(/[.,;)]+$/, '');
3466
+ m = /(?:Preview:|Serving at)\\s+(https?:\\/\\/localhost:\\d+\\/\\S+)/.exec(s);
3467
+ if (m) {
3468
+ const u = m[1].replace(/[.,;)]+$/, '');
3469
+ if (/\\.(html?|HTML?)/.test(u) || /\\/[0-9a-fA-F-]{36}\\/?$/.test(u)) return u;
3470
+ }
3471
+ m = /\\b(?:Wrote|Edited|MULTIEDIT)\\s+([^\\n]+?)\\s*(?:\\(|:)/.exec(s);
3472
+ if (m && /\\.(html?|HTML?)$/.test(m[1].trim())) return clientWorkspaceUrl(m[1].trim());
3473
+ m = /(?:\\/Users\\/|\\/home\\/|\\/workspace\\/)\\S+\\.(?:html?|HTML?)/.exec(s);
3474
+ if (m) return servedHrefForPath(m[0].replace(/[.,;)]+$/, '')) || '';
3475
+ return '';
3476
+ }
3477
+ function htmlPreviewKey(text, url) {
3478
+ const bytes = /\\((\\d+)\\s+bytes/.exec(text) || /->\\s+(\\d+)\\s+bytes/.exec(text);
3479
+ return url + '#' + (bytes ? bytes[1] : '0');
3480
+ }
3481
+ let parkedPreviews = {};
3482
+ function parkPreviews() {
3483
+ const parked = {};
3484
+ const nodes = log.querySelectorAll('.html-preview-wrap');
3485
+ for (let i = 0; i < nodes.length; i++) {
3486
+ const el = nodes[i];
3487
+ const k = el.getAttribute('data-preview');
3488
+ if (k) parked[k] = el;
3489
+ el.remove();
3490
+ }
3491
+ return parked;
3492
+ }
3493
+ function previewFrame(url, key) {
3494
+ const existing = parkedPreviews[key];
3495
+ if (existing) { delete parkedPreviews[key]; return existing; }
3496
+ const wrap = document.createElement('div');
3497
+ wrap.className = 'html-preview-wrap';
3498
+ wrap.setAttribute('data-preview', key);
3499
+ const open = document.createElement('a');
3500
+ open.className = 'html-preview-open';
3501
+ open.href = url;
3502
+ open.target = '_blank';
3503
+ open.rel = 'noopener';
3504
+ open.textContent = 'open';
3505
+ const frame = document.createElement('iframe');
3506
+ frame.className = 'html-preview';
3507
+ frame.src = url;
3508
+ frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-pointer-lock allow-forms');
3509
+ frame.title = 'preview';
3510
+ wrap.appendChild(open);
3511
+ wrap.appendChild(frame);
3512
+ return wrap;
3513
+ }
3220
3514
  // Inline span-level markdown. Runs AFTER escapeHtml, so every tag below is
3221
3515
  // one we created — model output can never inject its own.
3222
3516
  function mdInline(s) {
@@ -3225,6 +3519,7 @@ const APP_HTML = `<!doctype html>
3225
3519
  o = o.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
3226
3520
  o = o.replace(/(^|[^*])\\*([^*\\n]+)\\*/g, '$1<em>$2</em>');
3227
3521
  o = o.replace(/\\[([^\\]]+)\\]\\((https?:[^)\\s]+)\\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
3522
+ o = linkWorkspacePaths(o);
3228
3523
  // bare URLs, but only at a boundary — inside href="..." the preceding
3229
3524
  // char is a quote, so links we just built are left alone
3230
3525
  o = o.replace(/(^|[\\s(])(https?:\\/\\/[^\\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
@@ -3562,6 +3857,10 @@ const APP_HTML = `<!doctype html>
3562
3857
  }
3563
3858
  const textEl = document.createElement('div');
3564
3859
  textEl.innerHTML = renderMentions(text);
3860
+ if (who === 'bot') {
3861
+ const preview = htmlPreviewUrl(text);
3862
+ if (preview) textEl.appendChild(previewFrame(preview, htmlPreviewKey(text, preview)));
3863
+ }
3565
3864
  bubble.appendChild(textEl);
3566
3865
  row.appendChild(bubble);
3567
3866
  // Copy the message SOURCE, not rendered HTML — markdown, code fences and
@@ -3582,6 +3881,7 @@ const APP_HTML = `<!doctype html>
3582
3881
  log.appendChild(row);
3583
3882
  }
3584
3883
 
3884
+ let lastRenderKey = '';
3585
3885
  async function render() {
3586
3886
  const t = knownThreads.find((x) => x.id === activeId);
3587
3887
  if (!t) return;
@@ -3589,9 +3889,18 @@ const APP_HTML = `<!doctype html>
3589
3889
  inp.placeholder = 'Message ' + t.name;
3590
3890
  const full = await loadActiveMessages();
3591
3891
  if (!full || full.id !== activeId) return;
3892
+ const renderKey = String(workspacePort) + '|' + full.id + '|' + full.status + '|' + (full.history || []).map(function (h) {
3893
+ return [h.who, h.text, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
3894
+ }).join('||');
3895
+ if (renderKey === lastRenderKey) {
3896
+ if (streamBuf) paintStream();
3897
+ return;
3898
+ }
3899
+ lastRenderKey = renderKey;
3592
3900
  // only re-pin to bottom if the reader was already there — otherwise a
3593
3901
  // background poll (tick() runs every 1.2s) yanks them back mid-scroll
3594
3902
  const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
3903
+ parkedPreviews = parkPreviews();
3595
3904
  log.innerHTML = '';
3596
3905
  lastSpeaker = null;
3597
3906
  for (const h of full.history) {
@@ -3599,11 +3908,12 @@ const APP_HTML = `<!doctype html>
3599
3908
  h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
3600
3909
  }
3601
3910
  if (full.status === 'thinking') {
3911
+ if (full.liveStatus) streamStatus = full.liveStatus;
3602
3912
  addRow('bot', streamBuf || '…', t.color, t.name);
3603
3913
  // Tag the live bubble so deltas can repaint just this node instead of
3604
3914
  // re-rendering (and re-fetching) the whole thread on every token.
3605
3915
  const b = log.querySelector('.row:last-child .bubble');
3606
- if (b) b.id = 'streamBubble';
3916
+ if (b) { b.id = 'streamBubble'; paintStream(); }
3607
3917
  }
3608
3918
  if (wasNearBottom) log.scrollTop = log.scrollHeight;
3609
3919
  }
@@ -3612,13 +3922,28 @@ const APP_HTML = `<!doctype html>
3612
3922
  // The server has always been able to stream; /drive just never asked for it,
3613
3923
  // so a turn showed "…" for its whole duration and then arrived in one lump.
3614
3924
  let streamBuf = '';
3925
+ let streamStatus = '';
3615
3926
  let es = null, esId = null;
3927
+ function liveBubbleHtml() {
3928
+ if (streamBuf) {
3929
+ const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
3930
+ ? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
3931
+ return escapeHtml(streamBuf) + trail;
3932
+ }
3933
+ const dots = '<span class="dots"><span></span><span></span><span></span></span>';
3934
+ const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
3935
+ return dots + (st ? ' ' + st : '');
3936
+ }
3616
3937
  function paintStream() {
3617
3938
  const b = document.getElementById('streamBubble');
3618
3939
  if (!b) { render(); return; }
3619
- // textContent, not markdown: the partial text is frequently mid-fence or
3620
- // mid-link, and half-parsed markdown flickers. The final render formats it.
3621
- b.textContent = streamBuf || '…';
3940
+ // Deltas stay as text; a silent wait paints dots + one mutating status
3941
+ // line so a 20–40s pay/model wait is obviously alive.
3942
+ if (streamBuf && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
3943
+ b.textContent = streamBuf;
3944
+ } else {
3945
+ b.innerHTML = liveBubbleHtml();
3946
+ }
3622
3947
  if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
3623
3948
  }
3624
3949
  function connectStream(id) {
@@ -3626,13 +3951,15 @@ const APP_HTML = `<!doctype html>
3626
3951
  if (es) es.close();
3627
3952
  esId = id;
3628
3953
  streamBuf = '';
3954
+ streamStatus = '';
3629
3955
  es = new EventSource('/stream/' + id); // EventSource reconnects on its own
3630
3956
  es.onmessage = (e) => {
3631
3957
  let ev;
3632
3958
  try { ev = JSON.parse(e.data); } catch { return; }
3633
- if (ev.type === 'start') { streamBuf = ''; paintStream(); }
3959
+ if (ev.type === 'start') { streamBuf = ''; streamStatus = ev.detail || 'waiting on model…'; paintStream(); }
3960
+ else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
3634
3961
  else if (ev.type === 'delta') { streamBuf += ev.delta || ''; paintStream(); }
3635
- else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; render(); }
3962
+ else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; streamStatus = ''; render(); }
3636
3963
  };
3637
3964
  es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
3638
3965
  }
@@ -4020,7 +4347,6 @@ const server = http.createServer((req, res) => {
4020
4347
  try { w = await (await fetch(`${PROXY}/wallet`)).json(); }
4021
4348
  catch { /* proxy not up yet — say so rather than render an empty modal */ }
4022
4349
  try {
4023
- const { creditBalance } = await import('./info.js');
4024
4350
  w.creditUsd = await creditBalance();
4025
4351
  } catch { /* leave credit off if the gateway is down */ }
4026
4352
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -4078,11 +4404,10 @@ const server = http.createServer((req, res) => {
4078
4404
 
4079
4405
  if (req.method === 'GET' && req.url === '/hud-summary') {
4080
4406
  (async () => {
4081
- let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null };
4407
+ let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
4082
4408
  try { you = { ...you, ...(await (await fetch('http://127.0.0.1:8402/v1/session')).json()) }; }
4083
4409
  catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
4084
4410
  try {
4085
- const { creditBalance } = await import('./info.js');
4086
4411
  you.creditUsd = await creditBalance();
4087
4412
  } catch { /* credit is advisory */ }
4088
4413
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -4165,7 +4490,11 @@ const server = http.createServer((req, res) => {
4165
4490
  if (req.method === 'GET' && req.url.startsWith('/threads/')) {
4166
4491
  const t = threads.get(req.url.split('/')[2]);
4167
4492
  res.writeHead(200, { 'content-type': 'application/json' });
4168
- res.end(t ? JSON.stringify({ id: t.id, history: t.history, status: t.status }) : '{}');
4493
+ res.end(t ? JSON.stringify({
4494
+ id: t.id, history: t.history, status: t.status,
4495
+ liveStatus: t.liveStatus || '',
4496
+ workspacePort: workspacePort || 0, dir: t.dir || WORKSPACE_DIR,
4497
+ }) : '{}');
4169
4498
  return;
4170
4499
  }
4171
4500
  if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
@@ -4298,3 +4627,5 @@ const server = http.createServer((req, res) => {
4298
4627
  });
4299
4628
 
4300
4629
  server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0' ? 'localhost' : BIND}:${PORT}`));
4630
+
4631
+ export { tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl };