openzoo 0.48.89 → 0.48.94

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
@@ -14,6 +14,12 @@ import path from 'node:path';
14
14
  import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES } from './podagent.mjs';
15
15
  import { peekDirectiveStatus, STALE_THINKING_MS } from './livestatus.js';
16
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';
17
23
 
18
24
  const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
19
25
  // BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
@@ -28,9 +34,12 @@ const STORE_FILE = path.join(STORE_DIR, 'grokui-threads.json');
28
34
  // Real but SANDBOXED filesystem access for the bots — each THREAD has its own
29
35
  // root dir (default: a dedicated workspace, never the user's whole disk), and
30
36
  // the user can point a thread at a real project folder with "/dir <path>" in
31
- // chat. safeResolveIn rejects any path that would escape that thread's root
32
- // (../, absolute paths, symlink tricks via normalize) access is real, but
33
- // 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.
34
43
  // Where a thread's WRITE/READ/RUN/LS/GLOB/GREP are scoped by default.
35
44
  //
36
45
  // Overridable because a BOX puts uploaded files somewhere else: box-server
@@ -45,13 +54,48 @@ const WORKSPACE_DIR = process.env.OZ_WORKSPACE_DIR
45
54
  mkdirSync(WORKSPACE_DIR, { recursive: true });
46
55
  function expandHome(p) { return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p; }
47
56
  function dirFor(threadId) { return threads.get(threadId)?.dir || WORKSPACE_DIR; }
48
- function safeResolveIn(base, rel) {
49
- const full = path.normalize(path.join(base, rel));
50
- 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)) {
51
66
  throw new Error("path escapes this thread's directory");
52
67
  }
53
68
  return full;
54
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
+ }
55
99
  const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
56
100
  mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
57
101
  jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
@@ -177,10 +221,12 @@ subtasks), you may instead reply with EXACTLY one line, no prose, using one of:
177
221
  independent agent and give it a task
178
222
  SEND: <name> | <message> message an agent thread that already
179
223
  exists (yours or one you spawned)
180
- PING: <name> one-line status: still working, or
181
- its last result
224
+ PING: <name> wake that agent to take a turn now
225
+ (* / all / everyone = the whole project).
226
+ You get back "pinged, working" — that is
227
+ an ack, not the child's result
182
228
  PEEK: <name> a fuller look — its last few messages,
183
- not just the latest one
229
+ not just the latest one. Read-only.
184
230
  You are given the result before your next line, so none of these block you — check back
185
231
  later if it's still working.
186
232
 
@@ -381,6 +427,18 @@ function loadThreads() {
381
427
  t.status = 'idle';
382
428
  t.liveStatus = '';
383
429
  }
430
+ if (Array.isArray(t.history)) {
431
+ for (const h of t.history) {
432
+ if (h && h.who === 'bot' && typeof h.text === 'string') h.text = stripThinkTags(h.text);
433
+ }
434
+ }
435
+ if (Array.isArray(t.messages)) {
436
+ for (const m of t.messages) {
437
+ if (m && m.role === 'assistant' && typeof m.content === 'string') {
438
+ m.content = stripThinkTags(m.content);
439
+ }
440
+ }
441
+ }
384
442
  threads.set(t.id, t);
385
443
  }
386
444
  return true;
@@ -444,7 +502,7 @@ spawn/delegate/create agents AND no other bot has already done it this round, re
444
502
  EXACTLY one line, no prose, using one of:
445
503
  SPAWN: <short name> | <task for the new agent> create a new thread with its own agent
446
504
  SEND: <name> | <message> message an existing agent thread
447
- PING: <name> one-line status, or its last result
505
+ PING: <name> wake that agent (* / all = the project)
448
506
  PEEK: <name> a fuller look at its last few messages
449
507
 
450
508
  You ALSO have real (sandboxed) filesystem access, scoped to THIS group's own directory —
@@ -605,6 +663,32 @@ const NUDGE = 'That reply announced work instead of doing it — no directive li
605
663
  const AUTO_CONTINUE = 'AUTO is still on — do not stop and do not ask the user to type continue. '
606
664
  + 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
607
665
  + 'or DONE: if the job is actually finished.';
666
+ // PING used to be a read: last-line status, no turn. Idle children stayed idle
667
+ // while the parent treated the dump as evidence they had acted. A ping is a
668
+ // wake — the same harness continue AUTO already uses, unless a custom message
669
+ // was given. Thinking threads are left alone; pendingRun stays on the human.
670
+ let runTurnOverride = null;
671
+ function setRunTurnForTest(fn) {
672
+ runTurnOverride = typeof fn === 'function' ? fn : null;
673
+ }
674
+ function kickTurn(threadId, userText, onEvent, images) {
675
+ return (runTurnOverride || runTurn)(threadId, userText, onEvent, images);
676
+ }
677
+ function pingWakeText(extra) {
678
+ const msg = String(extra || '').trim();
679
+ // A restated spawn brief is not a nudge. MEASURED live: existing-SPAWN
680
+ // wrapped children in childKickoff({fresh:false}) → "CONTEXT REFRESH —
681
+ // you already exist" and they thought, then refused to redo the job.
682
+ if (!msg || /CONTEXT REFRESH|--- your specific job ---|ROOT ASK —/.test(msg)) return AUTO_CONTINUE;
683
+ return msg;
684
+ }
685
+ function pingCanWake(x) {
686
+ return Boolean(x) && !x.pendingRun && x.status !== 'thinking';
687
+ }
688
+ function wakeOnPing(x, extra) {
689
+ // Never childKickoff. Ping is a short continue, not a first-day re-brief.
690
+ kickTurn(x.id, pingWakeText(extra)).catch(() => {});
691
+ }
608
692
  // Ceiling on subagents per thread. Spawning is fire-and-forget and each child
609
693
  // can spawn too, so without a count it is unbounded — MEASURED as 15+ threads
610
694
  // all named tetris-contract, every one of them a live agent making paid calls.
@@ -721,6 +805,21 @@ function sanitizeRunCommand(command) {
721
805
  //
722
806
  // Also tolerates the directive being wrapped in a markdown code fence, which
723
807
  // is the other shape models reach for unprompted.
808
+ const MCP_AS_BASH_REFUSE = 'That RUN: body is MCP tool names, not a shell command. '
809
+ + 'get_skill, proofnetwork-*, publish-update, and MCP: lines must not be executed by bash '
810
+ + '— that is how a live thread printed `/bin/bash: get_skill: command not found`. '
811
+ + 'Emit a real MCP call instead:\n'
812
+ + ' MCP: <url> | <tool> | {"arg": "value"}\n'
813
+ + 'or list tools with:\n'
814
+ + ' MCP: <url>';
815
+
816
+ /** Skill names / MCP: lines the model listed, then a RUN: tried to shell. */
817
+ function looksLikeMcpAsBash(command) {
818
+ const text = String(command || '');
819
+ if (/^[ \t>*-]*MCP:/m.test(text)) return true;
820
+ return /^(?:[ \t>*-]*)(?:get_skill|publish-update|proofnetwork[-_][A-Za-z0-9._-]*)\b/im.test(text);
821
+ }
822
+
724
823
  function parseRun(reply) {
725
824
  // NATIVE TOOL-CALL ENVELOPE FIRST. deepseek-v4-pro has real function calling,
726
825
  // and when told to emit "RUN: <cmd>" it frequently wraps the call in its own
@@ -747,10 +846,8 @@ function parseRun(reply) {
747
846
  // the whole envelope was dropped in silence: the bot then explained what it
748
847
  // was "about to run" forever, never running anything. Match the shape of the
749
848
  // envelope, not one vendor's spelling of it.
750
- const SEP = '[||\\s]*';
751
- const NAME = '(?:command|cmd|shell_command|script)';
752
- const dsml = new RegExp(`<${SEP}DSML[^>]*\\bparameter\\b[^>]*\\bname="${NAME}"[^>]*>([\\s\\S]*?)<\\/${SEP}DSML`, 'i').exec(reply);
753
- if (dsml) return sanitizeRunCommand(dsml[1]);
849
+ const dsmlCmd = dsmlRunCommand(reply);
850
+ if (dsmlCmd !== undefined) return dsmlCmd && !looksLikeMcpAsBash(dsmlCmd) ? dsmlCmd : null;
754
851
 
755
852
  // SEVERAL "RUN:" LINES IN ONE REPLY RUN BACK TO BACK.
756
853
  //
@@ -782,12 +879,26 @@ function parseRun(reply) {
782
879
  const fenced = /^```[\w-]*\n([\s\S]*?)```/.exec(cmd.trim());
783
880
  if (fenced) cmd = fenced[1];
784
881
  else cmd = cmd.replace(/\n```[\s\S]*$/, ''); // trailing fence + any posttext
882
+ cmd = sliceToNextDirective(cmd);
785
883
  cmd = sanitizeRunCommand(cmd);
884
+ // A RUN: that swallowed MCP: / get_skill / proofnetwork-* is the
885
+ // over-match that produced `/bin/bash: line 3: RUN:: command not found`
886
+ // and then `/bin/bash: get_skill: command not found`. Refuse the batch
887
+ // rather than join skill names into one script.
888
+ if (cmd && looksLikeMcpAsBash(cmd)) return null;
786
889
  if (cmd) cmds.push(cmd);
787
890
  }
788
891
  return cmds.length ? cmds.join('\n') : null;
789
892
  }
790
893
 
894
+ function dsmlRunCommand(reply) {
895
+ const SEP = '[||\\s]*';
896
+ const NAME = '(?:command|cmd|shell_command|script)';
897
+ const dsml = new RegExp(`<${SEP}DSML[^>]*\\bparameter\\b[^>]*\\bname="${NAME}"[^>]*>([\\s\\S]*?)<\\/${SEP}DSML`, 'i').exec(reply);
898
+ if (!dsml) return undefined;
899
+ return sanitizeRunCommand(dsml[1]);
900
+ }
901
+
791
902
  // RUN through BASH, not /bin/sh. node's exec() defaults to /bin/sh, which on
792
903
  // Debian is dash — so every bash-ism a model writes (`for … do`, `[[ ]]`,
793
904
  // arrays, process substitution) dies as
@@ -844,7 +955,7 @@ const SLASH_COMMANDS = [
844
955
  { name: '/memory', args: '[text|clear]', help: 'facts injected into every turn' },
845
956
  { name: '/sessions', args: '', help: 'list all threads' },
846
957
  { name: '/all', args: '<message>', help: 'send a message to every bot in this project' },
847
- { name: '/ping', args: '', help: 'status of every bot in this project' },
958
+ { name: '/ping', args: '', help: 'wake idle bots below you to take a turn now' },
848
959
  { name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
849
960
  { name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
850
961
  { name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
@@ -954,7 +1065,8 @@ async function handleSlash(task, t) {
954
1065
  + ' SPAWN: <name> | <task> a NEW subagent (names are unique —\n'
955
1066
  + ' spawning an existing one sends to it)\n'
956
1067
  + ' SEND: <name> | <msg> more work for an EXISTING subagent\n'
957
- + ' PING / PEEK reach or inspect another bot\n\n'
1068
+ + ' PING: <name> wake that bot (* wakes the project)\n'
1069
+ + ' PEEK: <name> read-only look at another bot\n\n'
958
1070
  + 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
959
1071
  + 'appear in one reply — four files cost one round trip, not four.';
960
1072
  }
@@ -1127,19 +1239,26 @@ async function handleSlash(task, t) {
1127
1239
  return `Sent down your branch to ${crew.length} bot(s): ${crew.map((x) => x.name).join(', ')}`;
1128
1240
  }
1129
1241
 
1130
- // Read the room without spending anything: who is working, who is blocked on
1131
- // an approval, what each said last.
1242
+ // Wake the room. Used to be a free last-line dump idle children stayed
1243
+ // idle, and a parent reading "kid: <old reply>" thought they had acted.
1244
+ // Empty extra is a nudge (AUTO_CONTINUE), not a cancel. Thinking stays
1245
+ // thinking; pendingRun stays on the human. Same branch scope as /all.
1132
1246
  if (cmd === 'ping') {
1133
- // Same scoping as /all: your branch, not the whole project.
1134
1247
  const crew = subtreeOf(t.id, true);
1135
1248
  if (crew.length < 2) return 'You have no subagents yet.';
1136
1249
  return crew.map((x) => {
1137
1250
  const mark = x.id === t.id ? ' (here)' : '';
1138
- const last = x.history[x.history.length - 1];
1139
- return x.pendingRun ? ` ${x.name}${mark}: BLOCKED — waiting for your approval`
1140
- : x.status === 'thinking' ? ` ${x.name}${mark}: working`
1141
- : last ? ` ${x.name}${mark}: ${String(last.text).replace(/\s+/g, ' ').slice(0, 90)}`
1142
- : ` ${x.name}${mark}: nothing yet`;
1251
+ if (x.id === t.id) {
1252
+ const last = x.history[x.history.length - 1];
1253
+ return x.pendingRun ? ` ${x.name}${mark}: BLOCKED — waiting for your approval`
1254
+ : x.status === 'thinking' ? ` ${x.name}${mark}: working`
1255
+ : last ? ` ${x.name}${mark}: ${String(last.text).replace(/\s+/g, ' ').slice(0, 90)}`
1256
+ : ` ${x.name}${mark}: nothing yet`;
1257
+ }
1258
+ if (x.pendingRun) return ` ${x.name}${mark}: BLOCKED — waiting for your approval`;
1259
+ if (x.status === 'thinking') return ` ${x.name}${mark}: working`;
1260
+ wakeOnPing(x, arg);
1261
+ return ` ${x.name}${mark}: pinged, working`;
1143
1262
  }).join('\n');
1144
1263
  }
1145
1264
 
@@ -1228,7 +1347,10 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__
1228
1347
  function walkDir(base, rel = '', out = [], depth = 0) {
1229
1348
  if (depth > 12 || out.length > 5000) return out;
1230
1349
  let entries = [];
1231
- try { entries = readdirSync(path.join(base, rel), { withFileTypes: true }); } catch { return out; }
1350
+ try {
1351
+ const dir = rel && path.isAbsolute(rel) ? inDir(base, rel) : path.join(base, rel);
1352
+ entries = readdirSync(dir, { withFileTypes: true });
1353
+ } catch { return out; }
1232
1354
  for (const e of entries) {
1233
1355
  const r = rel ? path.join(rel, e.name) : e.name;
1234
1356
  if (e.isDirectory()) {
@@ -1626,6 +1748,14 @@ async function tryDirective(reply, originId, onEvent) {
1626
1748
  // separator-insensitive. That is what makes this safe: "Note:" or "Step 2:"
1627
1749
  // never matches a live agent, so ordinary prose is untouched.
1628
1750
  reply = nameAddressedToSend(reply, originId);
1751
+ // MCP SKILL NAMES ARE NOT SHELL. parseRun used to hand get_skill /
1752
+ // proofnetwork-* / MCP: to bash. Refuse here so the model sees the
1753
+ // real MCP: directive, including DSML-wrapped RUN bodies with no RUN: line.
1754
+ const runBodies = directiveLines(reply, 'RUN');
1755
+ const dsmlCmd = dsmlRunCommand(reply);
1756
+ if (runBodies.some(looksLikeMcpAsBash) || (dsmlCmd && looksLikeMcpAsBash(dsmlCmd))) {
1757
+ return MCP_AS_BASH_REFUSE;
1758
+ }
1629
1759
  // FAN OUT FIRST. Each line is re-entered on its own, so every branch below
1630
1760
  // stays single-directive and none of them had to learn about batching.
1631
1761
  const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
@@ -1673,14 +1803,15 @@ async function tryDirective(reply, originId, onEvent) {
1673
1803
  const notes = [];
1674
1804
  for (const { name, task } of parsed) {
1675
1805
  const existing = findByName(name);
1676
- if (existing) { notes.push(`${name} already exists — sending it the task.`); made.push({ t: existing, task, fresh: false }); continue; }
1806
+ if (existing) { notes.push(`${name} already exists — woke it to keep working.`); made.push({ t: existing, task, fresh: false }); continue; }
1677
1807
  const siblings = [...threads.values()].filter((x) => x.parent === originId).length;
1678
1808
  if (siblings >= SPAWN_MAX_CHILDREN) { notes.push(`Not spawning "${name}": already at ${SPAWN_MAX_CHILDREN} subagents.`); continue; }
1679
1809
  made.push({ t: newThread(name, originId), task, fresh: true });
1680
1810
  }
1681
1811
  // Every thread now exists, so spawnPosition sees the COMPLETE cohort.
1682
1812
  for (const { t: sub, task, fresh } of made) {
1683
- runTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
1813
+ if (fresh) runTurn(sub.id, childKickoff(parent, sub.name, task, { fresh })).catch(() => {});
1814
+ else wakeOnPing(sub);
1684
1815
  }
1685
1816
  const fresh = made.filter((m) => m.fresh).map((m) => m.t.name);
1686
1817
  return [fresh.length ? `Spawned ${fresh.length} together (they can each see the full crew): ${fresh.join(', ')}` : '', ...notes]
@@ -1699,8 +1830,11 @@ async function tryDirective(reply, originId, onEvent) {
1699
1830
  // is what SEND already does.
1700
1831
  const existing = findByName(name);
1701
1832
  if (existing) {
1702
- runTurn(existing.id, childKickoff(threads.get(originId), existing.name, task, { fresh: false })).catch(() => {});
1703
- return `${name} already exists sent it the task instead of spawning a duplicate.`;
1833
+ // Repeat SPAWN is a wake, not a CONTEXT REFRESH. childKickoff({fresh:false})
1834
+ // restates the original job and tells the child it already exists MEASURED,
1835
+ // the crew flipped to that preview, thought once, and sat.
1836
+ wakeOnPing(existing);
1837
+ return `${name} already exists — woke it to keep working.`;
1704
1838
  }
1705
1839
  // Storm guard. Fire-and-forget spawning is unbounded by construction: each
1706
1840
  // child can spawn, and nothing above it is counting.
@@ -1767,27 +1901,29 @@ async function tryDirective(reply, originId, onEvent) {
1767
1901
  const ping = pingAll.length === 1 ? [null, pingAll[0]] : null;
1768
1902
  if (ping) {
1769
1903
  const name = ping[1].trim();
1770
- // PING: * (or 'all' / 'project') reaches EVERY bot in this project.
1904
+ // PING: * (or 'all' / 'project') WAKES every other bot in this project.
1771
1905
  // Coordinating a spawn tree by naming siblings one at a time is a chore
1772
1906
  // the parent should not have to do, and it cannot know who else exists.
1907
+ // The return is an ack ("pinged, working"), not a last-line dump that
1908
+ // lets the parent think the child already acted.
1773
1909
  if (/^(\*|all|project|everyone)$/i.test(name)) {
1774
1910
  const me = threads.get(originId);
1775
1911
  const root = me ? rootOf(me).rootId : null;
1776
1912
  const crew = [...threads.values()].filter((x) => x.id !== originId && rootOf(x).rootId === root);
1777
1913
  if (!crew.length) return 'No other bots in this project yet.';
1778
1914
  return crew.map((x) => {
1779
- const last = x.history[x.history.length - 1];
1780
- return x.pendingRun ? x.name + ': BLOCKED — waiting for approval'
1781
- : x.status === 'thinking' ? x.name + ': still working'
1782
- : last ? x.name + ': ' + String(last.text).slice(0, 200)
1783
- : x.name + ': no reply yet';
1915
+ if (x.pendingRun) return x.name + ': BLOCKED — waiting for approval';
1916
+ if (x.status === 'thinking') return x.name + ': still working';
1917
+ wakeOnPing(x);
1918
+ return x.name + ': pinged, working';
1784
1919
  }).join('\n');
1785
1920
  }
1786
1921
  const target = findByName(name);
1787
- const last = target?.history[target.history.length - 1];
1788
- return !target ? `No thread named "${name}".`
1789
- : target.status === 'thinking' ? `${name} is still working.`
1790
- : last ? `${name}: ${last.text}` : `${name} hasn't replied yet.`;
1922
+ if (!target) return `No thread named "${name}".`;
1923
+ if (target.pendingRun) return `${name}: BLOCKED waiting for approval`;
1924
+ if (target.status === 'thinking') return `${name} is still working.`;
1925
+ wakeOnPing(target);
1926
+ return `${name}: pinged, working`;
1791
1927
  }
1792
1928
  const peek = /^[ \t>*-]*PEEK:\s*(.+)/m.exec(reply);
1793
1929
  if (peek) {
@@ -1889,8 +2025,8 @@ async function tryDirective(reply, originId, onEvent) {
1889
2025
  if (ls) {
1890
2026
  const rel = ls[1].trim() || '.';
1891
2027
  try {
1892
- const full = safeResolveIn(dirFor(originId), rel);
1893
- const entries = readdirSync(full, { withFileTypes: true });
2028
+ const full = inDir(dirFor(originId), rel);
2029
+ const entries = listDir(dirFor(originId), rel);
1894
2030
  if (!entries.length) return `${rel}: (empty)`;
1895
2031
  const lines = entries.slice(0, 300).map((e) => {
1896
2032
  if (e.isDirectory()) return ` ${e.name}/`;
@@ -1915,9 +2051,11 @@ async function tryDirective(reply, originId, onEvent) {
1915
2051
  // becomes `*`, which is what was meant.
1916
2052
  const glob = /^(?:GLOB|LS|LIST|DIR|FIND):[ \t]*(.*)$/m.exec(reply);
1917
2053
  if (glob) {
1918
- const pattern = glob[1].trim() || '*';
2054
+ let pattern = glob[1].trim() || '*';
1919
2055
  try {
1920
2056
  const base = dirFor(originId);
2057
+ const stripped = stripBasePrefix(base, pattern);
2058
+ if (stripped !== null) pattern = stripped || '*';
1921
2059
  const re = globToRe(pattern.startsWith('./') ? pattern.slice(2) : pattern);
1922
2060
  const hits = walkDir(base).filter((f) => re.test(f) || re.test(path.basename(f)));
1923
2061
  if (!hits.length) return `GLOB ${pattern}: no matches`;
@@ -1936,7 +2074,12 @@ async function tryDirective(reply, originId, onEvent) {
1936
2074
  try { re = new RegExp(pattern, 'i'); }
1937
2075
  catch { return `GREP: ${pattern} isn't a valid regex.`; }
1938
2076
  let files = walkDir(base);
1939
- if (scope) { const sre = globToRe(scope); files = files.filter((f) => sre.test(f) || f.startsWith(scope)); }
2077
+ if (scope) {
2078
+ const stripped = stripBasePrefix(base, scope);
2079
+ const use = stripped !== null ? stripped : scope;
2080
+ const sre = globToRe(use);
2081
+ files = files.filter((f) => sre.test(f) || f.startsWith(use));
2082
+ }
1940
2083
  const out = [];
1941
2084
  for (const f of files) {
1942
2085
  if (out.length > 200) break;
@@ -2006,7 +2149,14 @@ async function tryDirective(reply, originId, onEvent) {
2006
2149
 
2007
2150
  const serve = /^[ \t>*-]*SERVE:\s*(.*)$/m.exec(reply);
2008
2151
  if (serve) {
2009
- const rel = serve[1].trim();
2152
+ let rel = serve[1].trim();
2153
+ try {
2154
+ if (rel) {
2155
+ const root = path.resolve(dirFor(originId));
2156
+ const full = inDir(root, rel);
2157
+ rel = full === root ? '' : full.slice(root.length + 1);
2158
+ }
2159
+ } catch (e) { return `Couldn't serve ${serve[1].trim()}: ${e.message}`; }
2010
2160
  const port = await ensureWorkspacePort();
2011
2161
  if (!port) {
2012
2162
  return `Serving ${rel || 'index.html'} from ${dirFor(originId)} — waiting for the workspace port to bind.`;
@@ -2194,6 +2344,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2194
2344
  : (await brain(msgs, t.contextId)).trim();
2195
2345
  } catch (e) { r = `error: ${e.message}`; }
2196
2346
  if (!stillMine()) return;
2347
+ r = stripThinkTags(r);
2197
2348
  const runCmd = parseRun(r);
2198
2349
  if (runCmd) {
2199
2350
  const command = runCmd;
@@ -2314,6 +2465,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2314
2465
  reply = `error: ${e.message}`;
2315
2466
  }
2316
2467
  if (!stillMine()) return;
2468
+ reply = stripThinkTags(reply);
2317
2469
  t.messages.push({ role: 'assistant', content: reply });
2318
2470
  const runCmd = parseRun(reply);
2319
2471
  if (runCmd) {
@@ -2582,6 +2734,9 @@ function threadSummary(t) {
2582
2734
  // How many bots sit BELOW this one. The ping-all affordance belongs on
2583
2735
  // anyone with a crew, not only on a project root.
2584
2736
  kids: subtreeOf(t.id).length,
2737
+ // Names only — enough for the sidebar/header to stack a little crew PFP
2738
+ // without shipping every member's system prompt to the browser.
2739
+ members: t.members ? t.members.map((m) => m.name) : undefined,
2585
2740
  workspacePort: workspacePort || 0 };
2586
2741
  }
2587
2742
 
@@ -2635,8 +2790,22 @@ const APP_HTML = `<!doctype html>
2635
2790
  font-size: 13px; }
2636
2791
  .trow:hover .tclose { display: flex; }
2637
2792
  .tclose:hover { background: #3a3a3c; color: #ececec; }
2638
- .tavatar { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: flex; align-items: center;
2639
- justify-content: center; color: #fff; font-weight: 600; font-size: 14px; }
2793
+ /* BOT PFPs. Grok Bot uses a cute illustrated face, not two letters in a
2794
+ rounded square. The SVG is generated in botPfp(); this just frames it
2795
+ as a round clip and runs a cheap idle bob/blink. overflow:hidden clips
2796
+ the bounce so it cannot paint over the HUD or wallet. */
2797
+ .tavatar { width: 36px; height: 36px; border-radius: 50%; flex: 0 0 36px; overflow: hidden;
2798
+ display: flex; align-items: center; justify-content: center; background: #1c1c1e;
2799
+ color: #fff; font-weight: 600; font-size: 14px; }
2800
+ .tavatar svg { width: 100%; height: 100%; display: block; }
2801
+ .tavatar-sm { width: 28px; height: 28px; flex: 0 0 28px; }
2802
+ .tavatar-plus { background: #3a3a3c; font-size: 15px; }
2803
+ .bot-pfp .bot-bob { transform-box: fill-box; transform-origin: 50% 70%;
2804
+ animation: botbob 2.8s ease-in-out infinite; animation-delay: var(--bot-delay, 0s); }
2805
+ .bot-pfp .bot-eyes { transform-box: fill-box; transform-origin: 50% 50%;
2806
+ animation: botblink 3.8s step-end infinite; animation-delay: var(--bot-blink, 0s); }
2807
+ @keyframes botbob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } }
2808
+ @keyframes botblink { 0%,88%,100% { transform: scaleY(1); } 90%,94% { transform: scaleY(0.08); } }
2640
2809
  .tmeta { min-width: 0; flex: 1; }
2641
2810
  .tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2642
2811
  .tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -2651,11 +2820,13 @@ const APP_HTML = `<!doctype html>
2651
2820
  animation: twarnpulse 1.6s ease-in-out infinite;
2652
2821
  }
2653
2822
  @keyframes twarnpulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
2654
- @media (prefers-reduced-motion: reduce) { .twarn { animation: none; } }
2823
+ @media (prefers-reduced-motion: reduce) {
2824
+ .twarn, .bot-pfp .bot-bob, .bot-pfp .bot-eyes { animation: none; }
2825
+ }
2655
2826
  #main { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
2656
2827
  #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
2657
2828
  flex-wrap: wrap; gap: 8px 10px; font-weight: 600; }
2658
- #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
2829
+ #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 50%; font-size: 11px; flex: 0 0 26px; }
2659
2830
  /* Title shrinks and wraps; the spend dials must stay on screen. margin-left:auto
2660
2831
  on #modeToggle used to shove cheap/race/wallet off the right edge. */
2661
2832
  #chatHeaderId { display: flex; align-items: center; gap: 10px; flex: 1 1 120px; min-width: 0; overflow: hidden; }
@@ -2722,6 +2893,30 @@ const APP_HTML = `<!doctype html>
2722
2893
  font-size: 12px; color: #ececec; line-height: 1.7; word-break: break-word; }
2723
2894
  .wnote { color: #6f7080; font-size: 11px; line-height: 1.6; margin-top: 12px; }
2724
2895
  .wempty { color: #f28c4d; }
2896
+ .wlane { border-top: 1px solid #2c2c2e; margin-top: 16px; padding-top: 14px; }
2897
+ .wlanetitle { font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
2898
+ color: #ececec; margin-bottom: 4px; }
2899
+ .wtag { color: #b8f240; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; margin-bottom: 8px; }
2900
+ .wtier { border: 1px solid #1c1c1e; border-radius: 12px; padding: 10px 12px; margin-bottom: 8px; }
2901
+ .wtier.hot { border-color: #b8f240; }
2902
+ .wtier .wtn { font-size: 14px; font-weight: 600; }
2903
+ .wtier .wtp { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 18px;
2904
+ font-weight: 700; margin: 4px 0 2px; }
2905
+ .wtier .wts { color: #b8f240; font-size: 11px; }
2906
+ .wtier .wtb { color: #8e8e93; font-size: 11px; margin: 4px 0 8px; }
2907
+ .wtier button { border: 1px solid #2c2c2e; background: #131315; color: #ececec; font: inherit;
2908
+ font-size: 12px; border-radius: 8px; padding: 6px 10px; cursor: pointer; }
2909
+ .wtier.hot button { background: #b8f240; border-color: #b8f240; color: #0b0b0d; font-weight: 600; }
2910
+ .wtier button:disabled { opacity: .5; cursor: default; }
2911
+ .wpaste { margin-top: 10px; }
2912
+ .wpaste input { width: 100%; background: #0b0b0d; border: 1px solid #2c2c2e; border-radius: 8px;
2913
+ color: #ececec; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
2914
+ padding: 8px 10px; margin: 6px 0; }
2915
+ .wpaste button { border: 1px solid #2c2c2e; background: #131315; color: #ececec; font: inherit;
2916
+ font-size: 12px; border-radius: 8px; padding: 6px 10px; cursor: pointer; }
2917
+ .wquiet { margin-top: 10px; font-size: 11px; }
2918
+ .wquiet a { color: #6ab0ff; }
2919
+ .wsubon { color: #b8f240; font-size: 13px; font-weight: 600; margin-bottom: 8px; }
2725
2920
  /* Slash autocomplete. Anchored above the composer because the composer sits
2726
2921
  at the bottom of the viewport — a dropdown BELOW it would render off
2727
2922
  screen. */
@@ -2768,8 +2963,8 @@ const APP_HTML = `<!doctype html>
2768
2963
  -webkit-user-select: text; user-select: text; }
2769
2964
  .hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
2770
2965
  color: #8e8e93; font-size: 13px; }
2771
- .hdr .avatar { width: 18px; height: 18px; border-radius: 5px; display: flex; align-items: center;
2772
- justify-content: center; color: #fff; font-size: 9px; font-weight: 700; }
2966
+ .hdr .avatar { width: 18px; height: 18px; border-radius: 50%; overflow: hidden; display: flex;
2967
+ align-items: center; justify-content: center; background: #1c1c1e; }
2773
2968
  /* min-width:0 is load-bearing. A flex item defaults to min-width:auto, so it
2774
2969
  refuses to shrink below its content's intrinsic width — one long
2775
2970
  unbreakable line (a curl command, a JSON blob) in a <pre> then stretches
@@ -2968,9 +3163,29 @@ const APP_HTML = `<!doctype html>
2968
3163
  </div>
2969
3164
  <div id="walletOverlay" data-component="wallet-modal">
2970
3165
  <div id="walletBox">
2971
- <h3>Your wallet</h3>
2972
- <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>
2973
- <div id="walletBody">loading…</div>
3166
+ <h3>Pay with a card</h3>
3167
+ <div class="wsub">Pay with a card. Basic, Pro, and Ultra are first. Wallet/x402 is the other option below it stays; it is not the lead.</div>
3168
+ <div id="subLane" data-component="subscribe-lane">
3169
+ <div class="wlanetitle">Subscribe with a card</div>
3170
+ <div class="wtag">Subscription key · no x402</div>
3171
+ <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>
3172
+ <div id="subStatus"></div>
3173
+ <div id="subTiers">loading plans…</div>
3174
+ <div class="wpaste">
3175
+ <div class="wlab">I already subscribed — paste key</div>
3176
+ <input id="subKeyInp" type="text" autocomplete="off" spellcheck="false"
3177
+ placeholder="key, or the /billing/done?session=… URL">
3178
+ <button type="button" id="subKeyBtn">Save key</button>
3179
+ <button type="button" id="subForgetBtn" hidden>Remove key</button>
3180
+ </div>
3181
+ <div class="wnote" id="subNote"></div>
3182
+ <div class="wquiet"><a id="subPageLink" href="${SUBSCRIPTIONS_PAGE}" target="_blank" rel="noopener">Full subscriptions page</a></div>
3183
+ </div>
3184
+ <div class="wlane" id="x402Lane" data-component="x402-lane">
3185
+ <div class="wlanetitle">Wallet / x402</div>
3186
+ <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>
3187
+ <div id="walletBody">loading…</div>
3188
+ </div>
2974
3189
  </div>
2975
3190
  </div>
2976
3191
  <div id="main">
@@ -3005,13 +3220,14 @@ const APP_HTML = `<!doctype html>
3005
3220
  </optgroup>
3006
3221
  </select>
3007
3222
  <button class="dial" id="walletBtn" data-component="wallet-open"
3008
- title="Your local burner wallet deposit addresses and live balances">wallet</button>
3223
+ title="Pay with a card, or use wallet/x402">pay</button>
3009
3224
  <button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">&#8635;</button>
3010
3225
  <button class="icon-btn" id="hudBtn">◎</button>
3011
3226
  </div>
3012
3227
  </div>
3013
3228
  <div id="hud">
3014
3229
  <div class="htitle">YOUR WALLET · THIS SESSION</div>
3230
+ <div class="hrow" id="hSubRow" hidden><span>subscription</span><span id="hSub" class="hlime">—</span></div>
3015
3231
  <div class="hrow"><span>prepaid credit</span><span id="hCredit" class="hlime">—</span></div>
3016
3232
  <div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
3017
3233
  <div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
@@ -3078,7 +3294,153 @@ const APP_HTML = `<!doctype html>
3078
3294
  let knownThreads = [];
3079
3295
  let workspacePort = 0;
3080
3296
 
3081
- function initials(name) { return name.slice(0, 2).toUpperCase(); }
3297
+ // BOT PFPs. Same job as Grok Bot's agent faces: a round illustrated
3298
+ // creature, unique per name, idle-animated, no network. Hash is the same
3299
+ // 31-multiply used by colorFor, so a name always paints the same bot.
3300
+ // Built with string concat — template literals inside APP_HTML would be
3301
+ // interpolated by the outer backtick string before the browser sees them.
3302
+ let botPfpSeq = 0;
3303
+ function nameHash(name) {
3304
+ let h = 0;
3305
+ const s = String(name || '');
3306
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
3307
+ return h;
3308
+ }
3309
+ function botPalettes() {
3310
+ return [
3311
+ ['#ff6b9d', '#ffd0e0', '#c9184a'],
3312
+ ['#ffb347', '#ffe4b3', '#c27800'],
3313
+ ['#5eead4', '#ccfbf1', '#0f766e'],
3314
+ ['#b8f240', '#e6ffb3', '#4d7c0f'],
3315
+ ['#c084fc', '#edd4ff', '#7e22ce'],
3316
+ ['#60a5fa', '#dbeafe', '#1d4ed8'],
3317
+ ['#fb7185', '#ffe4e6', '#be123c'],
3318
+ ['#34d399', '#d1fae5', '#047857'],
3319
+ ['#fbbf24', '#fef3c7', '#b45309'],
3320
+ ['#a78bfa', '#ede9fe', '#6d28d9'],
3321
+ ['#38bdf8', '#e0f2fe', '#0369a1'],
3322
+ ['#f472b6', '#fce7f3', '#9d174d']
3323
+ ];
3324
+ }
3325
+ function botFaceInner(name) {
3326
+ const h = nameHash(name);
3327
+ const pal = botPalettes()[h % 12];
3328
+ const fur = pal[0], light = pal[1], line = pal[2];
3329
+ const ears = (h >>> 4) % 5;
3330
+ const eyes = (h >>> 8) % 5;
3331
+ const mouth = (h >>> 12) % 5;
3332
+ const extra = (h >>> 16) % 4;
3333
+ const gid = 'bfg' + (++botPfpSeq);
3334
+ let s = '<g class="bot-bob">';
3335
+ s += '<defs><radialGradient id="' + gid + '" cx="35%" cy="30%" r="75%">'
3336
+ + '<stop offset="0%" stop-color="' + light + '"/>'
3337
+ + '<stop offset="100%" stop-color="' + fur + '"/>'
3338
+ + '</radialGradient></defs>';
3339
+ if (ears === 0) {
3340
+ s += '<ellipse cx="16" cy="18" rx="9" ry="10" fill="' + fur + '"/>'
3341
+ + '<ellipse cx="48" cy="18" rx="9" ry="10" fill="' + fur + '"/>'
3342
+ + '<ellipse cx="16" cy="19" rx="4.5" ry="5.5" fill="' + light + '"/>'
3343
+ + '<ellipse cx="48" cy="19" rx="4.5" ry="5.5" fill="' + light + '"/>';
3344
+ } else if (ears === 1) {
3345
+ s += '<polygon points="10,28 17,6 29,22" fill="' + fur + '"/>'
3346
+ + '<polygon points="54,28 47,6 35,22" fill="' + fur + '"/>'
3347
+ + '<polygon points="14,26 18,11 26,22" fill="' + light + '"/>'
3348
+ + '<polygon points="50,26 46,11 38,22" fill="' + light + '"/>';
3349
+ } else if (ears === 2) {
3350
+ s += '<ellipse cx="11" cy="34" rx="8" ry="14" fill="' + fur + '" transform="rotate(-28 11 34)"/>'
3351
+ + '<ellipse cx="53" cy="34" rx="8" ry="14" fill="' + fur + '" transform="rotate(28 53 34)"/>'
3352
+ + '<ellipse cx="12" cy="34" rx="4" ry="8" fill="' + light + '" transform="rotate(-28 12 34)"/>'
3353
+ + '<ellipse cx="52" cy="34" rx="4" ry="8" fill="' + light + '" transform="rotate(28 52 34)"/>';
3354
+ } else if (ears === 3) {
3355
+ s += '<line x1="22" y1="20" x2="17" y2="6" stroke="' + line + '" stroke-width="2.2" stroke-linecap="round"/>'
3356
+ + '<line x1="42" y1="20" x2="47" y2="6" stroke="' + line + '" stroke-width="2.2" stroke-linecap="round"/>'
3357
+ + '<circle cx="16" cy="5" r="3.6" fill="' + light + '" stroke="' + line + '" stroke-width="1"/>'
3358
+ + '<circle cx="48" cy="5" r="3.6" fill="' + light + '" stroke="' + line + '" stroke-width="1"/>';
3359
+ } else {
3360
+ s += '<ellipse cx="22" cy="10" rx="6" ry="16" fill="' + fur + '"/>'
3361
+ + '<ellipse cx="42" cy="10" rx="6" ry="16" fill="' + fur + '"/>'
3362
+ + '<ellipse cx="22" cy="11" rx="2.6" ry="10" fill="' + light + '"/>'
3363
+ + '<ellipse cx="42" cy="11" rx="2.6" ry="10" fill="' + light + '"/>';
3364
+ }
3365
+ s += '<circle cx="32" cy="36" r="22" fill="url(#' + gid + ')" stroke="' + line + '" stroke-width="1.1"/>'
3366
+ + '<ellipse cx="24" cy="26" rx="8" ry="5" fill="#fff" opacity="0.28"/>';
3367
+ if (extra === 1 || extra === 2) {
3368
+ s += '<ellipse cx="20" cy="42" rx="5.5" ry="3.2" fill="#ff8fab" opacity="0.5"/>'
3369
+ + '<ellipse cx="44" cy="42" rx="5.5" ry="3.2" fill="#ff8fab" opacity="0.5"/>';
3370
+ }
3371
+ if (extra === 3) {
3372
+ s += '<circle cx="22" cy="40" r="1.1" fill="' + line + '" opacity="0.4"/>'
3373
+ + '<circle cx="26" cy="43" r="0.9" fill="' + line + '" opacity="0.35"/>'
3374
+ + '<circle cx="42" cy="40" r="1.1" fill="' + line + '" opacity="0.4"/>'
3375
+ + '<circle cx="38" cy="43" r="0.9" fill="' + line + '" opacity="0.35"/>';
3376
+ }
3377
+ s += '<g class="bot-eyes">';
3378
+ if (eyes === 0) {
3379
+ s += '<circle cx="24" cy="35" r="3.6" fill="#1a1220"/>'
3380
+ + '<circle cx="40" cy="35" r="3.6" fill="#1a1220"/>'
3381
+ + '<circle cx="25.2" cy="33.8" r="1.15" fill="#fff"/>'
3382
+ + '<circle cx="41.2" cy="33.8" r="1.15" fill="#fff"/>';
3383
+ } else if (eyes === 1) {
3384
+ s += '<ellipse cx="24" cy="35" rx="3.2" ry="4.6" fill="#1a1220"/>'
3385
+ + '<ellipse cx="40" cy="35" rx="3.2" ry="4.6" fill="#1a1220"/>'
3386
+ + '<circle cx="24.8" cy="33.2" r="1" fill="#fff"/>'
3387
+ + '<circle cx="40.8" cy="33.2" r="1" fill="#fff"/>';
3388
+ } else if (eyes === 2) {
3389
+ s += '<path d="M20 36 q4 -6 8 0" fill="none" stroke="#1a1220" stroke-width="2.2" stroke-linecap="round"/>'
3390
+ + '<path d="M36 36 q4 -6 8 0" fill="none" stroke="#1a1220" stroke-width="2.2" stroke-linecap="round"/>';
3391
+ } else if (eyes === 3) {
3392
+ s += '<circle cx="24" cy="35" r="4.4" fill="#1a1220"/>'
3393
+ + '<circle cx="40" cy="35" r="4.4" fill="#1a1220"/>'
3394
+ + '<circle cx="25.4" cy="33.4" r="1.5" fill="#fff"/>'
3395
+ + '<circle cx="41.4" cy="33.4" r="1.5" fill="#fff"/>'
3396
+ + '<circle cx="22.8" cy="36.4" r="0.7" fill="#fff" opacity="0.7"/>';
3397
+ } else {
3398
+ s += '<path d="M20 35 q4 5 8 0" fill="none" stroke="#1a1220" stroke-width="2.2" stroke-linecap="round"/>'
3399
+ + '<circle cx="40" cy="35" r="3.6" fill="#1a1220"/>'
3400
+ + '<circle cx="41.2" cy="33.8" r="1.15" fill="#fff"/>';
3401
+ }
3402
+ s += '</g>';
3403
+ if (mouth === 0) {
3404
+ s += '<path d="M26 46 q6 7 12 0" fill="none" stroke="#1a1220" stroke-width="2" stroke-linecap="round"/>';
3405
+ } else if (mouth === 1) {
3406
+ s += '<ellipse cx="32" cy="48" rx="5" ry="3.4" fill="#3a1a22"/>'
3407
+ + '<ellipse cx="32" cy="49.4" rx="3.2" ry="1.6" fill="#ff6b8a" opacity="0.85"/>';
3408
+ } else if (mouth === 2) {
3409
+ s += '<path d="M25 46 q4 6 6 0 q4 6 6 0" fill="none" stroke="#1a1220" stroke-width="2" stroke-linecap="round"/>';
3410
+ } else if (mouth === 3) {
3411
+ s += '<path d="M26 45 q6 6 12 0" fill="none" stroke="#1a1220" stroke-width="2" stroke-linecap="round"/>'
3412
+ + '<ellipse cx="34" cy="50.5" rx="3.1" ry="3.4" fill="#ff6b8a"/>';
3413
+ } else {
3414
+ s += '<circle cx="32" cy="47.5" r="1.7" fill="#1a1220"/>';
3415
+ }
3416
+ s += '</g>';
3417
+ return s;
3418
+ }
3419
+ function botPfp(name, members) {
3420
+ let names = (members && members.length > 1) ? members.slice(0, 3) : [name || 'Bot'];
3421
+ if (names.length === 1 && String(name || '').indexOf(', ') !== -1) {
3422
+ const parts = String(name).split(', ');
3423
+ const cleaned = [];
3424
+ for (let i = 0; i < parts.length && cleaned.length < 3; i++) {
3425
+ if (parts[i]) cleaned.push(parts[i]);
3426
+ }
3427
+ if (cleaned.length > 1) names = cleaned;
3428
+ }
3429
+ const delay = nameHash(names[0]);
3430
+ let inner = '';
3431
+ if (names.length === 1) inner = botFaceInner(names[0]);
3432
+ else if (names.length === 2) {
3433
+ inner = '<g transform="translate(-2,6) scale(0.7)">' + botFaceInner(names[0]) + '</g>'
3434
+ + '<g transform="translate(20,8) scale(0.7)">' + botFaceInner(names[1]) + '</g>';
3435
+ } else {
3436
+ inner = '<g transform="translate(-4,2) scale(0.58)">' + botFaceInner(names[0]) + '</g>'
3437
+ + '<g transform="translate(22,4) scale(0.58)">' + botFaceInner(names[1]) + '</g>'
3438
+ + '<g transform="translate(8,16) scale(0.62)">' + botFaceInner(names[2]) + '</g>';
3439
+ }
3440
+ return '<svg class="bot-pfp" viewBox="0 0 64 64" aria-hidden="true" style="--bot-delay:-'
3441
+ + ((delay % 20) / 8) + 's;--bot-blink:-' + (((delay >>> 3) % 30) / 10) + 's">'
3442
+ + inner + '</svg>';
3443
+ }
3082
3444
 
3083
3445
  // SEARCH. The input existed with no handler at all — typing in it did
3084
3446
  // nothing, which is worse than not shipping it. Debounced because every
@@ -3153,7 +3515,7 @@ const APP_HTML = `<!doctype html>
3153
3515
  // to nothing.
3154
3516
  if (t.depth) row.style.paddingLeft = (10 + Math.min(t.depth, 4) * 12) + 'px';
3155
3517
  if (t.depth) row.title = 'spawned under ' + (t.rootName || 'a parent');
3156
- row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
3518
+ row.innerHTML = '<div class="tavatar">' + botPfp(t.name, t.members) + '</div>' +
3157
3519
  '<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
3158
3520
  (hitById && hitById.get(t.id) && hitById.get(t.id).snippet
3159
3521
  ? hitById.get(t.id).snippet
@@ -3168,7 +3530,7 @@ const APP_HTML = `<!doctype html>
3168
3530
  // subagents without retyping — and pressing it addressed the WHOLE
3169
3531
  // project, cousins included. Now every thread with descendants gets
3170
3532
  // one, scoped to its own branch.
3171
- (t.kids ? '<button class="pingall trow-ping" data-testid="ping-all" title="Message all '
3533
+ (t.kids ? '<button class="pingall trow-ping" data-testid="ping-all" title="Wake all '
3172
3534
  + t.kids + ' bot(s) below ' + escapeHtml(t.name) + '">\u21f2 ' + t.kids + '</button>' : '') +
3173
3535
  '<button class="tclose" title="Remove">✕</button>';
3174
3536
  row.addEventListener('click', () => {
@@ -3183,18 +3545,17 @@ const APP_HTML = `<!doctype html>
3183
3545
  if (pingBtn) {
3184
3546
  pingBtn.addEventListener('click', async (e) => {
3185
3547
  e.stopPropagation();
3186
- const msg = prompt('Send to all ' + t.kids + ' bot(s) below ' + t.name + ':');
3187
- // Empty is a cancel sending "" would spend a paid turn on every
3188
- // bot in the branch for nothing.
3189
- if (msg === null || !msg.trim()) return;
3548
+ // Default click = wake with the harness continue. window.prompt is
3549
+ // missing or blocked in Electron, so a modal here silently no-op'd
3550
+ // the only UI path that tried to reach the crew. /all still sends
3551
+ // exact text; ping is "poke them to work".
3190
3552
  pingBtn.disabled = true;
3191
3553
  const was = pingBtn.textContent;
3192
3554
  pingBtn.textContent = '…';
3193
3555
  try {
3194
- // Routed through THIS thread, so /all scopes to its own subtree.
3195
3556
  await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
3196
- body: JSON.stringify({ threadId: t.id, task: '/all ' + msg.trim() }) });
3197
- pingBtn.textContent = 'sent';
3557
+ body: JSON.stringify({ threadId: t.id, task: '/ping' }) });
3558
+ pingBtn.textContent = 'pinged';
3198
3559
  } catch (err) { pingBtn.textContent = 'failed'; }
3199
3560
  setTimeout(() => { pingBtn.disabled = false; pingBtn.textContent = was; }, 1500);
3200
3561
  await loadThreads();
@@ -3220,7 +3581,7 @@ const APP_HTML = `<!doctype html>
3220
3581
 
3221
3582
  function renderHeader(t) {
3222
3583
  document.getElementById('chatHeaderId').innerHTML =
3223
- '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
3584
+ '<div class="tavatar">' + botPfp(t.name, t.members) + '</div>' +
3224
3585
  '<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
3225
3586
  '">' + escapeHtml(t.dir || '') + ' · type /dir &lt;path&gt; to change</div></div>';
3226
3587
  setModeButtons(t.runMode || 'ask');
@@ -3305,8 +3666,9 @@ const APP_HTML = `<!doctype html>
3305
3666
  if (!w || (!w.solana && !w.evm && w.creditUsd == null)) {
3306
3667
  const p = document.createElement('div');
3307
3668
  p.className = 'wnote wempty';
3308
- p.textContent = 'Could not reach the local openzoo proxy on :8402. It may still be starting — try again in a few seconds.';
3669
+ 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 above.';
3309
3670
  walletBody.appendChild(p);
3671
+ renderSubLane(w && w.subscription ? w.subscription : null);
3310
3672
  return;
3311
3673
  }
3312
3674
  if (w.creditUsd != null && w.creditUsd !== '') {
@@ -3340,29 +3702,194 @@ const APP_HTML = `<!doctype html>
3340
3702
  note.className = 'wnote';
3341
3703
  // funded === false is the genuinely-empty case. Undefined means the proxy
3342
3704
  // did not say, and guessing "empty" there would send someone to top up a
3343
- // wallet that is fine.
3344
- note.textContent = w.funded === false
3345
- ? 'This wallet is EMPTY — calls will fail with HTTP 402 until you fund the addresses above. ' + (w.funding || '')
3346
- : (w.funding || '');
3347
- if (w.funded === false) note.classList.add('wempty');
3705
+ // wallet that is fine. A live subscription is the other pay lane — do not
3706
+ // nag an empty wallet as fatal when calls already skip x402.
3707
+ const subOn = w.subscription && w.subscription.active;
3708
+ note.textContent = subOn
3709
+ ? ('Wallet is optional while a subscription is active. ' + (w.funding || ''))
3710
+ : (w.funded === false
3711
+ ? 'This wallet is EMPTY — wallet/x402 calls will fail with HTTP 402 until you fund the addresses above, or subscribe with a card at the top. ' + (w.funding || '')
3712
+ : (w.funding || ''));
3713
+ if (w.funded === false && !subOn) note.classList.add('wempty');
3348
3714
  if (note.textContent.trim()) walletBody.appendChild(note);
3715
+ renderSubLane(w.subscription || null);
3716
+ }
3717
+ var subPollTimer = null;
3718
+ var subBuying = null;
3719
+ function setSubNote(text, empty) {
3720
+ const el = document.getElementById('subNote');
3721
+ if (!el) return;
3722
+ el.textContent = text || '';
3723
+ el.className = empty ? 'wnote wempty' : 'wnote';
3724
+ }
3725
+ function renderSubLane(sub) {
3726
+ const status = document.getElementById('subStatus');
3727
+ const forget = document.getElementById('subForgetBtn');
3728
+ if (status) {
3729
+ status.innerHTML = '';
3730
+ if (sub && sub.active) {
3731
+ const p = document.createElement('div');
3732
+ p.className = 'wsubon';
3733
+ p.textContent = sub.label || (sub.tierName || 'Subscription') + ' · no x402';
3734
+ status.appendChild(p);
3735
+ }
3736
+ }
3737
+ if (forget) forget.hidden = !(sub && sub.active);
3738
+ loadSubTiers();
3739
+ }
3740
+ async function loadSubTiers() {
3741
+ const box = document.getElementById('subTiers');
3742
+ if (!box) return;
3743
+ let body = null;
3744
+ try {
3745
+ const r = await fetch(API + '/billing/tiers');
3746
+ body = r.ok ? await r.json() : null;
3747
+ } catch (e) { body = null; }
3748
+ box.innerHTML = '';
3749
+ const tiers = body && body.ok && Array.isArray(body.tiers) ? body.tiers : [];
3750
+ if (!tiers.length) {
3751
+ const p = document.createElement('div');
3752
+ p.className = 'wnote wempty';
3753
+ p.textContent = 'Could not load live plans from zoo.openzoo.fun — try again, or use the full subscriptions page.';
3754
+ box.appendChild(p);
3755
+ return;
3756
+ }
3757
+ tiers.forEach(function (t) {
3758
+ const art = document.createElement('div');
3759
+ art.className = 'wtier' + (t.id === 'pro' ? ' hot' : '');
3760
+ art.setAttribute('data-tier', t.id);
3761
+ const tag = document.createElement('div');
3762
+ tag.className = 'wtag';
3763
+ tag.textContent = t.id === 'pro' ? 'Most teams want this' : '';
3764
+ const name = document.createElement('div');
3765
+ name.className = 'wtn';
3766
+ name.textContent = t.name || t.id;
3767
+ const price = document.createElement('div');
3768
+ price.className = 'wtp';
3769
+ price.textContent = '$' + ((Number(t.monthlyCents) || 0) / 100).toFixed(0) + '/mo';
3770
+ const share = document.createElement('div');
3771
+ share.className = 'wts';
3772
+ share.textContent = (t.savingsSharePct != null ? t.savingsSharePct : '?') + '% savings share';
3773
+ const blurb = document.createElement('div');
3774
+ blurb.className = 'wtb';
3775
+ blurb.textContent = t.blurb || '';
3776
+ const btn = document.createElement('button');
3777
+ btn.type = 'button';
3778
+ btn.textContent = subBuying === t.id ? 'Opening checkout…' : ('Get ' + (t.name || t.id));
3779
+ btn.disabled = subBuying != null;
3780
+ btn.addEventListener('click', function () { buyTier(t.id); });
3781
+ art.append(tag, name, price, share, blurb, btn);
3782
+ box.appendChild(art);
3783
+ });
3784
+ }
3785
+ function openSystemBrowser(url) {
3786
+ // Electron's setWindowOpenHandler routes target=_blank to shell.openExternal.
3787
+ // Never load Stripe inside this window.
3788
+ window.open(url, '_blank', 'noopener,noreferrer');
3789
+ }
3790
+ function stopSubPoll() {
3791
+ if (subPollTimer) { clearInterval(subPollTimer); subPollTimer = null; }
3792
+ }
3793
+ function startSubPoll(sessionId) {
3794
+ stopSubPoll();
3795
+ var tries = 0;
3796
+ async function tick() {
3797
+ tries += 1;
3798
+ try {
3799
+ const r = await fetch(API + '/billing/key?session=' + encodeURIComponent(sessionId));
3800
+ const j = r.ok ? await r.json() : null;
3801
+ if (j && j.saved) {
3802
+ stopSubPoll();
3803
+ setSubNote('Subscription key saved · no x402');
3804
+ await openWallet();
3805
+ return;
3806
+ }
3807
+ if (j && j.pending) setSubNote('Waiting for Stripe to confirm…');
3808
+ else if (j && j.error && j.error !== 'session required') setSubNote(j.error, true);
3809
+ } catch (e) { /* keep polling */ }
3810
+ if (tries >= 60) {
3811
+ stopSubPoll();
3812
+ setSubNote('Still waiting on Stripe — paste the key from the success page if you have it.');
3813
+ }
3814
+ }
3815
+ subPollTimer = setInterval(tick, 2000);
3816
+ tick();
3817
+ }
3818
+ async function buyTier(tier) {
3819
+ subBuying = tier;
3820
+ setSubNote('');
3821
+ loadSubTiers();
3822
+ try {
3823
+ const r = await fetch(API + '/billing/checkout', {
3824
+ method: 'POST', headers: { 'content-type': 'application/json' },
3825
+ body: JSON.stringify({ tier: tier }),
3826
+ });
3827
+ const j = await r.json();
3828
+ if (!j || !j.ok || !j.url) throw new Error((j && j.error) || 'checkout failed');
3829
+ openSystemBrowser(j.url);
3830
+ setSubNote('Checkout opened in your system browser. This window will pick up the key when Stripe confirms.');
3831
+ if (j.sessionId) startSubPoll(j.sessionId);
3832
+ } catch (e) {
3833
+ setSubNote(e.message || String(e), true);
3834
+ }
3835
+ subBuying = null;
3836
+ loadSubTiers();
3837
+ }
3838
+ async function savePastedSub() {
3839
+ const inp = document.getElementById('subKeyInp');
3840
+ const paste = inp ? inp.value.trim() : '';
3841
+ if (!paste) { setSubNote('Paste a key or the billing/done URL.', true); return; }
3842
+ try {
3843
+ const r = await fetch(API + '/billing/key', {
3844
+ method: 'POST', headers: { 'content-type': 'application/json' },
3845
+ body: JSON.stringify({ paste: paste }),
3846
+ });
3847
+ const j = await r.json();
3848
+ if (j && j.pending && j.session) {
3849
+ setSubNote('Waiting for Stripe to confirm…');
3850
+ startSubPoll(j.session);
3851
+ return;
3852
+ }
3853
+ if (!j || !j.saved) throw new Error((j && j.error) || 'could not save key');
3854
+ if (inp) inp.value = '';
3855
+ setSubNote('Subscription key saved · no x402');
3856
+ await openWallet();
3857
+ } catch (e) {
3858
+ setSubNote(e.message || String(e), true);
3859
+ }
3860
+ }
3861
+ async function forgetSub() {
3862
+ try {
3863
+ await fetch(API + '/billing/key', { method: 'DELETE' });
3864
+ } catch (e) { /* still refresh */ }
3865
+ setSubNote('Subscription key removed. Wallet/x402 is the pay method again.');
3866
+ await openWallet();
3349
3867
  }
3350
3868
  document.getElementById('walletBtn').addEventListener('click', openWallet);
3351
- // First launch: if the burner is empty, open the wallet once so they see
3352
- // addresses they can copy — and whose wallet this is. localStorage so a
3353
- // funded session or a dismiss does not keep popping it.
3869
+ const subKeyBtn = document.getElementById('subKeyBtn');
3870
+ if (subKeyBtn) subKeyBtn.addEventListener('click', savePastedSub);
3871
+ const subForgetBtn = document.getElementById('subForgetBtn');
3872
+ if (subForgetBtn) subForgetBtn.addEventListener('click', forgetSub);
3873
+ const subKeyInp = document.getElementById('subKeyInp');
3874
+ if (subKeyInp) subKeyInp.addEventListener('keydown', function (e) {
3875
+ if (e.key === 'Enter') { e.preventDefault(); savePastedSub(); }
3876
+ });
3877
+ // First launch: if the burner is empty AND there is no subscription, open
3878
+ // the pay modal once so they see card plans first — and burner addresses
3879
+ // below. localStorage so a funded session, a saved key, or a dismiss does
3880
+ // not keep popping it.
3354
3881
  (async function maybeOpenWalletOnce() {
3355
3882
  if (localStorage.getItem('openzoo.wallet.seen')) return;
3356
3883
  for (let i = 0; i < 8; i++) {
3357
3884
  try {
3358
3885
  const r = await fetch(API + '/wallet');
3359
3886
  const w = r.ok ? await r.json() : null;
3360
- if (!w || (!w.solana && !w.evm)) {
3887
+ if (!w || (!w.solana && !w.evm && !(w.subscription && w.subscription.active))) {
3361
3888
  await new Promise((res) => setTimeout(res, 400));
3362
3889
  continue;
3363
3890
  }
3364
3891
  localStorage.setItem('openzoo.wallet.seen', '1');
3365
- if (w.funded === false) await openWallet();
3892
+ if (w.funded === false && !(w.subscription && w.subscription.active)) await openWallet();
3366
3893
  return;
3367
3894
  } catch (e) {
3368
3895
  await new Promise((res) => setTimeout(res, 400));
@@ -3403,6 +3930,13 @@ const APP_HTML = `<!doctype html>
3403
3930
  document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
3404
3931
 
3405
3932
  function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
3933
+ function stripThinkTags(s) {
3934
+ s = String(s == null ? '' : s);
3935
+ s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*?<\\/think(?:ing)?>/gi, '');
3936
+ s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*$/i, '');
3937
+ s = s.replace(/<\\/think(?:ing)?>/gi, '');
3938
+ return s.replace(/^\\n+|\\n+$/g, '').trim();
3939
+ }
3406
3940
  function clientWorkspaceUrl(rel) {
3407
3941
  if (!workspacePort || !activeId) return '';
3408
3942
  rel = String(rel || '').replace(/^\\/+/, '');
@@ -3786,11 +4320,12 @@ const APP_HTML = `<!doctype html>
3786
4320
 
3787
4321
  let lastSpeaker = null;
3788
4322
  function addRow(who, text, color, name, run, images) {
4323
+ if (who === 'bot') text = stripThinkTags(text);
3789
4324
  const speakerKey = who + '|' + name;
3790
4325
  if (who === 'bot' && speakerKey !== lastSpeaker) {
3791
4326
  const hdr = document.createElement('div');
3792
4327
  hdr.className = 'hdr';
3793
- hdr.innerHTML = '<span class="avatar" style="background:' + color + '">' + initials(name) + '</span><span>' + name + '</span>';
4328
+ hdr.innerHTML = '<span class="avatar">' + botPfp(name) + '</span><span>' + name + '</span>';
3794
4329
  log.appendChild(hdr);
3795
4330
  }
3796
4331
  lastSpeaker = speakerKey;
@@ -3928,7 +4463,7 @@ const APP_HTML = `<!doctype html>
3928
4463
  if (streamBuf) {
3929
4464
  const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
3930
4465
  ? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
3931
- return escapeHtml(streamBuf) + trail;
4466
+ return escapeHtml(stripThinkTags(streamBuf)) + trail;
3932
4467
  }
3933
4468
  const dots = '<span class="dots"><span></span><span></span><span></span></span>';
3934
4469
  const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
@@ -4155,7 +4690,7 @@ const APP_HTML = `<!doctype html>
4155
4690
  composeList.innerHTML = '';
4156
4691
  const createRow = document.createElement('div');
4157
4692
  createRow.className = 'crow';
4158
- createRow.innerHTML = '<div class="tavatar" style="background:#3a3a3c;width:28px;height:28px;border-radius:8px;font-size:15px">+</div>' +
4693
+ createRow.innerHTML = '<div class="tavatar tavatar-sm tavatar-plus">+</div>' +
4159
4694
  '<div>Create new Bot' + (q ? ': ' + escapeHtml(composeInp.value.trim()) : '') + '</div>' +
4160
4695
  '<div class="kbd"><kbd>⌘</kbd><kbd>1</kbd></div>';
4161
4696
  createRow.addEventListener('click', async () => {
@@ -4170,7 +4705,7 @@ const APP_HTML = `<!doctype html>
4170
4705
  candidates.slice(0, 8).forEach((t, i) => {
4171
4706
  const row = document.createElement('div');
4172
4707
  row.className = 'crow';
4173
- row.innerHTML = '<div class="tavatar" style="background:' + t.color + ';width:28px;height:28px;border-radius:8px;font-size:11px">' + initials(t.name) + '</div>' +
4708
+ row.innerHTML = '<div class="tavatar tavatar-sm">' + botPfp(t.name) + '</div>' +
4174
4709
  '<div>' + escapeHtml(t.name) + '</div><div class="kbd"><kbd>⌘</kbd><kbd>' + (i + 2) + '</kbd></div>';
4175
4710
  row.addEventListener('click', () => addChip(t));
4176
4711
  composeList.appendChild(row);
@@ -4278,6 +4813,16 @@ const APP_HTML = `<!doctype html>
4278
4813
  const you = await (await fetch(API + '/hud-summary')).json();
4279
4814
  const creditEl = document.getElementById('hCredit');
4280
4815
  if (creditEl) creditEl.textContent = (you.creditUsd == null) ? '—' : usd(Number(you.creditUsd) || 0);
4816
+ const subRow = document.getElementById('hSubRow');
4817
+ const subEl = document.getElementById('hSub');
4818
+ if (subRow && subEl) {
4819
+ if (you.subscription && you.subscription.active) {
4820
+ subRow.hidden = false;
4821
+ subEl.textContent = you.subscription.label || you.subscription.tierName || 'Subscription key · no x402';
4822
+ } else {
4823
+ subRow.hidden = true;
4824
+ }
4825
+ }
4281
4826
  const spent = Number(you.spentUsd) || 0;
4282
4827
  const cogs = Number(you.cogsUsd) || 0;
4283
4828
  const direct = Number(you.directUsd) || 0;
@@ -4349,12 +4894,116 @@ const server = http.createServer((req, res) => {
4349
4894
  try {
4350
4895
  w.creditUsd = await creditBalance();
4351
4896
  } catch { /* leave credit off if the gateway is down */ }
4897
+ // Subscription is local (~/.openzoo/subscription.json). Merge it here so
4898
+ // an older :8402 that does not yet know about Stripe still shows the lane.
4899
+ w.subscription = subscriptionPublicView();
4352
4900
  res.writeHead(200, { 'content-type': 'application/json' });
4353
4901
  res.end(JSON.stringify(w));
4354
4902
  })();
4355
4903
  return;
4356
4904
  }
4357
4905
 
4906
+ // Live Stripe plans — never a stale hardcoded $9/$29/$99. Same origin so
4907
+ // the renderer does not have to talk to zoo.openzoo.fun itself.
4908
+ if (req.method === 'GET' && req.url === '/billing/tiers') {
4909
+ (async () => {
4910
+ try {
4911
+ const body = await billingTiers();
4912
+ res.writeHead(200, { 'content-type': 'application/json' });
4913
+ res.end(JSON.stringify(body));
4914
+ } catch (e) {
4915
+ res.writeHead(502, { 'content-type': 'application/json' });
4916
+ res.end(JSON.stringify({ ok: false, error: e.message }));
4917
+ }
4918
+ })();
4919
+ return;
4920
+ }
4921
+
4922
+ if (req.method === 'POST' && req.url === '/billing/checkout') {
4923
+ const chunks = [];
4924
+ req.on('data', (d) => chunks.push(d));
4925
+ req.on('end', async () => {
4926
+ let tier = '';
4927
+ try { tier = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').tier || ''; }
4928
+ catch { /* ignore */ }
4929
+ try {
4930
+ const body = await billingCheckout(tier);
4931
+ res.writeHead(200, { 'content-type': 'application/json' });
4932
+ res.end(JSON.stringify(body));
4933
+ } catch (e) {
4934
+ res.writeHead(502, { 'content-type': 'application/json' });
4935
+ res.end(JSON.stringify({ ok: false, error: e.message }));
4936
+ }
4937
+ });
4938
+ return;
4939
+ }
4940
+
4941
+ // GET ?session= polls the same endpoint the public /billing/done page uses.
4942
+ // On a key, persist it locally and return a public view — never the secret
4943
+ // (this UI can sit on a public box URL).
4944
+ if (req.method === 'GET' && (req.url || '').startsWith('/billing/key')) {
4945
+ (async () => {
4946
+ const q = new URL(req.url, 'http://x').searchParams;
4947
+ const session = q.get('session') || q.get('session_id') || '';
4948
+ try {
4949
+ const body = await fetchBillingKey(session);
4950
+ res.writeHead(200, { 'content-type': 'application/json' });
4951
+ res.end(JSON.stringify(ingestBillingKeyResponse(body, {
4952
+ sessionId: session,
4953
+ tier: q.get('tier') || null,
4954
+ })));
4955
+ } catch (e) {
4956
+ res.writeHead(502, { 'content-type': 'application/json' });
4957
+ res.end(JSON.stringify({ ok: false, error: e.message }));
4958
+ }
4959
+ })();
4960
+ return;
4961
+ }
4962
+
4963
+ if (req.method === 'POST' && req.url === '/billing/key') {
4964
+ const chunks = [];
4965
+ req.on('data', (d) => chunks.push(d));
4966
+ req.on('end', async () => {
4967
+ let paste = '';
4968
+ let tier = '';
4969
+ try {
4970
+ const j = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
4971
+ paste = j.paste || j.key || '';
4972
+ tier = j.tier || '';
4973
+ } catch { /* ignore */ }
4974
+ const parsed = parseSubscriptionPaste(paste);
4975
+ if (parsed.error) {
4976
+ res.writeHead(400, { 'content-type': 'application/json' });
4977
+ res.end(JSON.stringify({ ok: false, error: parsed.error }));
4978
+ return;
4979
+ }
4980
+ if (parsed.session) {
4981
+ try {
4982
+ const body = await fetchBillingKey(parsed.session);
4983
+ const out = ingestBillingKeyResponse(body, { sessionId: parsed.session, tier });
4984
+ if (out.pending) out.session = parsed.session;
4985
+ res.writeHead(200, { 'content-type': 'application/json' });
4986
+ res.end(JSON.stringify(out));
4987
+ } catch (e) {
4988
+ res.writeHead(502, { 'content-type': 'application/json' });
4989
+ res.end(JSON.stringify({ ok: false, error: e.message }));
4990
+ }
4991
+ return;
4992
+ }
4993
+ saveSubscription({ key: parsed.key, tier: tier || null });
4994
+ res.writeHead(200, { 'content-type': 'application/json' });
4995
+ res.end(JSON.stringify({ ok: true, saved: true, ...subscriptionPublicView() }));
4996
+ });
4997
+ return;
4998
+ }
4999
+
5000
+ if (req.method === 'DELETE' && req.url === '/billing/key') {
5001
+ clearSubscription();
5002
+ res.writeHead(200, { 'content-type': 'application/json' });
5003
+ res.end(JSON.stringify({ ok: true, saved: false, ...subscriptionPublicView() }));
5004
+ return;
5005
+ }
5006
+
4358
5007
  // Restart grokui in place — and ACTUALLY PICK UP THE NEW BUILD.
4359
5008
  //
4360
5009
  // Exiting is the restart: on a production box, box-server's ensureOz() poll
@@ -4410,6 +5059,7 @@ const server = http.createServer((req, res) => {
4410
5059
  try {
4411
5060
  you.creditUsd = await creditBalance();
4412
5061
  } catch { /* credit is advisory */ }
5062
+ you.subscription = subscriptionPublicView();
4413
5063
  res.writeHead(200, { 'content-type': 'application/json' });
4414
5064
  res.end(JSON.stringify(you));
4415
5065
  })();
@@ -4628,4 +5278,9 @@ const server = http.createServer((req, res) => {
4628
5278
 
4629
5279
  server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0' ? 'localhost' : BIND}:${PORT}`));
4630
5280
 
4631
- export { tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl };
5281
+ export {
5282
+ tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
5283
+ parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
5284
+ handleSlash, newThread, setRunTurnForTest, AUTO_CONTINUE, pingWakeText, pingCanWake,
5285
+ childKickoff,
5286
+ };