openzoo 0.48.87 → 0.48.92

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,14 @@ 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';
17
+ import {
18
+ SUBSCRIPTIONS_PAGE,
19
+ saveSubscription, clearSubscription,
20
+ subscriptionPublicView, parseSubscriptionPaste,
21
+ billingTiers, billingCheckout, fetchBillingKey, ingestBillingKeyResponse,
22
+ } from './subscription.js';
15
23
 
16
24
  const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
17
25
  // BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
@@ -26,9 +34,12 @@ const STORE_FILE = path.join(STORE_DIR, 'grokui-threads.json');
26
34
  // Real but SANDBOXED filesystem access for the bots — each THREAD has its own
27
35
  // root dir (default: a dedicated workspace, never the user's whole disk), and
28
36
  // the user can point a thread at a real project folder with "/dir <path>" in
29
- // chat. safeResolveIn rejects any path that would escape that thread's root
30
- // (../, absolute paths, symlink tricks via normalize) access is real, but
31
- // always contained to whatever root was explicitly chosen for that thread.
37
+ // chat. inDir / safeResolveIn reject any path that would escape that thread's
38
+ // root (../, symlink tricks). An absolute path that is ALREADY inside the
39
+ // root is used as-is path.join(base, '/Users/...') doubles the prefix
40
+ // (MEASURED live: LIST of t.dir produced
41
+ // ENOENT scandir '/Users/…/Users/…/'). path.resolve treats an absolute
42
+ // second arg as a new root, which is the right join.
32
43
  // Where a thread's WRITE/READ/RUN/LS/GLOB/GREP are scoped by default.
33
44
  //
34
45
  // Overridable because a BOX puts uploaded files somewhere else: box-server
@@ -43,17 +54,74 @@ const WORKSPACE_DIR = process.env.OZ_WORKSPACE_DIR
43
54
  mkdirSync(WORKSPACE_DIR, { recursive: true });
44
55
  function expandHome(p) { return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p; }
45
56
  function dirFor(threadId) { return threads.get(threadId)?.dir || WORKSPACE_DIR; }
46
- function safeResolveIn(base, rel) {
47
- const full = path.normalize(path.join(base, rel));
48
- if (full !== base && !full.startsWith(base + path.sep)) {
57
+ /**
58
+ * Resolve `rel` inside `base`. If `rel` is already absolute, use it as-is
59
+ * when it stays inside `base` never path.join(base, '/Users/...').
60
+ */
61
+ function inDir(base, rel) {
62
+ const root = path.resolve(expandHome(String(base || '.')));
63
+ const raw = expandHome(String(rel ?? '').trim() || '.');
64
+ const full = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(root, raw);
65
+ if (full !== root && !full.startsWith(root + path.sep)) {
49
66
  throw new Error("path escapes this thread's directory");
50
67
  }
51
68
  return full;
52
69
  }
70
+ function safeResolveIn(base, rel) { return inDir(base, rel); }
71
+ function listDir(base, rel = '.') {
72
+ return readdirSync(inDir(base, rel), { withFileTypes: true });
73
+ }
74
+ /** If `spec` is an absolute path inside `base`, return the relative remainder
75
+ * ('' when it IS the base). Non-absolute specs are left alone (null). */
76
+ function stripBasePrefix(base, spec) {
77
+ const raw = expandHome(String(spec || '').trim());
78
+ if (!raw || !path.isAbsolute(raw)) return null;
79
+ const root = path.resolve(expandHome(String(base || '.')));
80
+ const full = path.resolve(raw);
81
+ if (full === root) return '';
82
+ if (full.startsWith(root + path.sep)) return full.slice(root.length + 1);
83
+ throw new Error("path escapes this thread's directory");
84
+ }
85
+
86
+ /**
87
+ * Reasoning models leak `<think>…</think>` / `<thinking>…` into the visible
88
+ * reply. MEASURED live on thread tetris: the user-visible bubble contained
89
+ * the raw tags, and the next turn sent them back to the model. Strip complete
90
+ * blocks, an unclosed opener (live stream), and stray closers.
91
+ */
92
+ function stripThinkTags(text) {
93
+ let s = String(text ?? '');
94
+ s = s.replace(/<think(?:ing)?\b[^>]*>[\s\S]*?<\/think(?:ing)?>/gi, '');
95
+ s = s.replace(/<think(?:ing)?\b[^>]*>[\s\S]*$/i, '');
96
+ s = s.replace(/<\/think(?:ing)?>/gi, '');
97
+ return s.replace(/^\n+|\n+$/g, '').trim();
98
+ }
53
99
  const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
54
100
  mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
55
101
  jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
56
102
  let workspacePort = null;
103
+ let workspacePortResolve = () => {};
104
+ let workspaceBinding = false;
105
+ const workspacePortReady = new Promise((resolve) => { workspacePortResolve = resolve; });
106
+ function bindWorkspaceServer() {
107
+ if (workspaceServer.listening) {
108
+ workspacePort = workspaceServer.address().port;
109
+ workspacePortResolve(workspacePort);
110
+ return;
111
+ }
112
+ if (workspaceBinding) return;
113
+ workspaceBinding = true;
114
+ try {
115
+ workspaceServer.listen(0, '127.0.0.1', () => {
116
+ workspacePort = workspaceServer.address().port;
117
+ workspacePortResolve(workspacePort);
118
+ });
119
+ } catch (err) {
120
+ workspaceBinding = false;
121
+ console.error('[grokui] workspace server:', err.message);
122
+ setTimeout(bindWorkspaceServer, 250);
123
+ }
124
+ }
57
125
  // route: /<threadId>/<relpath...> — each thread is served from ITS OWN dir
58
126
  const workspaceServer = http.createServer((req, res) => {
59
127
  try {
@@ -65,14 +133,65 @@ const workspaceServer = http.createServer((req, res) => {
65
133
  const full = safeResolveIn(dirFor(threadId), rel);
66
134
  const data = readFileSync(full);
67
135
  const ext = full.split('.').pop();
68
- res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' });
136
+ res.writeHead(200, {
137
+ 'content-type': MIME[ext] || 'application/octet-stream',
138
+ // EDIT of the same html must not be served from a cached first write.
139
+ 'cache-control': 'no-store',
140
+ });
69
141
  res.end(data);
70
142
  } catch {
71
143
  res.writeHead(404, { 'content-type': 'text/plain' });
72
144
  res.end('not found');
73
145
  }
74
146
  });
75
- workspaceServer.listen(0, '127.0.0.1', () => { workspacePort = workspaceServer.address().port; });
147
+ workspaceServer.on('error', (err) => {
148
+ workspaceBinding = false;
149
+ console.error('[grokui] workspace server:', err.message);
150
+ setTimeout(bindWorkspaceServer, 250);
151
+ });
152
+ bindWorkspaceServer();
153
+
154
+ function isPreviewableRel(rel) {
155
+ const base = path.basename(String(rel || '').split('?')[0]).toLowerCase();
156
+ return base.endsWith('.html') || base.endsWith('.htm');
157
+ }
158
+
159
+ async function ensureWorkspacePort(ms = 4000) {
160
+ if (workspacePort) return workspacePort;
161
+ if (!workspaceServer.listening) bindWorkspaceServer();
162
+ let timer;
163
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); });
164
+ const port = await Promise.race([workspacePortReady, timeout]);
165
+ clearTimeout(timer);
166
+ return port || workspacePort;
167
+ }
168
+
169
+ function workspaceFileUrl(originId, rel) {
170
+ const clean = String(rel || '').replace(/^\/+/, '');
171
+ return `http://localhost:${workspacePort}/${originId}/${clean}`;
172
+ }
173
+
174
+ // WRITE/EDIT of a playable page must ack a real http:// URL (same shape as
175
+ // SERVE), not a dead disk path. Wait for the static server rather than
176
+ // telling the user to try again after they just wrote a game.
177
+ async function previewAck(originId, rel) {
178
+ if (!isPreviewableRel(rel)) return '';
179
+ const port = await ensureWorkspacePort();
180
+ if (!port) {
181
+ return `\nPreview: the workspace server is binding; the page is ${rel} and will be at `
182
+ + `http://localhost/<port>/${originId}/${String(rel).replace(/^\/+/, '')}.`;
183
+ }
184
+ return `\nPreview: ${workspaceFileUrl(originId, rel)}`;
185
+ }
186
+
187
+ const HTML_PREVIEW_RULE = `
188
+ PREVIEW IS AUTOMATIC. After you WRITE or EDIT a .html / .htm file (including index.html),
189
+ the harness already served it — the WRITE ack includes a real http://localhost URL and the
190
+ chat bubble shows a live iframe. The harness will preview. Do not tell the user you
191
+ "can't preview", cannot open a browser, or dump a raw disk path (/Users/..., ~/.openzoo/...)
192
+ as the punchline. The page is already on screen. If you mention a location, use that
193
+ http://localhost link, never a filesystem path.
194
+ `;
76
195
 
77
196
  const PALETTE = ['#e91e8c', '#34c759', '#ff9500', '#5e5ce6', '#ff3b30', '#0a84ff', '#00c7be'];
78
197
  function colorFor(name) {
@@ -117,11 +236,15 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
117
236
  SERVE: <relative path, or blank for the dir root> get a real http:// URL for a file —
118
237
  use this instead of claiming you
119
238
  "can't expose a port": you can serve
120
- static files, just not run a process
239
+ static files, just not run a process.
240
+ HTML writes are auto-served — you do
241
+ not need a separate SERVE for a
242
+ playable page.
121
243
  FETCH: <url> actually fetch and read a page's real
122
244
  text — web search only gives you short
123
245
  snippets; use FETCH when asked to
124
246
  "read" or quote something specific
247
+ ${HTML_PREVIEW_RULE}
125
248
 
126
249
  HOW YOUR SITE ACTUALLY GETS A URL — read this before writing web files.
127
250
 
@@ -296,7 +419,26 @@ function loadThreads() {
296
419
  if (!existsSync(STORE_FILE)) return false;
297
420
  const arr = JSON.parse(readFileSync(STORE_FILE, 'utf8'));
298
421
  if (!Array.isArray(arr) || !arr.length) return false;
299
- for (const t of arr) threads.set(t.id, t);
422
+ for (const t of arr) {
423
+ // A crash mid-turn persisted status=thinking. Do not reload into mute "…".
424
+ if (t.status === 'thinking') {
425
+ t.status = 'idle';
426
+ t.liveStatus = '';
427
+ }
428
+ if (Array.isArray(t.history)) {
429
+ for (const h of t.history) {
430
+ if (h && h.who === 'bot' && typeof h.text === 'string') h.text = stripThinkTags(h.text);
431
+ }
432
+ }
433
+ if (Array.isArray(t.messages)) {
434
+ for (const m of t.messages) {
435
+ if (m && m.role === 'assistant' && typeof m.content === 'string') {
436
+ m.content = stripThinkTags(m.content);
437
+ }
438
+ }
439
+ }
440
+ threads.set(t.id, t);
441
+ }
300
442
  return true;
301
443
  } catch { return false; }
302
444
  }
@@ -367,9 +509,12 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
367
509
  READ: <relative path> read a file back
368
510
  SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
369
511
  this instead of saying you can't
370
- expose a port
512
+ expose a port. HTML writes are
513
+ auto-served; you do not need SERVE
514
+ just to preview a playable page.
371
515
  FETCH: <url> actually fetch and read a page's real
372
516
  text — web search only gives snippets
517
+ ${HTML_PREVIEW_RULE}
373
518
  RUN: <shell command> run a REAL shell command in this
374
519
  group's shared directory — pauses the
375
520
  WHOLE round for the user's approval
@@ -632,6 +777,21 @@ function sanitizeRunCommand(command) {
632
777
  //
633
778
  // Also tolerates the directive being wrapped in a markdown code fence, which
634
779
  // is the other shape models reach for unprompted.
780
+ const MCP_AS_BASH_REFUSE = 'That RUN: body is MCP tool names, not a shell command. '
781
+ + 'get_skill, proofnetwork-*, publish-update, and MCP: lines must not be executed by bash '
782
+ + '— that is how a live thread printed `/bin/bash: get_skill: command not found`. '
783
+ + 'Emit a real MCP call instead:\n'
784
+ + ' MCP: <url> | <tool> | {"arg": "value"}\n'
785
+ + 'or list tools with:\n'
786
+ + ' MCP: <url>';
787
+
788
+ /** Skill names / MCP: lines the model listed, then a RUN: tried to shell. */
789
+ function looksLikeMcpAsBash(command) {
790
+ const text = String(command || '');
791
+ if (/^[ \t>*-]*MCP:/m.test(text)) return true;
792
+ return /^(?:[ \t>*-]*)(?:get_skill|publish-update|proofnetwork[-_][A-Za-z0-9._-]*)\b/im.test(text);
793
+ }
794
+
635
795
  function parseRun(reply) {
636
796
  // NATIVE TOOL-CALL ENVELOPE FIRST. deepseek-v4-pro has real function calling,
637
797
  // and when told to emit "RUN: <cmd>" it frequently wraps the call in its own
@@ -658,10 +818,8 @@ function parseRun(reply) {
658
818
  // the whole envelope was dropped in silence: the bot then explained what it
659
819
  // was "about to run" forever, never running anything. Match the shape of the
660
820
  // envelope, not one vendor's spelling of it.
661
- const SEP = '[||\\s]*';
662
- const NAME = '(?:command|cmd|shell_command|script)';
663
- const dsml = new RegExp(`<${SEP}DSML[^>]*\\bparameter\\b[^>]*\\bname="${NAME}"[^>]*>([\\s\\S]*?)<\\/${SEP}DSML`, 'i').exec(reply);
664
- if (dsml) return sanitizeRunCommand(dsml[1]);
821
+ const dsmlCmd = dsmlRunCommand(reply);
822
+ if (dsmlCmd !== undefined) return dsmlCmd && !looksLikeMcpAsBash(dsmlCmd) ? dsmlCmd : null;
665
823
 
666
824
  // SEVERAL "RUN:" LINES IN ONE REPLY RUN BACK TO BACK.
667
825
  //
@@ -693,12 +851,26 @@ function parseRun(reply) {
693
851
  const fenced = /^```[\w-]*\n([\s\S]*?)```/.exec(cmd.trim());
694
852
  if (fenced) cmd = fenced[1];
695
853
  else cmd = cmd.replace(/\n```[\s\S]*$/, ''); // trailing fence + any posttext
854
+ cmd = sliceToNextDirective(cmd);
696
855
  cmd = sanitizeRunCommand(cmd);
856
+ // A RUN: that swallowed MCP: / get_skill / proofnetwork-* is the
857
+ // over-match that produced `/bin/bash: line 3: RUN:: command not found`
858
+ // and then `/bin/bash: get_skill: command not found`. Refuse the batch
859
+ // rather than join skill names into one script.
860
+ if (cmd && looksLikeMcpAsBash(cmd)) return null;
697
861
  if (cmd) cmds.push(cmd);
698
862
  }
699
863
  return cmds.length ? cmds.join('\n') : null;
700
864
  }
701
865
 
866
+ function dsmlRunCommand(reply) {
867
+ const SEP = '[||\\s]*';
868
+ const NAME = '(?:command|cmd|shell_command|script)';
869
+ const dsml = new RegExp(`<${SEP}DSML[^>]*\\bparameter\\b[^>]*\\bname="${NAME}"[^>]*>([\\s\\S]*?)<\\/${SEP}DSML`, 'i').exec(reply);
870
+ if (!dsml) return undefined;
871
+ return sanitizeRunCommand(dsml[1]);
872
+ }
873
+
702
874
  // RUN through BASH, not /bin/sh. node's exec() defaults to /bin/sh, which on
703
875
  // Debian is dash — so every bash-ism a model writes (`for … do`, `[[ ]]`,
704
876
  // arrays, process substitution) dies as
@@ -1106,6 +1278,24 @@ setInterval(() => {
1106
1278
  }
1107
1279
  }, 15000).unref();
1108
1280
 
1281
+ // A turn that is thinking with no deltas/status for too long is dead — the
1282
+ // stream reader used to hang forever and block the next user prompt behind
1283
+ // mute dots. Bump turnSeq so the in-flight runTurn bails, then idle.
1284
+ setInterval(() => {
1285
+ const now = Date.now();
1286
+ let dirty = false;
1287
+ for (const t of threads.values()) {
1288
+ if (t.status !== 'thinking') continue;
1289
+ const last = t.lastDeltaAt || t.thinkingAt || 0;
1290
+ if (!last || now - last < STALE_THINKING_MS) continue;
1291
+ t.turnSeq = (t.turnSeq || 0) + 1;
1292
+ t.status = 'idle';
1293
+ t.liveStatus = '';
1294
+ dirty = true;
1295
+ }
1296
+ if (dirty) saveThreads();
1297
+ }, 5000).unref();
1298
+
1109
1299
  // Directives that only READ. These are safe to run at the same time, so a
1110
1300
  // reply carrying several of them costs one round trip instead of N — a model
1111
1301
  // that wants four files currently spends four full turns (and four payments)
@@ -1121,7 +1311,10 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__
1121
1311
  function walkDir(base, rel = '', out = [], depth = 0) {
1122
1312
  if (depth > 12 || out.length > 5000) return out;
1123
1313
  let entries = [];
1124
- try { entries = readdirSync(path.join(base, rel), { withFileTypes: true }); } catch { return out; }
1314
+ try {
1315
+ const dir = rel && path.isAbsolute(rel) ? inDir(base, rel) : path.join(base, rel);
1316
+ entries = readdirSync(dir, { withFileTypes: true });
1317
+ } catch { return out; }
1125
1318
  for (const e of entries) {
1126
1319
  const r = rel ? path.join(rel, e.name) : e.name;
1127
1320
  if (e.isDirectory()) {
@@ -1487,7 +1680,19 @@ function nameAddressedToSend(reply, originId) {
1487
1680
  }).join('\n');
1488
1681
  }
1489
1682
 
1490
- async function tryDirective(reply, originId) {
1683
+ function emitLiveStatus(originId, onEvent, detail) {
1684
+ if (!detail) return;
1685
+ const t = threads.get(originId);
1686
+ if (t) {
1687
+ t.liveStatus = detail;
1688
+ t.lastDeltaAt = Date.now();
1689
+ }
1690
+ onEvent?.({ type: 'status', name: t?.name, color: t?.color, detail });
1691
+ }
1692
+
1693
+ async function tryDirective(reply, originId, onEvent) {
1694
+ const trail = peekDirectiveStatus(reply);
1695
+ if (trail) emitLiveStatus(originId, onEvent, trail);
1491
1696
  // Foreign envelope in, our directives out — before any matching runs, so
1492
1697
  // every branch below sees the shape it was written for.
1493
1698
  const translated = translateForeignToolCall(reply);
@@ -1507,12 +1712,20 @@ async function tryDirective(reply, originId) {
1507
1712
  // separator-insensitive. That is what makes this safe: "Note:" or "Step 2:"
1508
1713
  // never matches a live agent, so ordinary prose is untouched.
1509
1714
  reply = nameAddressedToSend(reply, originId);
1715
+ // MCP SKILL NAMES ARE NOT SHELL. parseRun used to hand get_skill /
1716
+ // proofnetwork-* / MCP: to bash. Refuse here so the model sees the
1717
+ // real MCP: directive, including DSML-wrapped RUN bodies with no RUN: line.
1718
+ const runBodies = directiveLines(reply, 'RUN');
1719
+ const dsmlCmd = dsmlRunCommand(reply);
1720
+ if (runBodies.some(looksLikeMcpAsBash) || (dsmlCmd && looksLikeMcpAsBash(dsmlCmd))) {
1721
+ return MCP_AS_BASH_REFUSE;
1722
+ }
1510
1723
  // FAN OUT FIRST. Each line is re-entered on its own, so every branch below
1511
1724
  // stays single-directive and none of them had to learn about batching.
1512
1725
  const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
1513
1726
  if (batch.length > 1) {
1514
1727
  const results = await Promise.all(
1515
- batch.map((m) => tryDirective(m[0].replace(/^[ \t>*-]*/, ''), originId)
1728
+ batch.map((m) => tryDirective(m[0].replace(/^[ \t>*-]*/, ''), originId, onEvent)
1516
1729
  .catch((e) => `${m[1]}: ${e.message}`)),
1517
1730
  );
1518
1731
  return results.filter(Boolean).join('\n\n');
@@ -1613,7 +1826,7 @@ async function tryDirective(reply, originId) {
1613
1826
  const sendAll = directiveLines(reply, 'SEND');
1614
1827
  if (sendAll.length > 1) {
1615
1828
  const out = [];
1616
- for (const line of sendAll) out.push(await tryDirective('SEND: ' + line, originId));
1829
+ for (const line of sendAll) out.push(await tryDirective('SEND: ' + line, originId, onEvent));
1617
1830
  return out.filter(Boolean).join('\n');
1618
1831
  }
1619
1832
  const sendM = sendAll.length === 1 ? /^([^|]+)\|([\s\S]+)/.exec(sendAll[0]) : null;
@@ -1642,7 +1855,7 @@ async function tryDirective(reply, originId) {
1642
1855
  const pingAll = directiveLines(reply, 'PING');
1643
1856
  if (pingAll.length > 1) {
1644
1857
  const out = [];
1645
- for (const line of pingAll) out.push(await tryDirective('PING: ' + line, originId));
1858
+ for (const line of pingAll) out.push(await tryDirective('PING: ' + line, originId, onEvent));
1646
1859
  return out.filter(Boolean).join('\n');
1647
1860
  }
1648
1861
  const ping = pingAll.length === 1 ? [null, pingAll[0]] : null;
@@ -1687,7 +1900,7 @@ async function tryDirective(reply, originId) {
1687
1900
  const full = safeResolveIn(dirFor(originId), rel);
1688
1901
  mkdirSync(path.dirname(full), { recursive: true });
1689
1902
  writeFileSync(full, content);
1690
- return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.`;
1903
+ return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.${await previewAck(originId, rel)}`;
1691
1904
  } catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
1692
1905
  }
1693
1906
  const readD = /^[ \t>*-]*READ:\s*(.+)/m.exec(reply);
@@ -1713,7 +1926,7 @@ async function tryDirective(reply, originId) {
1713
1926
  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
1927
  if (hits > 1) return `EDIT ${rel}: that text appears ${hits} times — include more surrounding context so it matches exactly once.`;
1715
1928
  writeFileSync(full, before.replace(oldStr, newStr));
1716
- return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes).`;
1929
+ return `Edited ${rel} (${before.length} -> ${before.replace(oldStr, newStr).length} bytes).${await previewAck(originId, rel)}`;
1717
1930
  } catch (e) { return `Couldn't edit ${rel}: ${e.message}`; }
1718
1931
  }
1719
1932
 
@@ -1739,7 +1952,7 @@ async function tryDirective(reply, originId) {
1739
1952
  applied.push(o.slice(0, 40));
1740
1953
  }
1741
1954
  writeFileSync(full, next);
1742
- return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes).`;
1955
+ return `MULTIEDIT ${rel}: ${applied.length} edit(s) applied (${before.length} -> ${next.length} bytes).${await previewAck(originId, rel)}`;
1743
1956
  } catch (e) { return `Couldn't multiedit ${rel}: ${e.message}`; }
1744
1957
  }
1745
1958
 
@@ -1770,8 +1983,8 @@ async function tryDirective(reply, originId) {
1770
1983
  if (ls) {
1771
1984
  const rel = ls[1].trim() || '.';
1772
1985
  try {
1773
- const full = safeResolveIn(dirFor(originId), rel);
1774
- const entries = readdirSync(full, { withFileTypes: true });
1986
+ const full = inDir(dirFor(originId), rel);
1987
+ const entries = listDir(dirFor(originId), rel);
1775
1988
  if (!entries.length) return `${rel}: (empty)`;
1776
1989
  const lines = entries.slice(0, 300).map((e) => {
1777
1990
  if (e.isDirectory()) return ` ${e.name}/`;
@@ -1796,9 +2009,11 @@ async function tryDirective(reply, originId) {
1796
2009
  // becomes `*`, which is what was meant.
1797
2010
  const glob = /^(?:GLOB|LS|LIST|DIR|FIND):[ \t]*(.*)$/m.exec(reply);
1798
2011
  if (glob) {
1799
- const pattern = glob[1].trim() || '*';
2012
+ let pattern = glob[1].trim() || '*';
1800
2013
  try {
1801
2014
  const base = dirFor(originId);
2015
+ const stripped = stripBasePrefix(base, pattern);
2016
+ if (stripped !== null) pattern = stripped || '*';
1802
2017
  const re = globToRe(pattern.startsWith('./') ? pattern.slice(2) : pattern);
1803
2018
  const hits = walkDir(base).filter((f) => re.test(f) || re.test(path.basename(f)));
1804
2019
  if (!hits.length) return `GLOB ${pattern}: no matches`;
@@ -1817,7 +2032,12 @@ async function tryDirective(reply, originId) {
1817
2032
  try { re = new RegExp(pattern, 'i'); }
1818
2033
  catch { return `GREP: ${pattern} isn't a valid regex.`; }
1819
2034
  let files = walkDir(base);
1820
- if (scope) { const sre = globToRe(scope); files = files.filter((f) => sre.test(f) || f.startsWith(scope)); }
2035
+ if (scope) {
2036
+ const stripped = stripBasePrefix(base, scope);
2037
+ const use = stripped !== null ? stripped : scope;
2038
+ const sre = globToRe(use);
2039
+ files = files.filter((f) => sre.test(f) || f.startsWith(use));
2040
+ }
1821
2041
  const out = [];
1822
2042
  for (const f of files) {
1823
2043
  if (out.length > 200) break;
@@ -1887,9 +2107,19 @@ async function tryDirective(reply, originId) {
1887
2107
 
1888
2108
  const serve = /^[ \t>*-]*SERVE:\s*(.*)$/m.exec(reply);
1889
2109
  if (serve) {
1890
- 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}`;
2110
+ let rel = serve[1].trim();
2111
+ try {
2112
+ if (rel) {
2113
+ const root = path.resolve(dirFor(originId));
2114
+ const full = inDir(root, rel);
2115
+ rel = full === root ? '' : full.slice(root.length + 1);
2116
+ }
2117
+ } catch (e) { return `Couldn't serve ${serve[1].trim()}: ${e.message}`; }
2118
+ const port = await ensureWorkspacePort();
2119
+ if (!port) {
2120
+ return `Serving ${rel || 'index.html'} from ${dirFor(originId)} — waiting for the workspace port to bind.`;
2121
+ }
2122
+ return `Serving at http://localhost:${port}/${originId}/${rel}`;
1893
2123
  }
1894
2124
  const fetchD = /^[ \t>*-]*FETCH:\s*(\S+)/m.exec(reply);
1895
2125
  if (fetchD) {
@@ -2026,59 +2256,79 @@ async function mcpDirective(url, tool, args) {
2026
2256
  }
2027
2257
 
2028
2258
  // 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
2259
+ // call: {type:'start',name,color} when a bot begins its turn, {type:'status',
2260
+ // detail} while paying / waiting / walking tools, {type:'delta',name,color,
2261
+ // delta} per streamed token, {type:'final',name,color,text} once its full
2262
+ // reply (or directive ack) is settled. Background turns — a SPAWNed
2032
2263
  // subagent nobody's looking at yet — run with onEvent omitted and just use
2033
2264
  // the plain non-streaming brain(), which is cheaper when nothing renders it.
2034
2265
  async function runTurn(threadId, userText, onEvent, images) {
2035
2266
  const t = threads.get(threadId);
2036
2267
  if (!t) return;
2268
+ // A new user prompt must not sit behind a dead auto-continue. Bump the
2269
+ // generation so the previous runTurn's awaits bail instead of keeping
2270
+ // status=thinking and the UI on "…".
2271
+ if (!isHarnessUserText(userText)) t.turnSeq = (t.turnSeq || 0) + 1;
2272
+ const seq = t.turnSeq || 0;
2273
+ const stillMine = () => threads.get(threadId)?.turnSeq === seq;
2274
+ const paint = (ev) => {
2275
+ if (!stillMine()) return;
2276
+ if (ev.type === 'status' && ev.detail) t.liveStatus = ev.detail;
2277
+ if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start') t.lastDeltaAt = Date.now();
2278
+ onEvent?.(ev);
2279
+ };
2037
2280
  t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
2038
2281
  t.lastActivityAt = Date.now();
2282
+ t.status = 'thinking';
2283
+ t.thinkingAt = Date.now();
2284
+ t.lastDeltaAt = Date.now();
2285
+ t.liveStatus = 'waiting on model…';
2286
+ let chained = false;
2287
+ let parked = false;
2288
+ try {
2039
2289
  if (t.members) {
2040
- t.status = 'thinking';
2041
2290
  // sequential, not parallel: each member's context is rebuilt from
2042
2291
  // t.history right before its turn, so it sees every reply (including
2043
2292
  // spawns/sends) the earlier members in THIS round already made
2044
2293
  for (const m of t.members) {
2294
+ if (!stillMine()) return;
2045
2295
  const msgs = buildMemberMessages(t, m);
2046
2296
  let r = '';
2047
- onEvent?.({ type: 'start', name: m.name, color: m.color });
2297
+ paint({ type: 'start', name: m.name, color: m.color, detail: 'waiting on model…' });
2298
+ const emitStatus = (detail) => paint({ type: 'status', name: m.name, color: m.color, detail });
2048
2299
  try {
2049
2300
  r = onEvent
2050
- ? (await brainStream(msgs, (delta) => onEvent({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId)).trim()
2301
+ ? (await brainStream(msgs, (delta) => paint({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId, undefined, undefined, 0, 0, emitStatus)).trim()
2051
2302
  : (await brain(msgs, t.contextId)).trim();
2052
2303
  } catch (e) { r = `error: ${e.message}`; }
2304
+ if (!stillMine()) return;
2305
+ r = stripThinkTags(r);
2053
2306
  const runCmd = parseRun(r);
2054
2307
  if (runCmd) {
2055
2308
  const command = runCmd;
2056
2309
  if (t.runMode === 'auto') {
2310
+ emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
2057
2311
  const output = await execCommand(command, dirFor(t.id));
2058
2312
  const shown = `$ ${command}\n${output}`;
2059
2313
  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 });
2314
+ paint({ type: 'final', name: m.name, color: m.color, text: shown });
2061
2315
  // this member's turn is done; the round continues to the next member
2062
2316
  continue;
2063
2317
  }
2064
2318
  const runId = randomUUID();
2065
2319
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
2066
2320
  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 });
2321
+ paint({ type: 'run-pending', runId, command, name: m.name, color: m.color });
2068
2322
  // pauses the WHOLE round here — the rest of the group gets their turn
2069
2323
  // on the round that runs after the user approves/denies
2070
- t.status = 'idle';
2071
- t.lastActivityAt = Date.now();
2072
- saveThreads();
2324
+ parked = true;
2073
2325
  return;
2074
2326
  }
2075
- const ack = await tryDirective(r, t.id);
2327
+ const ack = await tryDirective(r, t.id, paint);
2076
2328
  const finalText = ack ?? (r || '(no response)');
2077
2329
  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 });
2330
+ paint({ type: 'final', name: m.name, color: m.color, text: finalText });
2079
2331
  }
2080
- t.status = 'idle';
2081
- saveThreads();
2082
2332
  bindThread(t).catch(() => {});
2083
2333
  return;
2084
2334
  }
@@ -2092,9 +2342,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2092
2342
  delete t.autoNudged;
2093
2343
  }
2094
2344
  t.messages.push({ role: 'user', content: contentFor(userText, images) });
2095
- t.status = 'thinking';
2096
2345
  let reply = '';
2097
- onEvent?.({ type: 'start', name: t.name, color: t.color });
2346
+ paint({ type: 'start', name: t.name, color: t.color, detail: 'waiting on model…' });
2098
2347
  // Transient: the nudge is appended for THIS call only and never pushed into
2099
2348
  // t.messages, so it can't accumulate across a chained auto run or get bound
2100
2349
  // into the thread's context.
@@ -2119,7 +2368,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2119
2368
  // `attempt` exists because a retry must be allowed to land somewhere else:
2120
2369
  // see the empty-completion loop below.
2121
2370
  const ask = async (attempt = 0) => {
2122
- const emit = (delta) => onEvent && onEvent({ type: 'delta', name: t.name, color: t.color, delta });
2371
+ const emit = (delta) => paint({ type: 'delta', name: t.name, color: t.color, delta });
2372
+ const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
2123
2373
  // Retrieval breadth scales with the PROJECT's corpus, not this thread's —
2124
2374
  // the holobrain is shared at the root, so that is the pool being searched.
2125
2375
  const topK = adaptiveTopK((threads.get(rootOf(t).rootId) || t).boundItems);
@@ -2131,16 +2381,18 @@ async function runTurn(threadId, userText, onEvent, images) {
2131
2381
  // the middle (2 of 3) is a judged answer without the slowest entrant
2132
2382
  // setting the latency.
2133
2383
  const need = Math.min(Math.max(Number(t.raceNeed) || 1, 1), race);
2384
+ emitStatus('waiting on model…');
2134
2385
  return (await brainRace(callMsgs, emit, t.contextId, models, need)).trim();
2135
2386
  }
2136
2387
  // A retry draws a DIFFERENT model from the tier rather than the same one.
2137
2388
  const model = t.model || (await tierModels(t.tier || 'medium', attempt + 1, attempt > 0))[attempt] || undefined;
2138
2389
  return (onEvent
2139
- ? (await brainStream(callMsgs, emit, t.contextId, model)).trim()
2390
+ ? (await brainStream(callMsgs, emit, t.contextId, model, undefined, 0, topK, emitStatus)).trim()
2140
2391
  : (await brain(callMsgs, t.contextId, model, topK)).trim());
2141
2392
  };
2142
2393
  try {
2143
2394
  reply = await ask();
2395
+ if (!stillMine()) return;
2144
2396
  // An EMPTY completion is transient far more often than it is meaningful —
2145
2397
  // it showed up repeatedly as a dead "(no response)" bubble that cost the
2146
2398
  // user a turn and told them nothing. Retry once before giving up, and if
@@ -2157,7 +2409,9 @@ async function runTurn(threadId, userText, onEvent, images) {
2157
2409
  // fix. A thread pinned with /model stays pinned; that was an explicit
2158
2410
  // choice and silently answering as something else would be worse.
2159
2411
  for (let i = 0; !reply && i < AUTO_EMPTY_RETRIES; i++) {
2412
+ paint({ type: 'status', name: t.name, color: t.color, detail: 'retrying…' });
2160
2413
  await new Promise((r) => setTimeout(r, 400 * (i + 1)));
2414
+ if (!stillMine()) return;
2161
2415
  reply = await ask(t.model ? 0 : i + 1);
2162
2416
  }
2163
2417
  if (!reply) {
@@ -2168,15 +2422,19 @@ async function runTurn(threadId, userText, onEvent, images) {
2168
2422
  } catch (e) {
2169
2423
  reply = `error: ${e.message}`;
2170
2424
  }
2425
+ if (!stillMine()) return;
2426
+ reply = stripThinkTags(reply);
2171
2427
  t.messages.push({ role: 'assistant', content: reply });
2172
2428
  const runCmd = parseRun(reply);
2173
2429
  if (runCmd) {
2174
2430
  const command = runCmd;
2175
2431
  if (t.runMode === 'auto') {
2432
+ emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
2176
2433
  const output = await execCommand(command, dirFor(t.id));
2434
+ if (!stillMine()) return;
2177
2435
  const shown = `$ ${command}\n${output}`;
2178
2436
  t.history.push({ who: 'bot', text: shown });
2179
- onEvent?.({ type: 'final', name: t.name, color: t.color, text: shown });
2437
+ paint({ type: 'final', name: t.name, color: t.color, text: shown });
2180
2438
  // FEED THE OUTPUT BACK. The 'ask' path already does this on approve, so
2181
2439
  // auto mode was strictly LESS capable than the gated one: the command
2182
2440
  // ran, the result was shown, and the model never saw it — no diagnosis,
@@ -2186,9 +2444,6 @@ async function runTurn(threadId, userText, onEvent, images) {
2186
2444
  // AUTO_MAX_STEPS chained commands per user message, reset whenever the
2187
2445
  // user speaks again.
2188
2446
  t.autoSteps = (t.autoSteps || 0) + 1;
2189
- t.status = 'idle';
2190
- t.lastActivityAt = Date.now();
2191
- saveThreads();
2192
2447
  if (t.autoSteps < AUTO_MAX_STEPS) {
2193
2448
  // BIND BEFORE CHAINING. bindThread only ran at the end of a normal
2194
2449
  // turn, and both auto paths return before reaching it — so in auto
@@ -2196,12 +2451,14 @@ async function runTurn(threadId, userText, onEvent, images) {
2196
2451
  // most material (command output, GLOB results, MCP tool lists). The
2197
2452
  // holographic context stopped growing precisely when it mattered.
2198
2453
  bindThread(t).catch(() => {});
2454
+ chained = true;
2199
2455
  runTurn(threadId, condense('(command output)', output), onEvent).catch(() => {});
2200
2456
  } else {
2201
2457
  // Do not park. A "say continue" note is the failure mode auto exists
2202
2458
  // to avoid — inject continue and keep going until DONE or ask.
2203
2459
  t.autoSteps = 0;
2204
2460
  bindThread(t).catch(() => {});
2461
+ chained = true;
2205
2462
  runTurn(threadId, AUTO_CONTINUE, onEvent).catch(() => {});
2206
2463
  }
2207
2464
  return;
@@ -2210,20 +2467,16 @@ async function runTurn(threadId, userText, onEvent, images) {
2210
2467
  const runId = randomUUID();
2211
2468
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
2212
2469
  t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
2213
- onEvent?.({ type: 'run-pending', runId, command, name: t.name, color: t.color });
2470
+ paint({ type: 'run-pending', runId, command, name: t.name, color: t.color });
2214
2471
  }
2215
- t.status = 'idle';
2216
- t.lastActivityAt = Date.now();
2217
- saveThreads();
2472
+ parked = true;
2218
2473
  return;
2219
2474
  }
2220
- const ack = await tryDirective(reply, t.id);
2475
+ const ack = await tryDirective(reply, t.id, paint);
2476
+ if (!stillMine()) return;
2221
2477
  const finalText = ack ?? (reply || '(no response)');
2222
2478
  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();
2479
+ paint({ type: 'final', name: t.name, color: t.color, text: finalText });
2227
2480
 
2228
2481
  // AUTO CONTINUES AFTER *ANY* DIRECTIVE, not just RUN.
2229
2482
  //
@@ -2240,6 +2493,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2240
2493
  && !/^[ \t>*-]*DONE:/m.test(reply)) {
2241
2494
  t.autoSteps = (t.autoSteps || 0) + 1;
2242
2495
  bindThread(t).catch(() => {}); // bind every hop, not just the last one
2496
+ chained = true;
2243
2497
  if (t.autoSteps < AUTO_MAX_STEPS) {
2244
2498
  runTurn(threadId, condense('(directive result)', ack), onEvent).catch(() => {});
2245
2499
  } else {
@@ -2273,8 +2527,8 @@ async function runTurn(threadId, userText, onEvent, images) {
2273
2527
  // another continue rather than stopping.
2274
2528
  t.autoNudged = true;
2275
2529
  t.autoSteps = (t.autoSteps || 0) + 1;
2276
- saveThreads();
2277
2530
  bindThread(t).catch(() => {});
2531
+ chained = true;
2278
2532
  if (t.autoSteps < AUTO_MAX_STEPS) {
2279
2533
  runTurn(threadId, NUDGE, onEvent).catch(() => {});
2280
2534
  } else {
@@ -2284,6 +2538,17 @@ async function runTurn(threadId, userText, onEvent, images) {
2284
2538
  return;
2285
2539
  }
2286
2540
  bindThread(t).catch(() => {});
2541
+ } finally {
2542
+ // A hung or thrown brainStream used to leave t.status = 'thinking' and the
2543
+ // UI on mute "…" forever. Always idle unless this hop chained or parked
2544
+ // on an approval — the next runTurn sets thinking again itself.
2545
+ if (stillMine() && !chained && !parked && !t.pendingRun) {
2546
+ t.status = 'idle';
2547
+ t.liveStatus = '';
2548
+ }
2549
+ t.lastActivityAt = Date.now();
2550
+ saveThreads();
2551
+ }
2287
2552
  }
2288
2553
 
2289
2554
  // Said it would, without a directive line. "Spawned X" and "working on it" are
@@ -2418,6 +2683,7 @@ function threadSummary(t) {
2418
2683
  // an approval nobody knew it wanted. The blue dot means "working"; this
2419
2684
  // means "your move".
2420
2685
  awaitingUser: Boolean(t.pendingRun),
2686
+ liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
2421
2687
  rootId: rootOf(t).rootId, depth: rootOf(t).depth,
2422
2688
  rootName: (threads.get(rootOf(t).rootId) || t).name,
2423
2689
  // The spend dial, so the header can show it without a round trip per
@@ -2425,7 +2691,11 @@ function threadSummary(t) {
2425
2691
  tier: t.tier || 'medium', race: Number(t.race) || 0, raceNeed: Number(t.raceNeed) || 1, model: t.model || '',
2426
2692
  // How many bots sit BELOW this one. The ping-all affordance belongs on
2427
2693
  // anyone with a crew, not only on a project root.
2428
- kids: subtreeOf(t.id).length };
2694
+ kids: subtreeOf(t.id).length,
2695
+ // Names only — enough for the sidebar/header to stack a little crew PFP
2696
+ // without shipping every member's system prompt to the browser.
2697
+ members: t.members ? t.members.map((m) => m.name) : undefined,
2698
+ workspacePort: workspacePort || 0 };
2429
2699
  }
2430
2700
 
2431
2701
  const APP_HTML = `<!doctype html>
@@ -2478,8 +2748,22 @@ const APP_HTML = `<!doctype html>
2478
2748
  font-size: 13px; }
2479
2749
  .trow:hover .tclose { display: flex; }
2480
2750
  .tclose:hover { background: #3a3a3c; color: #ececec; }
2481
- .tavatar { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: flex; align-items: center;
2482
- justify-content: center; color: #fff; font-weight: 600; font-size: 14px; }
2751
+ /* BOT PFPs. Grok Bot uses a cute illustrated face, not two letters in a
2752
+ rounded square. The SVG is generated in botPfp(); this just frames it
2753
+ as a round clip and runs a cheap idle bob/blink. overflow:hidden clips
2754
+ the bounce so it cannot paint over the HUD or wallet. */
2755
+ .tavatar { width: 36px; height: 36px; border-radius: 50%; flex: 0 0 36px; overflow: hidden;
2756
+ display: flex; align-items: center; justify-content: center; background: #1c1c1e;
2757
+ color: #fff; font-weight: 600; font-size: 14px; }
2758
+ .tavatar svg { width: 100%; height: 100%; display: block; }
2759
+ .tavatar-sm { width: 28px; height: 28px; flex: 0 0 28px; }
2760
+ .tavatar-plus { background: #3a3a3c; font-size: 15px; }
2761
+ .bot-pfp .bot-bob { transform-box: fill-box; transform-origin: 50% 70%;
2762
+ animation: botbob 2.8s ease-in-out infinite; animation-delay: var(--bot-delay, 0s); }
2763
+ .bot-pfp .bot-eyes { transform-box: fill-box; transform-origin: 50% 50%;
2764
+ animation: botblink 3.8s step-end infinite; animation-delay: var(--bot-blink, 0s); }
2765
+ @keyframes botbob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } }
2766
+ @keyframes botblink { 0%,88%,100% { transform: scaleY(1); } 90%,94% { transform: scaleY(0.08); } }
2483
2767
  .tmeta { min-width: 0; flex: 1; }
2484
2768
  .tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2485
2769
  .tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -2494,11 +2778,13 @@ const APP_HTML = `<!doctype html>
2494
2778
  animation: twarnpulse 1.6s ease-in-out infinite;
2495
2779
  }
2496
2780
  @keyframes twarnpulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
2497
- @media (prefers-reduced-motion: reduce) { .twarn { animation: none; } }
2781
+ @media (prefers-reduced-motion: reduce) {
2782
+ .twarn, .bot-pfp .bot-bob, .bot-pfp .bot-eyes { animation: none; }
2783
+ }
2498
2784
  #main { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
2499
2785
  #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
2500
2786
  flex-wrap: wrap; gap: 8px 10px; font-weight: 600; }
2501
- #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
2787
+ #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 50%; font-size: 11px; flex: 0 0 26px; }
2502
2788
  /* Title shrinks and wraps; the spend dials must stay on screen. margin-left:auto
2503
2789
  on #modeToggle used to shove cheap/race/wallet off the right edge. */
2504
2790
  #chatHeaderId { display: flex; align-items: center; gap: 10px; flex: 1 1 120px; min-width: 0; overflow: hidden; }
@@ -2557,10 +2843,38 @@ const APP_HTML = `<!doctype html>
2557
2843
  .wrow .wcopy { flex: 0 0 auto; color: #6f7080; font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
2558
2844
  user-select: none; }
2559
2845
  .wrow:hover .wcopy { color: #b8f240; }
2846
+ .wcredit { border: 1px solid #2a3a18; background: rgba(184,242,64,.08); border-radius: 12px;
2847
+ padding: 12px 14px; margin-bottom: 12px; }
2848
+ .wcredit .wbig { color: #b8f240; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; }
2849
+ .wcredit .wlab2 { color: #8e8e93; font-size: 11px; margin-top: 2px; }
2560
2850
  .wbal { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 10px;
2561
2851
  font-size: 12px; color: #ececec; line-height: 1.7; word-break: break-word; }
2562
2852
  .wnote { color: #6f7080; font-size: 11px; line-height: 1.6; margin-top: 12px; }
2563
2853
  .wempty { color: #f28c4d; }
2854
+ .wlane { border-top: 1px solid #2c2c2e; margin-top: 16px; padding-top: 14px; }
2855
+ .wlanetitle { font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
2856
+ color: #ececec; margin-bottom: 4px; }
2857
+ .wtag { color: #b8f240; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; margin-bottom: 8px; }
2858
+ .wtier { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 8px; }
2859
+ .wtier.hot { border-color: #b8f240; }
2860
+ .wtier .wtn { font-size: 14px; font-weight: 600; }
2861
+ .wtier .wtp { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 18px;
2862
+ font-weight: 700; margin: 4px 0 2px; }
2863
+ .wtier .wts { color: #b8f240; font-size: 11px; }
2864
+ .wtier .wtb { color: #8e8e93; font-size: 11px; margin: 4px 0 8px; }
2865
+ .wtier button { border: 1px solid #2c2c2e; background: #131315; color: #ececec; font: inherit;
2866
+ font-size: 12px; border-radius: 8px; padding: 6px 10px; cursor: pointer; }
2867
+ .wtier.hot button { background: #b8f240; border-color: #b8f240; color: #0b0b0d; font-weight: 600; }
2868
+ .wtier button:disabled { opacity: .5; cursor: default; }
2869
+ .wpaste { margin-top: 10px; }
2870
+ .wpaste input { width: 100%; background: #0b0b0d; border: 1px solid #2c2c2e; border-radius: 8px;
2871
+ color: #ececec; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
2872
+ padding: 8px 10px; margin: 6px 0; }
2873
+ .wpaste button { border: 1px solid #2c2c2e; background: #131315; color: #ececec; font: inherit;
2874
+ font-size: 12px; border-radius: 8px; padding: 6px 10px; cursor: pointer; }
2875
+ .wquiet { margin-top: 10px; font-size: 11px; }
2876
+ .wquiet a { color: #6ab0ff; }
2877
+ .wsubon { color: #b8f240; font-size: 13px; font-weight: 600; margin-bottom: 8px; }
2564
2878
  /* Slash autocomplete. Anchored above the composer because the composer sits
2565
2879
  at the bottom of the viewport — a dropdown BELOW it would render off
2566
2880
  screen. */
@@ -2579,11 +2893,14 @@ const APP_HTML = `<!doctype html>
2579
2893
  inside the dials (race / wallet / ◎) once they take a second row. top is
2580
2894
  set from the header's live bottom in placeHud(). z-index stays high; the
2581
2895
  bug was geometry, not stacking. */
2582
- #hud { position: absolute; right: 14px; width: 250px; background: rgba(14,14,17,.94);
2896
+ #hud { position: absolute; right: 14px; width: 270px; background: rgba(14,14,17,.94);
2583
2897
  border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
2584
2898
  display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
2585
2899
  #hud.show { display: block; }
2586
2900
  #hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
2901
+ #hud .htitle.hsession { margin-top: 12px; padding-top: 10px; border-top: 1px solid #333340; color: #6f7080; }
2902
+ #hud .hcredit { color: #b8f240; font-size: 22px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.15; }
2903
+ #hud .hcreditlab { color: #999aa8; font-size: 10px; margin: 2px 0 8px; }
2587
2904
  #hud .hrow { display: flex; justify-content: space-between; margin: 6px 0; color: #f0f0eb; font-size: 12px; }
2588
2905
  #hud .hrow span:first-child { color: #999aa8; font-size: 10.5px; }
2589
2906
  #hud .hlime { color: #b8f240; }
@@ -2604,8 +2921,8 @@ const APP_HTML = `<!doctype html>
2604
2921
  -webkit-user-select: text; user-select: text; }
2605
2922
  .hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
2606
2923
  color: #8e8e93; font-size: 13px; }
2607
- .hdr .avatar { width: 18px; height: 18px; border-radius: 5px; display: flex; align-items: center;
2608
- justify-content: center; color: #fff; font-size: 9px; font-weight: 700; }
2924
+ .hdr .avatar { width: 18px; height: 18px; border-radius: 50%; overflow: hidden; display: flex;
2925
+ align-items: center; justify-content: center; background: #1c1c1e; }
2609
2926
  /* min-width:0 is load-bearing. A flex item defaults to min-width:auto, so it
2610
2927
  refuses to shrink below its content's intrinsic width — one long
2611
2928
  unbreakable line (a curl command, a JSON blob) in a <pre> then stretches
@@ -2621,7 +2938,13 @@ const APP_HTML = `<!doctype html>
2621
2938
  /* rendered markdown. The bubble is pre-wrap for plain text, but block
2622
2939
  elements carry their own spacing — leaving pre-wrap on would add the
2623
2940
  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; }
2941
+ .bubble:has(> p, > .md-h, > .md-table, > .md-list, > .md-pre, > .html-preview-wrap) { white-space: normal; }
2942
+ .row:has(.html-preview) { max-width: 92%; }
2943
+ .html-preview-wrap { margin-top: 10px; }
2944
+ .html-preview-open { display: inline-block; margin-bottom: 6px; font-size: 12px; color: #6ab0ff;
2945
+ text-decoration: underline; cursor: pointer; }
2946
+ .html-preview { display: block; width: 100%; height: 420px; border: 1px solid #3a3a3c;
2947
+ border-radius: 12px; background: #111; }
2625
2948
  .bubble > p { margin: 0 0 10px; }
2626
2949
  .bubble > p:last-child { margin-bottom: 0; }
2627
2950
  .md-h { margin: 14px 0 8px; font-size: 15px; font-weight: 600; line-height: 1.3; }
@@ -2690,6 +3013,8 @@ const APP_HTML = `<!doctype html>
2690
3013
  background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
2691
3014
  .dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
2692
3015
  @keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
3016
+ .tstatus { color: #8e8e93; font-size: 13px; margin-left: 6px; }
3017
+ .ttrail { display: block; color: #8e8e93; font-size: 12.5px; margin-top: 8px; }
2693
3018
  #bar { padding: 10px 16px 18px; position: relative; }
2694
3019
  #row-input { display: flex; align-items: center; gap: 8px; }
2695
3020
  #plusMenu { position: absolute; bottom: 62px; left: 16px; background: #1c1c1e; border-radius: 14px;
@@ -2797,8 +3122,26 @@ const APP_HTML = `<!doctype html>
2797
3122
  <div id="walletOverlay" data-component="wallet-modal">
2798
3123
  <div id="walletBox">
2799
3124
  <h3>Your wallet</h3>
3125
+ <div class="wsub">Two ways to pay, side by side. Wallet/x402 stays. A card subscription sits next to it — not instead of it.</div>
3126
+ <div class="wlanetitle">Wallet / x402</div>
2800
3127
  <div class="wsub">This is <b>your</b> local burner on this machine (or this box). Keys stay in ~/.openzoo/wallet.json. It is not openzoo’s wallet, not a shared zoo account, not the model’s. You fund these deposit addresses; the app pays x402 per call from this wallet. Public addresses only — the UI never shows the key.</div>
2801
3128
  <div id="walletBody">loading…</div>
3129
+ <div class="wlane" id="subLane" data-component="subscribe-lane">
3130
+ <div class="wlanetitle">Subscribe with a card</div>
3131
+ <div class="wtag">Subscription key · no x402</div>
3132
+ <div class="wsub">Same plans as the public page. Checkout opens in the system browser — never an in-app Stripe window. After Stripe, this app polls the site’s key endpoint with the checkout session, or you paste the key from the success page.</div>
3133
+ <div id="subStatus"></div>
3134
+ <div id="subTiers">loading plans…</div>
3135
+ <div class="wpaste">
3136
+ <div class="wlab">I already subscribed — paste key</div>
3137
+ <input id="subKeyInp" type="text" autocomplete="off" spellcheck="false"
3138
+ placeholder="key, or the /billing/done?session=… URL">
3139
+ <button type="button" id="subKeyBtn">Save key</button>
3140
+ <button type="button" id="subForgetBtn" hidden>Remove key</button>
3141
+ </div>
3142
+ <div class="wnote" id="subNote"></div>
3143
+ <div class="wquiet"><a id="subPageLink" href="${SUBSCRIPTIONS_PAGE}" target="_blank" rel="noopener">Full subscriptions page</a></div>
3144
+ </div>
2802
3145
  </div>
2803
3146
  </div>
2804
3147
  <div id="main">
@@ -2833,13 +3176,14 @@ const APP_HTML = `<!doctype html>
2833
3176
  </optgroup>
2834
3177
  </select>
2835
3178
  <button class="dial" id="walletBtn" data-component="wallet-open"
2836
- title="Your local burner wallet — deposit addresses and live balances">wallet</button>
3179
+ title="Wallet/x402 or subscribe with a card — deposit addresses, live balances, Stripe plans">wallet</button>
2837
3180
  <button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">&#8635;</button>
2838
3181
  <button class="icon-btn" id="hudBtn">◎</button>
2839
3182
  </div>
2840
3183
  </div>
2841
3184
  <div id="hud">
2842
3185
  <div class="htitle">YOUR WALLET · THIS SESSION</div>
3186
+ <div class="hrow" id="hSubRow" hidden><span>subscription</span><span id="hSub" class="hlime">—</span></div>
2843
3187
  <div class="hrow"><span>prepaid credit</span><span id="hCredit" class="hlime">—</span></div>
2844
3188
  <div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
2845
3189
  <div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
@@ -2904,8 +3248,155 @@ const APP_HTML = `<!doctype html>
2904
3248
  : '';
2905
3249
  let activeId = null;
2906
3250
  let knownThreads = [];
2907
-
2908
- function initials(name) { return name.slice(0, 2).toUpperCase(); }
3251
+ let workspacePort = 0;
3252
+
3253
+ // BOT PFPs. Same job as Grok Bot's agent faces: a round illustrated
3254
+ // creature, unique per name, idle-animated, no network. Hash is the same
3255
+ // 31-multiply used by colorFor, so a name always paints the same bot.
3256
+ // Built with string concat — template literals inside APP_HTML would be
3257
+ // interpolated by the outer backtick string before the browser sees them.
3258
+ let botPfpSeq = 0;
3259
+ function nameHash(name) {
3260
+ let h = 0;
3261
+ const s = String(name || '');
3262
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
3263
+ return h;
3264
+ }
3265
+ function botPalettes() {
3266
+ return [
3267
+ ['#ff6b9d', '#ffd0e0', '#c9184a'],
3268
+ ['#ffb347', '#ffe4b3', '#c27800'],
3269
+ ['#5eead4', '#ccfbf1', '#0f766e'],
3270
+ ['#b8f240', '#e6ffb3', '#4d7c0f'],
3271
+ ['#c084fc', '#edd4ff', '#7e22ce'],
3272
+ ['#60a5fa', '#dbeafe', '#1d4ed8'],
3273
+ ['#fb7185', '#ffe4e6', '#be123c'],
3274
+ ['#34d399', '#d1fae5', '#047857'],
3275
+ ['#fbbf24', '#fef3c7', '#b45309'],
3276
+ ['#a78bfa', '#ede9fe', '#6d28d9'],
3277
+ ['#38bdf8', '#e0f2fe', '#0369a1'],
3278
+ ['#f472b6', '#fce7f3', '#9d174d']
3279
+ ];
3280
+ }
3281
+ function botFaceInner(name) {
3282
+ const h = nameHash(name);
3283
+ const pal = botPalettes()[h % 12];
3284
+ const fur = pal[0], light = pal[1], line = pal[2];
3285
+ const ears = (h >>> 4) % 5;
3286
+ const eyes = (h >>> 8) % 5;
3287
+ const mouth = (h >>> 12) % 5;
3288
+ const extra = (h >>> 16) % 4;
3289
+ const gid = 'bfg' + (++botPfpSeq);
3290
+ let s = '<g class="bot-bob">';
3291
+ s += '<defs><radialGradient id="' + gid + '" cx="35%" cy="30%" r="75%">'
3292
+ + '<stop offset="0%" stop-color="' + light + '"/>'
3293
+ + '<stop offset="100%" stop-color="' + fur + '"/>'
3294
+ + '</radialGradient></defs>';
3295
+ if (ears === 0) {
3296
+ s += '<ellipse cx="16" cy="18" rx="9" ry="10" fill="' + fur + '"/>'
3297
+ + '<ellipse cx="48" cy="18" rx="9" ry="10" fill="' + fur + '"/>'
3298
+ + '<ellipse cx="16" cy="19" rx="4.5" ry="5.5" fill="' + light + '"/>'
3299
+ + '<ellipse cx="48" cy="19" rx="4.5" ry="5.5" fill="' + light + '"/>';
3300
+ } else if (ears === 1) {
3301
+ s += '<polygon points="10,28 17,6 29,22" fill="' + fur + '"/>'
3302
+ + '<polygon points="54,28 47,6 35,22" fill="' + fur + '"/>'
3303
+ + '<polygon points="14,26 18,11 26,22" fill="' + light + '"/>'
3304
+ + '<polygon points="50,26 46,11 38,22" fill="' + light + '"/>';
3305
+ } else if (ears === 2) {
3306
+ s += '<ellipse cx="11" cy="34" rx="8" ry="14" fill="' + fur + '" transform="rotate(-28 11 34)"/>'
3307
+ + '<ellipse cx="53" cy="34" rx="8" ry="14" fill="' + fur + '" transform="rotate(28 53 34)"/>'
3308
+ + '<ellipse cx="12" cy="34" rx="4" ry="8" fill="' + light + '" transform="rotate(-28 12 34)"/>'
3309
+ + '<ellipse cx="52" cy="34" rx="4" ry="8" fill="' + light + '" transform="rotate(28 52 34)"/>';
3310
+ } else if (ears === 3) {
3311
+ s += '<line x1="22" y1="20" x2="17" y2="6" stroke="' + line + '" stroke-width="2.2" stroke-linecap="round"/>'
3312
+ + '<line x1="42" y1="20" x2="47" y2="6" stroke="' + line + '" stroke-width="2.2" stroke-linecap="round"/>'
3313
+ + '<circle cx="16" cy="5" r="3.6" fill="' + light + '" stroke="' + line + '" stroke-width="1"/>'
3314
+ + '<circle cx="48" cy="5" r="3.6" fill="' + light + '" stroke="' + line + '" stroke-width="1"/>';
3315
+ } else {
3316
+ s += '<ellipse cx="22" cy="10" rx="6" ry="16" fill="' + fur + '"/>'
3317
+ + '<ellipse cx="42" cy="10" rx="6" ry="16" fill="' + fur + '"/>'
3318
+ + '<ellipse cx="22" cy="11" rx="2.6" ry="10" fill="' + light + '"/>'
3319
+ + '<ellipse cx="42" cy="11" rx="2.6" ry="10" fill="' + light + '"/>';
3320
+ }
3321
+ s += '<circle cx="32" cy="36" r="22" fill="url(#' + gid + ')" stroke="' + line + '" stroke-width="1.1"/>'
3322
+ + '<ellipse cx="24" cy="26" rx="8" ry="5" fill="#fff" opacity="0.28"/>';
3323
+ if (extra === 1 || extra === 2) {
3324
+ s += '<ellipse cx="20" cy="42" rx="5.5" ry="3.2" fill="#ff8fab" opacity="0.5"/>'
3325
+ + '<ellipse cx="44" cy="42" rx="5.5" ry="3.2" fill="#ff8fab" opacity="0.5"/>';
3326
+ }
3327
+ if (extra === 3) {
3328
+ s += '<circle cx="22" cy="40" r="1.1" fill="' + line + '" opacity="0.4"/>'
3329
+ + '<circle cx="26" cy="43" r="0.9" fill="' + line + '" opacity="0.35"/>'
3330
+ + '<circle cx="42" cy="40" r="1.1" fill="' + line + '" opacity="0.4"/>'
3331
+ + '<circle cx="38" cy="43" r="0.9" fill="' + line + '" opacity="0.35"/>';
3332
+ }
3333
+ s += '<g class="bot-eyes">';
3334
+ if (eyes === 0) {
3335
+ s += '<circle cx="24" cy="35" r="3.6" fill="#1a1220"/>'
3336
+ + '<circle cx="40" cy="35" r="3.6" fill="#1a1220"/>'
3337
+ + '<circle cx="25.2" cy="33.8" r="1.15" fill="#fff"/>'
3338
+ + '<circle cx="41.2" cy="33.8" r="1.15" fill="#fff"/>';
3339
+ } else if (eyes === 1) {
3340
+ s += '<ellipse cx="24" cy="35" rx="3.2" ry="4.6" fill="#1a1220"/>'
3341
+ + '<ellipse cx="40" cy="35" rx="3.2" ry="4.6" fill="#1a1220"/>'
3342
+ + '<circle cx="24.8" cy="33.2" r="1" fill="#fff"/>'
3343
+ + '<circle cx="40.8" cy="33.2" r="1" fill="#fff"/>';
3344
+ } else if (eyes === 2) {
3345
+ s += '<path d="M20 36 q4 -6 8 0" fill="none" stroke="#1a1220" stroke-width="2.2" stroke-linecap="round"/>'
3346
+ + '<path d="M36 36 q4 -6 8 0" fill="none" stroke="#1a1220" stroke-width="2.2" stroke-linecap="round"/>';
3347
+ } else if (eyes === 3) {
3348
+ s += '<circle cx="24" cy="35" r="4.4" fill="#1a1220"/>'
3349
+ + '<circle cx="40" cy="35" r="4.4" fill="#1a1220"/>'
3350
+ + '<circle cx="25.4" cy="33.4" r="1.5" fill="#fff"/>'
3351
+ + '<circle cx="41.4" cy="33.4" r="1.5" fill="#fff"/>'
3352
+ + '<circle cx="22.8" cy="36.4" r="0.7" fill="#fff" opacity="0.7"/>';
3353
+ } else {
3354
+ s += '<path d="M20 35 q4 5 8 0" fill="none" stroke="#1a1220" stroke-width="2.2" stroke-linecap="round"/>'
3355
+ + '<circle cx="40" cy="35" r="3.6" fill="#1a1220"/>'
3356
+ + '<circle cx="41.2" cy="33.8" r="1.15" fill="#fff"/>';
3357
+ }
3358
+ s += '</g>';
3359
+ if (mouth === 0) {
3360
+ s += '<path d="M26 46 q6 7 12 0" fill="none" stroke="#1a1220" stroke-width="2" stroke-linecap="round"/>';
3361
+ } else if (mouth === 1) {
3362
+ s += '<ellipse cx="32" cy="48" rx="5" ry="3.4" fill="#3a1a22"/>'
3363
+ + '<ellipse cx="32" cy="49.4" rx="3.2" ry="1.6" fill="#ff6b8a" opacity="0.85"/>';
3364
+ } else if (mouth === 2) {
3365
+ s += '<path d="M25 46 q4 6 6 0 q4 6 6 0" fill="none" stroke="#1a1220" stroke-width="2" stroke-linecap="round"/>';
3366
+ } else if (mouth === 3) {
3367
+ s += '<path d="M26 45 q6 6 12 0" fill="none" stroke="#1a1220" stroke-width="2" stroke-linecap="round"/>'
3368
+ + '<ellipse cx="34" cy="50.5" rx="3.1" ry="3.4" fill="#ff6b8a"/>';
3369
+ } else {
3370
+ s += '<circle cx="32" cy="47.5" r="1.7" fill="#1a1220"/>';
3371
+ }
3372
+ s += '</g>';
3373
+ return s;
3374
+ }
3375
+ function botPfp(name, members) {
3376
+ let names = (members && members.length > 1) ? members.slice(0, 3) : [name || 'Bot'];
3377
+ if (names.length === 1 && String(name || '').indexOf(', ') !== -1) {
3378
+ const parts = String(name).split(', ');
3379
+ const cleaned = [];
3380
+ for (let i = 0; i < parts.length && cleaned.length < 3; i++) {
3381
+ if (parts[i]) cleaned.push(parts[i]);
3382
+ }
3383
+ if (cleaned.length > 1) names = cleaned;
3384
+ }
3385
+ const delay = nameHash(names[0]);
3386
+ let inner = '';
3387
+ if (names.length === 1) inner = botFaceInner(names[0]);
3388
+ else if (names.length === 2) {
3389
+ inner = '<g transform="translate(-2,6) scale(0.7)">' + botFaceInner(names[0]) + '</g>'
3390
+ + '<g transform="translate(20,8) scale(0.7)">' + botFaceInner(names[1]) + '</g>';
3391
+ } else {
3392
+ inner = '<g transform="translate(-4,2) scale(0.58)">' + botFaceInner(names[0]) + '</g>'
3393
+ + '<g transform="translate(22,4) scale(0.58)">' + botFaceInner(names[1]) + '</g>'
3394
+ + '<g transform="translate(8,16) scale(0.62)">' + botFaceInner(names[2]) + '</g>';
3395
+ }
3396
+ return '<svg class="bot-pfp" viewBox="0 0 64 64" aria-hidden="true" style="--bot-delay:-'
3397
+ + ((delay % 20) / 8) + 's;--bot-blink:-' + (((delay >>> 3) % 30) / 10) + 's">'
3398
+ + inner + '</svg>';
3399
+ }
2909
3400
 
2910
3401
  // SEARCH. The input existed with no handler at all — typing in it did
2911
3402
  // nothing, which is worse than not shipping it. Debounced because every
@@ -2937,6 +3428,7 @@ const APP_HTML = `<!doctype html>
2937
3428
  // seeing WHY something matched, not just that it did.
2938
3429
  const hitById = searchHits ? new Map(searchHits.map((h) => [h.id, h])) : null;
2939
3430
  knownThreads = list;
3431
+ if (list[0] && list[0].workspacePort) workspacePort = Number(list[0].workspacePort) || workspacePort;
2940
3432
  if (!activeId && list.length) activeId = list[0].id;
2941
3433
  threadsEl.innerHTML = '';
2942
3434
  const shown = hitById
@@ -2979,11 +3471,11 @@ const APP_HTML = `<!doctype html>
2979
3471
  // to nothing.
2980
3472
  if (t.depth) row.style.paddingLeft = (10 + Math.min(t.depth, 4) * 12) + 'px';
2981
3473
  if (t.depth) row.title = 'spawned under ' + (t.rootName || 'a parent');
2982
- row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
3474
+ row.innerHTML = '<div class="tavatar">' + botPfp(t.name, t.members) + '</div>' +
2983
3475
  '<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
2984
3476
  (hitById && hitById.get(t.id) && hitById.get(t.id).snippet
2985
3477
  ? hitById.get(t.id).snippet
2986
- : t.awaitingUser ? 'waiting for you' : t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
3478
+ : t.awaitingUser ? 'waiting for you' : t.status === 'thinking' ? escapeHtml(t.liveStatus || 'typing…') : (t.preview || '')) + '</div></div>' +
2987
3479
  // awaitingUser WINS over thinking: a thread blocked on an approval is
2988
3480
  // NOT working, and showing a working indicator there is a lie that
2989
3481
  // quietly costs you a subagent nobody knows is stuck.
@@ -3039,12 +3531,14 @@ const APP_HTML = `<!doctype html>
3039
3531
 
3040
3532
  async function loadActiveMessages() {
3041
3533
  if (!activeId) return null;
3042
- return await (await fetch(API + '/threads/' + activeId)).json();
3534
+ const data = await (await fetch(API + '/threads/' + activeId)).json();
3535
+ if (data && data.workspacePort) workspacePort = Number(data.workspacePort) || workspacePort;
3536
+ return data;
3043
3537
  }
3044
3538
 
3045
3539
  function renderHeader(t) {
3046
3540
  document.getElementById('chatHeaderId').innerHTML =
3047
- '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
3541
+ '<div class="tavatar">' + botPfp(t.name, t.members) + '</div>' +
3048
3542
  '<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
3049
3543
  '">' + escapeHtml(t.dir || '') + ' · type /dir &lt;path&gt; to change</div></div>';
3050
3544
  setModeButtons(t.runMode || 'ask');
@@ -3126,11 +3620,12 @@ const APP_HTML = `<!doctype html>
3126
3620
  w = r.ok ? await r.json() : null;
3127
3621
  } catch (e) { w = null; }
3128
3622
  walletBody.innerHTML = '';
3129
- if (!w || (!w.solana && !w.evm)) {
3623
+ if (!w || (!w.solana && !w.evm && w.creditUsd == null)) {
3130
3624
  const p = document.createElement('div');
3131
3625
  p.className = 'wnote wempty';
3132
- p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds.';
3626
+ p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds. You can still subscribe with a card below.';
3133
3627
  walletBody.appendChild(p);
3628
+ renderSubLane(w && w.subscription ? w.subscription : null);
3134
3629
  return;
3135
3630
  }
3136
3631
  if (w.creditUsd != null && w.creditUsd !== '') {
@@ -3144,7 +3639,17 @@ const APP_HTML = `<!doctype html>
3144
3639
  }
3145
3640
  if (w.solana) walletBody.appendChild(walletRow('Solana', w.solana));
3146
3641
  if (w.evm) walletBody.appendChild(walletRow('Base / RH', w.evm));
3147
- if (w.balances) {
3642
+ if (Array.isArray(w.holdings) && w.holdings.length) {
3643
+ const b = document.createElement('div');
3644
+ b.className = 'wbal';
3645
+ b.textContent = w.holdings.filter((h) => h.chain === 'solana' || Number(h.ui) > 0).map((h) => {
3646
+ const qty = (h.ui) + ' ' + h.symbol + (h.chain && h.chain !== 'solana' ? ' (' + h.chain + ')' : '');
3647
+ if (h.usd == null || !isFinite(Number(h.usd))) return qty;
3648
+ const n = Number(h.usd);
3649
+ return qty + ' ($' + (n >= 0.01 || n === 0 ? n.toFixed(2) : n.toFixed(4)) + ')';
3650
+ }).join('\\n');
3651
+ walletBody.appendChild(b);
3652
+ } else if (w.balances) {
3148
3653
  const b = document.createElement('div');
3149
3654
  b.className = 'wbal';
3150
3655
  b.textContent = w.balances;
@@ -3154,29 +3659,193 @@ const APP_HTML = `<!doctype html>
3154
3659
  note.className = 'wnote';
3155
3660
  // funded === false is the genuinely-empty case. Undefined means the proxy
3156
3661
  // did not say, and guessing "empty" there would send someone to top up a
3157
- // wallet that is fine.
3158
- note.textContent = w.funded === false
3159
- ? 'This wallet is EMPTY — calls will fail with HTTP 402 until you fund the addresses above. ' + (w.funding || '')
3160
- : (w.funding || '');
3161
- if (w.funded === false) note.classList.add('wempty');
3662
+ // wallet that is fine. A live subscription is the other pay lane — do not
3663
+ // nag an empty wallet as fatal when calls already skip x402.
3664
+ const subOn = w.subscription && w.subscription.active;
3665
+ note.textContent = subOn
3666
+ ? ('Wallet is optional while a subscription is active. ' + (w.funding || ''))
3667
+ : (w.funded === false
3668
+ ? 'This wallet is EMPTY — wallet/x402 calls will fail with HTTP 402 until you fund the addresses above, or subscribe with a card below. ' + (w.funding || '')
3669
+ : (w.funding || ''));
3670
+ if (w.funded === false && !subOn) note.classList.add('wempty');
3162
3671
  if (note.textContent.trim()) walletBody.appendChild(note);
3672
+ renderSubLane(w.subscription || null);
3673
+ }
3674
+ var subPollTimer = null;
3675
+ var subBuying = null;
3676
+ function setSubNote(text, empty) {
3677
+ const el = document.getElementById('subNote');
3678
+ if (!el) return;
3679
+ el.textContent = text || '';
3680
+ el.className = empty ? 'wnote wempty' : 'wnote';
3681
+ }
3682
+ function renderSubLane(sub) {
3683
+ const status = document.getElementById('subStatus');
3684
+ const forget = document.getElementById('subForgetBtn');
3685
+ if (status) {
3686
+ status.innerHTML = '';
3687
+ if (sub && sub.active) {
3688
+ const p = document.createElement('div');
3689
+ p.className = 'wsubon';
3690
+ p.textContent = sub.label || (sub.tierName || 'Subscription') + ' · no x402';
3691
+ status.appendChild(p);
3692
+ }
3693
+ }
3694
+ if (forget) forget.hidden = !(sub && sub.active);
3695
+ loadSubTiers();
3696
+ }
3697
+ async function loadSubTiers() {
3698
+ const box = document.getElementById('subTiers');
3699
+ if (!box) return;
3700
+ let body = null;
3701
+ try {
3702
+ const r = await fetch(API + '/billing/tiers');
3703
+ body = r.ok ? await r.json() : null;
3704
+ } catch (e) { body = null; }
3705
+ box.innerHTML = '';
3706
+ const tiers = body && body.ok && Array.isArray(body.tiers) ? body.tiers : [];
3707
+ if (!tiers.length) {
3708
+ const p = document.createElement('div');
3709
+ p.className = 'wnote wempty';
3710
+ p.textContent = 'Could not load live plans from zoo.openzoo.fun — try again, or use the full subscriptions page.';
3711
+ box.appendChild(p);
3712
+ return;
3713
+ }
3714
+ tiers.forEach(function (t) {
3715
+ const art = document.createElement('div');
3716
+ art.className = 'wtier' + (t.id === 'pro' ? ' hot' : '');
3717
+ art.setAttribute('data-tier', t.id);
3718
+ const tag = document.createElement('div');
3719
+ tag.className = 'wtag';
3720
+ tag.textContent = t.id === 'pro' ? 'Most teams want this' : '';
3721
+ const name = document.createElement('div');
3722
+ name.className = 'wtn';
3723
+ name.textContent = t.name || t.id;
3724
+ const price = document.createElement('div');
3725
+ price.className = 'wtp';
3726
+ price.textContent = '$' + ((Number(t.monthlyCents) || 0) / 100).toFixed(0) + '/mo';
3727
+ const share = document.createElement('div');
3728
+ share.className = 'wts';
3729
+ share.textContent = (t.savingsSharePct != null ? t.savingsSharePct : '?') + '% savings share';
3730
+ const blurb = document.createElement('div');
3731
+ blurb.className = 'wtb';
3732
+ blurb.textContent = t.blurb || '';
3733
+ const btn = document.createElement('button');
3734
+ btn.type = 'button';
3735
+ btn.textContent = subBuying === t.id ? 'Opening checkout…' : ('Get ' + (t.name || t.id));
3736
+ btn.disabled = subBuying != null;
3737
+ btn.addEventListener('click', function () { buyTier(t.id); });
3738
+ art.append(tag, name, price, share, blurb, btn);
3739
+ box.appendChild(art);
3740
+ });
3741
+ }
3742
+ function openSystemBrowser(url) {
3743
+ // Electron's setWindowOpenHandler routes target=_blank to shell.openExternal.
3744
+ // Never load Stripe inside this window.
3745
+ window.open(url, '_blank', 'noopener,noreferrer');
3746
+ }
3747
+ function stopSubPoll() {
3748
+ if (subPollTimer) { clearInterval(subPollTimer); subPollTimer = null; }
3749
+ }
3750
+ function startSubPoll(sessionId) {
3751
+ stopSubPoll();
3752
+ var tries = 0;
3753
+ async function tick() {
3754
+ tries += 1;
3755
+ try {
3756
+ const r = await fetch(API + '/billing/key?session=' + encodeURIComponent(sessionId));
3757
+ const j = r.ok ? await r.json() : null;
3758
+ if (j && j.saved) {
3759
+ stopSubPoll();
3760
+ setSubNote('Subscription key saved · no x402');
3761
+ await openWallet();
3762
+ return;
3763
+ }
3764
+ if (j && j.pending) setSubNote('Waiting for Stripe to confirm…');
3765
+ else if (j && j.error && j.error !== 'session required') setSubNote(j.error, true);
3766
+ } catch (e) { /* keep polling */ }
3767
+ if (tries >= 60) {
3768
+ stopSubPoll();
3769
+ setSubNote('Still waiting on Stripe — paste the key from the success page if you have it.');
3770
+ }
3771
+ }
3772
+ subPollTimer = setInterval(tick, 2000);
3773
+ tick();
3774
+ }
3775
+ async function buyTier(tier) {
3776
+ subBuying = tier;
3777
+ setSubNote('');
3778
+ loadSubTiers();
3779
+ try {
3780
+ const r = await fetch(API + '/billing/checkout', {
3781
+ method: 'POST', headers: { 'content-type': 'application/json' },
3782
+ body: JSON.stringify({ tier: tier }),
3783
+ });
3784
+ const j = await r.json();
3785
+ if (!j || !j.ok || !j.url) throw new Error((j && j.error) || 'checkout failed');
3786
+ openSystemBrowser(j.url);
3787
+ setSubNote('Checkout opened in your system browser. This window will pick up the key when Stripe confirms.');
3788
+ if (j.sessionId) startSubPoll(j.sessionId);
3789
+ } catch (e) {
3790
+ setSubNote(e.message || String(e), true);
3791
+ }
3792
+ subBuying = null;
3793
+ loadSubTiers();
3794
+ }
3795
+ async function savePastedSub() {
3796
+ const inp = document.getElementById('subKeyInp');
3797
+ const paste = inp ? inp.value.trim() : '';
3798
+ if (!paste) { setSubNote('Paste a key or the billing/done URL.', true); return; }
3799
+ try {
3800
+ const r = await fetch(API + '/billing/key', {
3801
+ method: 'POST', headers: { 'content-type': 'application/json' },
3802
+ body: JSON.stringify({ paste: paste }),
3803
+ });
3804
+ const j = await r.json();
3805
+ if (j && j.pending && j.session) {
3806
+ setSubNote('Waiting for Stripe to confirm…');
3807
+ startSubPoll(j.session);
3808
+ return;
3809
+ }
3810
+ if (!j || !j.saved) throw new Error((j && j.error) || 'could not save key');
3811
+ if (inp) inp.value = '';
3812
+ setSubNote('Subscription key saved · no x402');
3813
+ await openWallet();
3814
+ } catch (e) {
3815
+ setSubNote(e.message || String(e), true);
3816
+ }
3817
+ }
3818
+ async function forgetSub() {
3819
+ try {
3820
+ await fetch(API + '/billing/key', { method: 'DELETE' });
3821
+ } catch (e) { /* still refresh */ }
3822
+ setSubNote('Subscription key removed. Wallet/x402 is the pay method again.');
3823
+ await openWallet();
3163
3824
  }
3164
3825
  document.getElementById('walletBtn').addEventListener('click', openWallet);
3165
- // First launch: if the burner is empty, open the wallet once so they see
3166
- // addresses they can copy — and whose wallet this is. localStorage so a
3167
- // funded session or a dismiss does not keep popping it.
3826
+ const subKeyBtn = document.getElementById('subKeyBtn');
3827
+ if (subKeyBtn) subKeyBtn.addEventListener('click', savePastedSub);
3828
+ const subForgetBtn = document.getElementById('subForgetBtn');
3829
+ if (subForgetBtn) subForgetBtn.addEventListener('click', forgetSub);
3830
+ const subKeyInp = document.getElementById('subKeyInp');
3831
+ if (subKeyInp) subKeyInp.addEventListener('keydown', function (e) {
3832
+ if (e.key === 'Enter') { e.preventDefault(); savePastedSub(); }
3833
+ });
3834
+ // First launch: if the burner is empty AND there is no subscription, open
3835
+ // the wallet once so they see addresses — and the card lane. localStorage
3836
+ // so a funded session, a saved key, or a dismiss does not keep popping it.
3168
3837
  (async function maybeOpenWalletOnce() {
3169
3838
  if (localStorage.getItem('openzoo.wallet.seen')) return;
3170
3839
  for (let i = 0; i < 8; i++) {
3171
3840
  try {
3172
3841
  const r = await fetch(API + '/wallet');
3173
3842
  const w = r.ok ? await r.json() : null;
3174
- if (!w || (!w.solana && !w.evm)) {
3843
+ if (!w || (!w.solana && !w.evm && !(w.subscription && w.subscription.active))) {
3175
3844
  await new Promise((res) => setTimeout(res, 400));
3176
3845
  continue;
3177
3846
  }
3178
3847
  localStorage.setItem('openzoo.wallet.seen', '1');
3179
- if (w.funded === false) await openWallet();
3848
+ if (w.funded === false && !(w.subscription && w.subscription.active)) await openWallet();
3180
3849
  return;
3181
3850
  } catch (e) {
3182
3851
  await new Promise((res) => setTimeout(res, 400));
@@ -3217,6 +3886,121 @@ const APP_HTML = `<!doctype html>
3217
3886
  document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
3218
3887
 
3219
3888
  function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
3889
+ function stripThinkTags(s) {
3890
+ s = String(s == null ? '' : s);
3891
+ s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*?<\\/think(?:ing)?>/gi, '');
3892
+ s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*$/i, '');
3893
+ s = s.replace(/<\\/think(?:ing)?>/gi, '');
3894
+ return s.replace(/^\\n+|\\n+$/g, '').trim();
3895
+ }
3896
+ function clientWorkspaceUrl(rel) {
3897
+ if (!workspacePort || !activeId) return '';
3898
+ rel = String(rel || '').replace(/^\\/+/, '');
3899
+ return 'http://localhost:' + workspacePort + '/' + activeId + '/' + rel;
3900
+ }
3901
+ function relFromDiskPath(p) {
3902
+ p = String(p || '');
3903
+ if (p.indexOf('file://') === 0) p = decodeURIComponent(p.slice(7));
3904
+ const t = knownThreads.find(function (x) { return x.id === activeId; });
3905
+ const dir = (t && t.dir) || '';
3906
+ if (dir && (p === dir || p.indexOf(dir + '/') === 0)) {
3907
+ return p.slice(dir.length).replace(/^\\/+/, '');
3908
+ }
3909
+ const marker = '/grokui-workspace/';
3910
+ const i = p.indexOf(marker);
3911
+ if (i >= 0) return p.slice(i + marker.length);
3912
+ const base = p.split('/').pop() || '';
3913
+ return /\\.(html?|HTML?)$/.test(base) ? base : '';
3914
+ }
3915
+ function servedHrefForPath(p) {
3916
+ p = String(p || '');
3917
+ const t = knownThreads.find(function (x) { return x.id === activeId; });
3918
+ const dir = (t && t.dir) || '';
3919
+ if (dir && p === dir) return clientWorkspaceUrl('');
3920
+ if (/\\.(html?|HTML?)$/.test(p) || p.indexOf('grokui-workspace') >= 0 || (dir && p.indexOf(dir) === 0)) {
3921
+ return clientWorkspaceUrl(relFromDiskPath(p));
3922
+ }
3923
+ return '';
3924
+ }
3925
+ // Turn "Wrote foo.html" and /Users/.../foo.html into the live localhost URL
3926
+ // the workspace server already exposes — never a file:// Electron blocks.
3927
+ function linkWorkspacePaths(o) {
3928
+ o = o.replace(/\\b(Wrote|Edited)\\s+([^\\n<]+?)\\s+\\(/g, function (m, verb, file) {
3929
+ const f = file.trim();
3930
+ if (!/\\.(html?|HTML?)$/.test(f)) return m;
3931
+ const url = clientWorkspaceUrl(f);
3932
+ if (!url) return m;
3933
+ return verb + ' <a href="' + url + '" target="_blank" rel="noopener">' + f + '</a> (';
3934
+ });
3935
+ o = o.replace(/\\b(MULTIEDIT)\\s+([^\\s:<]+\\.(?:html?|HTML?))/g, function (m, verb, file) {
3936
+ const url = clientWorkspaceUrl(file);
3937
+ if (!url) return m;
3938
+ return verb + ' <a href="' + url + '" target="_blank" rel="noopener">' + file + '</a>';
3939
+ });
3940
+ o = o.replace(/file:\\/\\/([^\\s<)]+)/g, function (m, raw) {
3941
+ return servedHrefForPath(decodeURIComponent(raw)) || m;
3942
+ });
3943
+ o = o.replace(/(^|[\\s(])((?:\\/Users\\/|\\/home\\/|\\/opt\\/|\\/workspace\\/|\\/tmp\\/|\\/var\\/|~\\/)[^\\s<)]+)/g, function (m, pre, raw) {
3944
+ const punct = /[.,;:]+$/.exec(raw);
3945
+ const p = punct ? raw.slice(0, -punct[0].length) : raw;
3946
+ const href = servedHrefForPath(p);
3947
+ if (!href) return m;
3948
+ return pre + '<a href="' + href + '" target="_blank" rel="noopener">' + p + '</a>' + (punct ? punct[0] : '');
3949
+ });
3950
+ return o;
3951
+ }
3952
+ function htmlPreviewUrl(text) {
3953
+ const s = String(text || '');
3954
+ let m = /https?:\\/\\/localhost:\\d+\\/\\S+\\.(?:html?|HTML?)/.exec(s);
3955
+ if (m) return m[0].replace(/[.,;)]+$/, '');
3956
+ m = /(?:Preview:|Serving at)\\s+(https?:\\/\\/localhost:\\d+\\/\\S+)/.exec(s);
3957
+ if (m) {
3958
+ const u = m[1].replace(/[.,;)]+$/, '');
3959
+ if (/\\.(html?|HTML?)/.test(u) || /\\/[0-9a-fA-F-]{36}\\/?$/.test(u)) return u;
3960
+ }
3961
+ m = /\\b(?:Wrote|Edited|MULTIEDIT)\\s+([^\\n]+?)\\s*(?:\\(|:)/.exec(s);
3962
+ if (m && /\\.(html?|HTML?)$/.test(m[1].trim())) return clientWorkspaceUrl(m[1].trim());
3963
+ m = /(?:\\/Users\\/|\\/home\\/|\\/workspace\\/)\\S+\\.(?:html?|HTML?)/.exec(s);
3964
+ if (m) return servedHrefForPath(m[0].replace(/[.,;)]+$/, '')) || '';
3965
+ return '';
3966
+ }
3967
+ function htmlPreviewKey(text, url) {
3968
+ const bytes = /\\((\\d+)\\s+bytes/.exec(text) || /->\\s+(\\d+)\\s+bytes/.exec(text);
3969
+ return url + '#' + (bytes ? bytes[1] : '0');
3970
+ }
3971
+ let parkedPreviews = {};
3972
+ function parkPreviews() {
3973
+ const parked = {};
3974
+ const nodes = log.querySelectorAll('.html-preview-wrap');
3975
+ for (let i = 0; i < nodes.length; i++) {
3976
+ const el = nodes[i];
3977
+ const k = el.getAttribute('data-preview');
3978
+ if (k) parked[k] = el;
3979
+ el.remove();
3980
+ }
3981
+ return parked;
3982
+ }
3983
+ function previewFrame(url, key) {
3984
+ const existing = parkedPreviews[key];
3985
+ if (existing) { delete parkedPreviews[key]; return existing; }
3986
+ const wrap = document.createElement('div');
3987
+ wrap.className = 'html-preview-wrap';
3988
+ wrap.setAttribute('data-preview', key);
3989
+ const open = document.createElement('a');
3990
+ open.className = 'html-preview-open';
3991
+ open.href = url;
3992
+ open.target = '_blank';
3993
+ open.rel = 'noopener';
3994
+ open.textContent = 'open';
3995
+ const frame = document.createElement('iframe');
3996
+ frame.className = 'html-preview';
3997
+ frame.src = url;
3998
+ frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-pointer-lock allow-forms');
3999
+ frame.title = 'preview';
4000
+ wrap.appendChild(open);
4001
+ wrap.appendChild(frame);
4002
+ return wrap;
4003
+ }
3220
4004
  // Inline span-level markdown. Runs AFTER escapeHtml, so every tag below is
3221
4005
  // one we created — model output can never inject its own.
3222
4006
  function mdInline(s) {
@@ -3225,6 +4009,7 @@ const APP_HTML = `<!doctype html>
3225
4009
  o = o.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
3226
4010
  o = o.replace(/(^|[^*])\\*([^*\\n]+)\\*/g, '$1<em>$2</em>');
3227
4011
  o = o.replace(/\\[([^\\]]+)\\]\\((https?:[^)\\s]+)\\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
4012
+ o = linkWorkspacePaths(o);
3228
4013
  // bare URLs, but only at a boundary — inside href="..." the preceding
3229
4014
  // char is a quote, so links we just built are left alone
3230
4015
  o = o.replace(/(^|[\\s(])(https?:\\/\\/[^\\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
@@ -3491,11 +4276,12 @@ const APP_HTML = `<!doctype html>
3491
4276
 
3492
4277
  let lastSpeaker = null;
3493
4278
  function addRow(who, text, color, name, run, images) {
4279
+ if (who === 'bot') text = stripThinkTags(text);
3494
4280
  const speakerKey = who + '|' + name;
3495
4281
  if (who === 'bot' && speakerKey !== lastSpeaker) {
3496
4282
  const hdr = document.createElement('div');
3497
4283
  hdr.className = 'hdr';
3498
- hdr.innerHTML = '<span class="avatar" style="background:' + color + '">' + initials(name) + '</span><span>' + name + '</span>';
4284
+ hdr.innerHTML = '<span class="avatar">' + botPfp(name) + '</span><span>' + name + '</span>';
3499
4285
  log.appendChild(hdr);
3500
4286
  }
3501
4287
  lastSpeaker = speakerKey;
@@ -3562,6 +4348,10 @@ const APP_HTML = `<!doctype html>
3562
4348
  }
3563
4349
  const textEl = document.createElement('div');
3564
4350
  textEl.innerHTML = renderMentions(text);
4351
+ if (who === 'bot') {
4352
+ const preview = htmlPreviewUrl(text);
4353
+ if (preview) textEl.appendChild(previewFrame(preview, htmlPreviewKey(text, preview)));
4354
+ }
3565
4355
  bubble.appendChild(textEl);
3566
4356
  row.appendChild(bubble);
3567
4357
  // Copy the message SOURCE, not rendered HTML — markdown, code fences and
@@ -3582,6 +4372,7 @@ const APP_HTML = `<!doctype html>
3582
4372
  log.appendChild(row);
3583
4373
  }
3584
4374
 
4375
+ let lastRenderKey = '';
3585
4376
  async function render() {
3586
4377
  const t = knownThreads.find((x) => x.id === activeId);
3587
4378
  if (!t) return;
@@ -3589,9 +4380,18 @@ const APP_HTML = `<!doctype html>
3589
4380
  inp.placeholder = 'Message ' + t.name;
3590
4381
  const full = await loadActiveMessages();
3591
4382
  if (!full || full.id !== activeId) return;
4383
+ const renderKey = String(workspacePort) + '|' + full.id + '|' + full.status + '|' + (full.history || []).map(function (h) {
4384
+ return [h.who, h.text, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
4385
+ }).join('||');
4386
+ if (renderKey === lastRenderKey) {
4387
+ if (streamBuf) paintStream();
4388
+ return;
4389
+ }
4390
+ lastRenderKey = renderKey;
3592
4391
  // only re-pin to bottom if the reader was already there — otherwise a
3593
4392
  // background poll (tick() runs every 1.2s) yanks them back mid-scroll
3594
4393
  const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
4394
+ parkedPreviews = parkPreviews();
3595
4395
  log.innerHTML = '';
3596
4396
  lastSpeaker = null;
3597
4397
  for (const h of full.history) {
@@ -3599,11 +4399,12 @@ const APP_HTML = `<!doctype html>
3599
4399
  h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
3600
4400
  }
3601
4401
  if (full.status === 'thinking') {
4402
+ if (full.liveStatus) streamStatus = full.liveStatus;
3602
4403
  addRow('bot', streamBuf || '…', t.color, t.name);
3603
4404
  // Tag the live bubble so deltas can repaint just this node instead of
3604
4405
  // re-rendering (and re-fetching) the whole thread on every token.
3605
4406
  const b = log.querySelector('.row:last-child .bubble');
3606
- if (b) b.id = 'streamBubble';
4407
+ if (b) { b.id = 'streamBubble'; paintStream(); }
3607
4408
  }
3608
4409
  if (wasNearBottom) log.scrollTop = log.scrollHeight;
3609
4410
  }
@@ -3612,13 +4413,28 @@ const APP_HTML = `<!doctype html>
3612
4413
  // The server has always been able to stream; /drive just never asked for it,
3613
4414
  // so a turn showed "…" for its whole duration and then arrived in one lump.
3614
4415
  let streamBuf = '';
4416
+ let streamStatus = '';
3615
4417
  let es = null, esId = null;
4418
+ function liveBubbleHtml() {
4419
+ if (streamBuf) {
4420
+ const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
4421
+ ? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
4422
+ return escapeHtml(stripThinkTags(streamBuf)) + trail;
4423
+ }
4424
+ const dots = '<span class="dots"><span></span><span></span><span></span></span>';
4425
+ const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
4426
+ return dots + (st ? ' ' + st : '');
4427
+ }
3616
4428
  function paintStream() {
3617
4429
  const b = document.getElementById('streamBubble');
3618
4430
  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 || '…';
4431
+ // Deltas stay as text; a silent wait paints dots + one mutating status
4432
+ // line so a 20–40s pay/model wait is obviously alive.
4433
+ if (streamBuf && !(streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus))) {
4434
+ b.textContent = streamBuf;
4435
+ } else {
4436
+ b.innerHTML = liveBubbleHtml();
4437
+ }
3622
4438
  if (log.scrollHeight - log.scrollTop - log.clientHeight < 140) log.scrollTop = log.scrollHeight;
3623
4439
  }
3624
4440
  function connectStream(id) {
@@ -3626,13 +4442,15 @@ const APP_HTML = `<!doctype html>
3626
4442
  if (es) es.close();
3627
4443
  esId = id;
3628
4444
  streamBuf = '';
4445
+ streamStatus = '';
3629
4446
  es = new EventSource('/stream/' + id); // EventSource reconnects on its own
3630
4447
  es.onmessage = (e) => {
3631
4448
  let ev;
3632
4449
  try { ev = JSON.parse(e.data); } catch { return; }
3633
- if (ev.type === 'start') { streamBuf = ''; paintStream(); }
4450
+ if (ev.type === 'start') { streamBuf = ''; streamStatus = ev.detail || 'waiting on model…'; paintStream(); }
4451
+ else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
3634
4452
  else if (ev.type === 'delta') { streamBuf += ev.delta || ''; paintStream(); }
3635
- else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; render(); }
4453
+ else if (ev.type === 'final' || ev.type === 'run-pending') { streamBuf = ''; streamStatus = ''; render(); }
3636
4454
  };
3637
4455
  es.onerror = () => { /* EventSource retries; the 1.2s poll is the backstop */ };
3638
4456
  }
@@ -3828,7 +4646,7 @@ const APP_HTML = `<!doctype html>
3828
4646
  composeList.innerHTML = '';
3829
4647
  const createRow = document.createElement('div');
3830
4648
  createRow.className = 'crow';
3831
- createRow.innerHTML = '<div class="tavatar" style="background:#3a3a3c;width:28px;height:28px;border-radius:8px;font-size:15px">+</div>' +
4649
+ createRow.innerHTML = '<div class="tavatar tavatar-sm tavatar-plus">+</div>' +
3832
4650
  '<div>Create new Bot' + (q ? ': ' + escapeHtml(composeInp.value.trim()) : '') + '</div>' +
3833
4651
  '<div class="kbd"><kbd>⌘</kbd><kbd>1</kbd></div>';
3834
4652
  createRow.addEventListener('click', async () => {
@@ -3843,7 +4661,7 @@ const APP_HTML = `<!doctype html>
3843
4661
  candidates.slice(0, 8).forEach((t, i) => {
3844
4662
  const row = document.createElement('div');
3845
4663
  row.className = 'crow';
3846
- row.innerHTML = '<div class="tavatar" style="background:' + t.color + ';width:28px;height:28px;border-radius:8px;font-size:11px">' + initials(t.name) + '</div>' +
4664
+ row.innerHTML = '<div class="tavatar tavatar-sm">' + botPfp(t.name) + '</div>' +
3847
4665
  '<div>' + escapeHtml(t.name) + '</div><div class="kbd"><kbd>⌘</kbd><kbd>' + (i + 2) + '</kbd></div>';
3848
4666
  row.addEventListener('click', () => addChip(t));
3849
4667
  composeList.appendChild(row);
@@ -3951,6 +4769,16 @@ const APP_HTML = `<!doctype html>
3951
4769
  const you = await (await fetch(API + '/hud-summary')).json();
3952
4770
  const creditEl = document.getElementById('hCredit');
3953
4771
  if (creditEl) creditEl.textContent = (you.creditUsd == null) ? '—' : usd(Number(you.creditUsd) || 0);
4772
+ const subRow = document.getElementById('hSubRow');
4773
+ const subEl = document.getElementById('hSub');
4774
+ if (subRow && subEl) {
4775
+ if (you.subscription && you.subscription.active) {
4776
+ subRow.hidden = false;
4777
+ subEl.textContent = you.subscription.label || you.subscription.tierName || 'Subscription key · no x402';
4778
+ } else {
4779
+ subRow.hidden = true;
4780
+ }
4781
+ }
3954
4782
  const spent = Number(you.spentUsd) || 0;
3955
4783
  const cogs = Number(you.cogsUsd) || 0;
3956
4784
  const direct = Number(you.directUsd) || 0;
@@ -4020,15 +4848,118 @@ const server = http.createServer((req, res) => {
4020
4848
  try { w = await (await fetch(`${PROXY}/wallet`)).json(); }
4021
4849
  catch { /* proxy not up yet — say so rather than render an empty modal */ }
4022
4850
  try {
4023
- const { creditBalance } = await import('./info.js');
4024
4851
  w.creditUsd = await creditBalance();
4025
4852
  } catch { /* leave credit off if the gateway is down */ }
4853
+ // Subscription is local (~/.openzoo/subscription.json). Merge it here so
4854
+ // an older :8402 that does not yet know about Stripe still shows the lane.
4855
+ w.subscription = subscriptionPublicView();
4026
4856
  res.writeHead(200, { 'content-type': 'application/json' });
4027
4857
  res.end(JSON.stringify(w));
4028
4858
  })();
4029
4859
  return;
4030
4860
  }
4031
4861
 
4862
+ // Live Stripe plans — never a stale hardcoded $9/$29/$99. Same origin so
4863
+ // the renderer does not have to talk to zoo.openzoo.fun itself.
4864
+ if (req.method === 'GET' && req.url === '/billing/tiers') {
4865
+ (async () => {
4866
+ try {
4867
+ const body = await billingTiers();
4868
+ res.writeHead(200, { 'content-type': 'application/json' });
4869
+ res.end(JSON.stringify(body));
4870
+ } catch (e) {
4871
+ res.writeHead(502, { 'content-type': 'application/json' });
4872
+ res.end(JSON.stringify({ ok: false, error: e.message }));
4873
+ }
4874
+ })();
4875
+ return;
4876
+ }
4877
+
4878
+ if (req.method === 'POST' && req.url === '/billing/checkout') {
4879
+ const chunks = [];
4880
+ req.on('data', (d) => chunks.push(d));
4881
+ req.on('end', async () => {
4882
+ let tier = '';
4883
+ try { tier = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').tier || ''; }
4884
+ catch { /* ignore */ }
4885
+ try {
4886
+ const body = await billingCheckout(tier);
4887
+ res.writeHead(200, { 'content-type': 'application/json' });
4888
+ res.end(JSON.stringify(body));
4889
+ } catch (e) {
4890
+ res.writeHead(502, { 'content-type': 'application/json' });
4891
+ res.end(JSON.stringify({ ok: false, error: e.message }));
4892
+ }
4893
+ });
4894
+ return;
4895
+ }
4896
+
4897
+ // GET ?session= polls the same endpoint the public /billing/done page uses.
4898
+ // On a key, persist it locally and return a public view — never the secret
4899
+ // (this UI can sit on a public box URL).
4900
+ if (req.method === 'GET' && (req.url || '').startsWith('/billing/key')) {
4901
+ (async () => {
4902
+ const q = new URL(req.url, 'http://x').searchParams;
4903
+ const session = q.get('session') || q.get('session_id') || '';
4904
+ try {
4905
+ const body = await fetchBillingKey(session);
4906
+ res.writeHead(200, { 'content-type': 'application/json' });
4907
+ res.end(JSON.stringify(ingestBillingKeyResponse(body, {
4908
+ sessionId: session,
4909
+ tier: q.get('tier') || null,
4910
+ })));
4911
+ } catch (e) {
4912
+ res.writeHead(502, { 'content-type': 'application/json' });
4913
+ res.end(JSON.stringify({ ok: false, error: e.message }));
4914
+ }
4915
+ })();
4916
+ return;
4917
+ }
4918
+
4919
+ if (req.method === 'POST' && req.url === '/billing/key') {
4920
+ const chunks = [];
4921
+ req.on('data', (d) => chunks.push(d));
4922
+ req.on('end', async () => {
4923
+ let paste = '';
4924
+ let tier = '';
4925
+ try {
4926
+ const j = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
4927
+ paste = j.paste || j.key || '';
4928
+ tier = j.tier || '';
4929
+ } catch { /* ignore */ }
4930
+ const parsed = parseSubscriptionPaste(paste);
4931
+ if (parsed.error) {
4932
+ res.writeHead(400, { 'content-type': 'application/json' });
4933
+ res.end(JSON.stringify({ ok: false, error: parsed.error }));
4934
+ return;
4935
+ }
4936
+ if (parsed.session) {
4937
+ try {
4938
+ const body = await fetchBillingKey(parsed.session);
4939
+ const out = ingestBillingKeyResponse(body, { sessionId: parsed.session, tier });
4940
+ if (out.pending) out.session = parsed.session;
4941
+ res.writeHead(200, { 'content-type': 'application/json' });
4942
+ res.end(JSON.stringify(out));
4943
+ } catch (e) {
4944
+ res.writeHead(502, { 'content-type': 'application/json' });
4945
+ res.end(JSON.stringify({ ok: false, error: e.message }));
4946
+ }
4947
+ return;
4948
+ }
4949
+ saveSubscription({ key: parsed.key, tier: tier || null });
4950
+ res.writeHead(200, { 'content-type': 'application/json' });
4951
+ res.end(JSON.stringify({ ok: true, saved: true, ...subscriptionPublicView() }));
4952
+ });
4953
+ return;
4954
+ }
4955
+
4956
+ if (req.method === 'DELETE' && req.url === '/billing/key') {
4957
+ clearSubscription();
4958
+ res.writeHead(200, { 'content-type': 'application/json' });
4959
+ res.end(JSON.stringify({ ok: true, saved: false, ...subscriptionPublicView() }));
4960
+ return;
4961
+ }
4962
+
4032
4963
  // Restart grokui in place — and ACTUALLY PICK UP THE NEW BUILD.
4033
4964
  //
4034
4965
  // Exiting is the restart: on a production box, box-server's ensureOz() poll
@@ -4078,13 +5009,13 @@ const server = http.createServer((req, res) => {
4078
5009
 
4079
5010
  if (req.method === 'GET' && req.url === '/hud-summary') {
4080
5011
  (async () => {
4081
- let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null };
5012
+ let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
4082
5013
  try { you = { ...you, ...(await (await fetch('http://127.0.0.1:8402/v1/session')).json()) }; }
4083
5014
  catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
4084
5015
  try {
4085
- const { creditBalance } = await import('./info.js');
4086
5016
  you.creditUsd = await creditBalance();
4087
5017
  } catch { /* credit is advisory */ }
5018
+ you.subscription = subscriptionPublicView();
4088
5019
  res.writeHead(200, { 'content-type': 'application/json' });
4089
5020
  res.end(JSON.stringify(you));
4090
5021
  })();
@@ -4165,7 +5096,11 @@ const server = http.createServer((req, res) => {
4165
5096
  if (req.method === 'GET' && req.url.startsWith('/threads/')) {
4166
5097
  const t = threads.get(req.url.split('/')[2]);
4167
5098
  res.writeHead(200, { 'content-type': 'application/json' });
4168
- res.end(t ? JSON.stringify({ id: t.id, history: t.history, status: t.status }) : '{}');
5099
+ res.end(t ? JSON.stringify({
5100
+ id: t.id, history: t.history, status: t.status,
5101
+ liveStatus: t.liveStatus || '',
5102
+ workspacePort: workspacePort || 0, dir: t.dir || WORKSPACE_DIR,
5103
+ }) : '{}');
4169
5104
  return;
4170
5105
  }
4171
5106
  if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
@@ -4298,3 +5233,8 @@ const server = http.createServer((req, res) => {
4298
5233
  });
4299
5234
 
4300
5235
  server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0' ? 'localhost' : BIND}:${PORT}`));
5236
+
5237
+ export {
5238
+ tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
5239
+ parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
5240
+ };