openzoo 0.48.89 → 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
@@ -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' };
@@ -381,6 +425,18 @@ function loadThreads() {
381
425
  t.status = 'idle';
382
426
  t.liveStatus = '';
383
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
+ }
384
440
  threads.set(t.id, t);
385
441
  }
386
442
  return true;
@@ -721,6 +777,21 @@ function sanitizeRunCommand(command) {
721
777
  //
722
778
  // Also tolerates the directive being wrapped in a markdown code fence, which
723
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
+
724
795
  function parseRun(reply) {
725
796
  // NATIVE TOOL-CALL ENVELOPE FIRST. deepseek-v4-pro has real function calling,
726
797
  // and when told to emit "RUN: <cmd>" it frequently wraps the call in its own
@@ -747,10 +818,8 @@ function parseRun(reply) {
747
818
  // the whole envelope was dropped in silence: the bot then explained what it
748
819
  // was "about to run" forever, never running anything. Match the shape of the
749
820
  // 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]);
821
+ const dsmlCmd = dsmlRunCommand(reply);
822
+ if (dsmlCmd !== undefined) return dsmlCmd && !looksLikeMcpAsBash(dsmlCmd) ? dsmlCmd : null;
754
823
 
755
824
  // SEVERAL "RUN:" LINES IN ONE REPLY RUN BACK TO BACK.
756
825
  //
@@ -782,12 +851,26 @@ function parseRun(reply) {
782
851
  const fenced = /^```[\w-]*\n([\s\S]*?)```/.exec(cmd.trim());
783
852
  if (fenced) cmd = fenced[1];
784
853
  else cmd = cmd.replace(/\n```[\s\S]*$/, ''); // trailing fence + any posttext
854
+ cmd = sliceToNextDirective(cmd);
785
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;
786
861
  if (cmd) cmds.push(cmd);
787
862
  }
788
863
  return cmds.length ? cmds.join('\n') : null;
789
864
  }
790
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
+
791
874
  // RUN through BASH, not /bin/sh. node's exec() defaults to /bin/sh, which on
792
875
  // Debian is dash — so every bash-ism a model writes (`for … do`, `[[ ]]`,
793
876
  // arrays, process substitution) dies as
@@ -1228,7 +1311,10 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__
1228
1311
  function walkDir(base, rel = '', out = [], depth = 0) {
1229
1312
  if (depth > 12 || out.length > 5000) return out;
1230
1313
  let entries = [];
1231
- 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; }
1232
1318
  for (const e of entries) {
1233
1319
  const r = rel ? path.join(rel, e.name) : e.name;
1234
1320
  if (e.isDirectory()) {
@@ -1626,6 +1712,14 @@ async function tryDirective(reply, originId, onEvent) {
1626
1712
  // separator-insensitive. That is what makes this safe: "Note:" or "Step 2:"
1627
1713
  // never matches a live agent, so ordinary prose is untouched.
1628
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
+ }
1629
1723
  // FAN OUT FIRST. Each line is re-entered on its own, so every branch below
1630
1724
  // stays single-directive and none of them had to learn about batching.
1631
1725
  const batch = [...reply.matchAll(PARALLEL_DIRECTIVE)];
@@ -1889,8 +1983,8 @@ async function tryDirective(reply, originId, onEvent) {
1889
1983
  if (ls) {
1890
1984
  const rel = ls[1].trim() || '.';
1891
1985
  try {
1892
- const full = safeResolveIn(dirFor(originId), rel);
1893
- const entries = readdirSync(full, { withFileTypes: true });
1986
+ const full = inDir(dirFor(originId), rel);
1987
+ const entries = listDir(dirFor(originId), rel);
1894
1988
  if (!entries.length) return `${rel}: (empty)`;
1895
1989
  const lines = entries.slice(0, 300).map((e) => {
1896
1990
  if (e.isDirectory()) return ` ${e.name}/`;
@@ -1915,9 +2009,11 @@ async function tryDirective(reply, originId, onEvent) {
1915
2009
  // becomes `*`, which is what was meant.
1916
2010
  const glob = /^(?:GLOB|LS|LIST|DIR|FIND):[ \t]*(.*)$/m.exec(reply);
1917
2011
  if (glob) {
1918
- const pattern = glob[1].trim() || '*';
2012
+ let pattern = glob[1].trim() || '*';
1919
2013
  try {
1920
2014
  const base = dirFor(originId);
2015
+ const stripped = stripBasePrefix(base, pattern);
2016
+ if (stripped !== null) pattern = stripped || '*';
1921
2017
  const re = globToRe(pattern.startsWith('./') ? pattern.slice(2) : pattern);
1922
2018
  const hits = walkDir(base).filter((f) => re.test(f) || re.test(path.basename(f)));
1923
2019
  if (!hits.length) return `GLOB ${pattern}: no matches`;
@@ -1936,7 +2032,12 @@ async function tryDirective(reply, originId, onEvent) {
1936
2032
  try { re = new RegExp(pattern, 'i'); }
1937
2033
  catch { return `GREP: ${pattern} isn't a valid regex.`; }
1938
2034
  let files = walkDir(base);
1939
- 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
+ }
1940
2041
  const out = [];
1941
2042
  for (const f of files) {
1942
2043
  if (out.length > 200) break;
@@ -2006,7 +2107,14 @@ async function tryDirective(reply, originId, onEvent) {
2006
2107
 
2007
2108
  const serve = /^[ \t>*-]*SERVE:\s*(.*)$/m.exec(reply);
2008
2109
  if (serve) {
2009
- const rel = serve[1].trim();
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}`; }
2010
2118
  const port = await ensureWorkspacePort();
2011
2119
  if (!port) {
2012
2120
  return `Serving ${rel || 'index.html'} from ${dirFor(originId)} — waiting for the workspace port to bind.`;
@@ -2194,6 +2302,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2194
2302
  : (await brain(msgs, t.contextId)).trim();
2195
2303
  } catch (e) { r = `error: ${e.message}`; }
2196
2304
  if (!stillMine()) return;
2305
+ r = stripThinkTags(r);
2197
2306
  const runCmd = parseRun(r);
2198
2307
  if (runCmd) {
2199
2308
  const command = runCmd;
@@ -2314,6 +2423,7 @@ async function runTurn(threadId, userText, onEvent, images) {
2314
2423
  reply = `error: ${e.message}`;
2315
2424
  }
2316
2425
  if (!stillMine()) return;
2426
+ reply = stripThinkTags(reply);
2317
2427
  t.messages.push({ role: 'assistant', content: reply });
2318
2428
  const runCmd = parseRun(reply);
2319
2429
  if (runCmd) {
@@ -2582,6 +2692,9 @@ function threadSummary(t) {
2582
2692
  // How many bots sit BELOW this one. The ping-all affordance belongs on
2583
2693
  // anyone with a crew, not only on a project root.
2584
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,
2585
2698
  workspacePort: workspacePort || 0 };
2586
2699
  }
2587
2700
 
@@ -2635,8 +2748,22 @@ const APP_HTML = `<!doctype html>
2635
2748
  font-size: 13px; }
2636
2749
  .trow:hover .tclose { display: flex; }
2637
2750
  .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; }
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); } }
2640
2767
  .tmeta { min-width: 0; flex: 1; }
2641
2768
  .tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2642
2769
  .tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -2651,11 +2778,13 @@ const APP_HTML = `<!doctype html>
2651
2778
  animation: twarnpulse 1.6s ease-in-out infinite;
2652
2779
  }
2653
2780
  @keyframes twarnpulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
2654
- @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
+ }
2655
2784
  #main { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100vh; }
2656
2785
  #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
2657
2786
  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; }
2787
+ #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 50%; font-size: 11px; flex: 0 0 26px; }
2659
2788
  /* Title shrinks and wraps; the spend dials must stay on screen. margin-left:auto
2660
2789
  on #modeToggle used to shove cheap/race/wallet off the right edge. */
2661
2790
  #chatHeaderId { display: flex; align-items: center; gap: 10px; flex: 1 1 120px; min-width: 0; overflow: hidden; }
@@ -2722,6 +2851,30 @@ const APP_HTML = `<!doctype html>
2722
2851
  font-size: 12px; color: #ececec; line-height: 1.7; word-break: break-word; }
2723
2852
  .wnote { color: #6f7080; font-size: 11px; line-height: 1.6; margin-top: 12px; }
2724
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; }
2725
2878
  /* Slash autocomplete. Anchored above the composer because the composer sits
2726
2879
  at the bottom of the viewport — a dropdown BELOW it would render off
2727
2880
  screen. */
@@ -2768,8 +2921,8 @@ const APP_HTML = `<!doctype html>
2768
2921
  -webkit-user-select: text; user-select: text; }
2769
2922
  .hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
2770
2923
  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; }
2924
+ .hdr .avatar { width: 18px; height: 18px; border-radius: 50%; overflow: hidden; display: flex;
2925
+ align-items: center; justify-content: center; background: #1c1c1e; }
2773
2926
  /* min-width:0 is load-bearing. A flex item defaults to min-width:auto, so it
2774
2927
  refuses to shrink below its content's intrinsic width — one long
2775
2928
  unbreakable line (a curl command, a JSON blob) in a <pre> then stretches
@@ -2969,8 +3122,26 @@ const APP_HTML = `<!doctype html>
2969
3122
  <div id="walletOverlay" data-component="wallet-modal">
2970
3123
  <div id="walletBox">
2971
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>
2972
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>
2973
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>
2974
3145
  </div>
2975
3146
  </div>
2976
3147
  <div id="main">
@@ -3005,13 +3176,14 @@ const APP_HTML = `<!doctype html>
3005
3176
  </optgroup>
3006
3177
  </select>
3007
3178
  <button class="dial" id="walletBtn" data-component="wallet-open"
3008
- 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>
3009
3180
  <button class="icon-btn" id="reloadBtn" title="Restart grokui on this box">&#8635;</button>
3010
3181
  <button class="icon-btn" id="hudBtn">◎</button>
3011
3182
  </div>
3012
3183
  </div>
3013
3184
  <div id="hud">
3014
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>
3015
3187
  <div class="hrow"><span>prepaid credit</span><span id="hCredit" class="hlime">—</span></div>
3016
3188
  <div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
3017
3189
  <div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
@@ -3078,7 +3250,153 @@ const APP_HTML = `<!doctype html>
3078
3250
  let knownThreads = [];
3079
3251
  let workspacePort = 0;
3080
3252
 
3081
- function initials(name) { return name.slice(0, 2).toUpperCase(); }
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
+ }
3082
3400
 
3083
3401
  // SEARCH. The input existed with no handler at all — typing in it did
3084
3402
  // nothing, which is worse than not shipping it. Debounced because every
@@ -3153,7 +3471,7 @@ const APP_HTML = `<!doctype html>
3153
3471
  // to nothing.
3154
3472
  if (t.depth) row.style.paddingLeft = (10 + Math.min(t.depth, 4) * 12) + 'px';
3155
3473
  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>' +
3474
+ row.innerHTML = '<div class="tavatar">' + botPfp(t.name, t.members) + '</div>' +
3157
3475
  '<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
3158
3476
  (hitById && hitById.get(t.id) && hitById.get(t.id).snippet
3159
3477
  ? hitById.get(t.id).snippet
@@ -3220,7 +3538,7 @@ const APP_HTML = `<!doctype html>
3220
3538
 
3221
3539
  function renderHeader(t) {
3222
3540
  document.getElementById('chatHeaderId').innerHTML =
3223
- '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
3541
+ '<div class="tavatar">' + botPfp(t.name, t.members) + '</div>' +
3224
3542
  '<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
3225
3543
  '">' + escapeHtml(t.dir || '') + ' · type /dir &lt;path&gt; to change</div></div>';
3226
3544
  setModeButtons(t.runMode || 'ask');
@@ -3305,8 +3623,9 @@ const APP_HTML = `<!doctype html>
3305
3623
  if (!w || (!w.solana && !w.evm && w.creditUsd == null)) {
3306
3624
  const p = document.createElement('div');
3307
3625
  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.';
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.';
3309
3627
  walletBody.appendChild(p);
3628
+ renderSubLane(w && w.subscription ? w.subscription : null);
3310
3629
  return;
3311
3630
  }
3312
3631
  if (w.creditUsd != null && w.creditUsd !== '') {
@@ -3340,29 +3659,193 @@ const APP_HTML = `<!doctype html>
3340
3659
  note.className = 'wnote';
3341
3660
  // funded === false is the genuinely-empty case. Undefined means the proxy
3342
3661
  // 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');
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');
3348
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();
3349
3824
  }
3350
3825
  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.
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.
3354
3837
  (async function maybeOpenWalletOnce() {
3355
3838
  if (localStorage.getItem('openzoo.wallet.seen')) return;
3356
3839
  for (let i = 0; i < 8; i++) {
3357
3840
  try {
3358
3841
  const r = await fetch(API + '/wallet');
3359
3842
  const w = r.ok ? await r.json() : null;
3360
- if (!w || (!w.solana && !w.evm)) {
3843
+ if (!w || (!w.solana && !w.evm && !(w.subscription && w.subscription.active))) {
3361
3844
  await new Promise((res) => setTimeout(res, 400));
3362
3845
  continue;
3363
3846
  }
3364
3847
  localStorage.setItem('openzoo.wallet.seen', '1');
3365
- if (w.funded === false) await openWallet();
3848
+ if (w.funded === false && !(w.subscription && w.subscription.active)) await openWallet();
3366
3849
  return;
3367
3850
  } catch (e) {
3368
3851
  await new Promise((res) => setTimeout(res, 400));
@@ -3403,6 +3886,13 @@ const APP_HTML = `<!doctype html>
3403
3886
  document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
3404
3887
 
3405
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
+ }
3406
3896
  function clientWorkspaceUrl(rel) {
3407
3897
  if (!workspacePort || !activeId) return '';
3408
3898
  rel = String(rel || '').replace(/^\\/+/, '');
@@ -3786,11 +4276,12 @@ const APP_HTML = `<!doctype html>
3786
4276
 
3787
4277
  let lastSpeaker = null;
3788
4278
  function addRow(who, text, color, name, run, images) {
4279
+ if (who === 'bot') text = stripThinkTags(text);
3789
4280
  const speakerKey = who + '|' + name;
3790
4281
  if (who === 'bot' && speakerKey !== lastSpeaker) {
3791
4282
  const hdr = document.createElement('div');
3792
4283
  hdr.className = 'hdr';
3793
- 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>';
3794
4285
  log.appendChild(hdr);
3795
4286
  }
3796
4287
  lastSpeaker = speakerKey;
@@ -3928,7 +4419,7 @@ const APP_HTML = `<!doctype html>
3928
4419
  if (streamBuf) {
3929
4420
  const trail = streamStatus && /^(RUN|READ|WRITE|EDIT|SPAWN|SEND|GLOB|GREP|MCP|FETCH|TODO|SERVE|PING|PEEK|MULTIEDIT|NOTEBOOK):/i.test(streamStatus)
3930
4421
  ? '<span class="ttrail">' + escapeHtml(streamStatus) + '</span>' : '';
3931
- return escapeHtml(streamBuf) + trail;
4422
+ return escapeHtml(stripThinkTags(streamBuf)) + trail;
3932
4423
  }
3933
4424
  const dots = '<span class="dots"><span></span><span></span><span></span></span>';
3934
4425
  const st = streamStatus ? '<span class="tstatus">' + escapeHtml(streamStatus) + '</span>' : '';
@@ -4155,7 +4646,7 @@ const APP_HTML = `<!doctype html>
4155
4646
  composeList.innerHTML = '';
4156
4647
  const createRow = document.createElement('div');
4157
4648
  createRow.className = 'crow';
4158
- 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>' +
4159
4650
  '<div>Create new Bot' + (q ? ': ' + escapeHtml(composeInp.value.trim()) : '') + '</div>' +
4160
4651
  '<div class="kbd"><kbd>⌘</kbd><kbd>1</kbd></div>';
4161
4652
  createRow.addEventListener('click', async () => {
@@ -4170,7 +4661,7 @@ const APP_HTML = `<!doctype html>
4170
4661
  candidates.slice(0, 8).forEach((t, i) => {
4171
4662
  const row = document.createElement('div');
4172
4663
  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>' +
4664
+ row.innerHTML = '<div class="tavatar tavatar-sm">' + botPfp(t.name) + '</div>' +
4174
4665
  '<div>' + escapeHtml(t.name) + '</div><div class="kbd"><kbd>⌘</kbd><kbd>' + (i + 2) + '</kbd></div>';
4175
4666
  row.addEventListener('click', () => addChip(t));
4176
4667
  composeList.appendChild(row);
@@ -4278,6 +4769,16 @@ const APP_HTML = `<!doctype html>
4278
4769
  const you = await (await fetch(API + '/hud-summary')).json();
4279
4770
  const creditEl = document.getElementById('hCredit');
4280
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
+ }
4281
4782
  const spent = Number(you.spentUsd) || 0;
4282
4783
  const cogs = Number(you.cogsUsd) || 0;
4283
4784
  const direct = Number(you.directUsd) || 0;
@@ -4349,12 +4850,116 @@ const server = http.createServer((req, res) => {
4349
4850
  try {
4350
4851
  w.creditUsd = await creditBalance();
4351
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();
4352
4856
  res.writeHead(200, { 'content-type': 'application/json' });
4353
4857
  res.end(JSON.stringify(w));
4354
4858
  })();
4355
4859
  return;
4356
4860
  }
4357
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
+
4358
4963
  // Restart grokui in place — and ACTUALLY PICK UP THE NEW BUILD.
4359
4964
  //
4360
4965
  // Exiting is the restart: on a production box, box-server's ensureOz() poll
@@ -4410,6 +5015,7 @@ const server = http.createServer((req, res) => {
4410
5015
  try {
4411
5016
  you.creditUsd = await creditBalance();
4412
5017
  } catch { /* credit is advisory */ }
5018
+ you.subscription = subscriptionPublicView();
4413
5019
  res.writeHead(200, { 'content-type': 'application/json' });
4414
5020
  res.end(JSON.stringify(you));
4415
5021
  })();
@@ -4628,4 +5234,7 @@ const server = http.createServer((req, res) => {
4628
5234
 
4629
5235
  server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0' ? 'localhost' : BIND}:${PORT}`));
4630
5236
 
4631
- export { tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl };
5237
+ export {
5238
+ tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
5239
+ parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
5240
+ };
package/lib/mcp.js CHANGED
@@ -8,6 +8,7 @@ import { tokenBalance } from './x402.js';
8
8
  import { askWithContext, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
9
9
  import { listContexts } from './contexts.js';
10
10
  import { withNamespace } from './namespace.js';
11
+ import { subscriptionPublicView } from './subscription.js';
11
12
 
12
13
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
13
14
  // The model zoo_ask uses when the caller does not name one. Opus 5 by default:
@@ -383,6 +384,7 @@ export function buildMcpServer() {
383
384
  fundHint: 'send a few cents of a listed asset to the address for that rail — Solana assets to solanaAddress, Base assets to evmAddress. The shim converts to whatever the 402 quotes, at payment time. Force a rail with OPENZOO_RAIL=solana|base|robinhood.',
384
385
  balances,
385
386
  receipts: client.receipts.map((r) => ({ at: r.at, line: r.line })),
387
+ subscription: subscriptionPublicView(),
386
388
  });
387
389
  });
388
390
 
package/lib/pay.js CHANGED
@@ -13,6 +13,7 @@ import { withNamespace } from './namespace.js';
13
13
  import {
14
14
  resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
15
15
  } from './wrap.js';
16
+ import { applySubscriptionHeaders, loadSubscription, stripAuthorization } from './subscription.js';
16
17
 
17
18
  export class QuoteTooHighError extends Error {
18
19
  constructor(billedUsd, quote) {
@@ -333,8 +334,15 @@ export class PayClient {
333
334
  // Contexts are tenanted by this namespace server-side — a request without
334
335
  // it cannot see corpora this wallet bound.
335
336
  init = { ...init, headers: withNamespace(init.headers || {}) };
337
+ // Subscription key · no x402. A stored Stripe key is a bearer on the zoo
338
+ // API (same as the public /billing/done snippet). Wallet/x402 stays if
339
+ // there is no key, or if the gateway still answers 402.
340
+ const sub = loadSubscription();
341
+ if (sub?.key) init = { ...init, headers: applySubscriptionHeaders(init.headers, sub) };
336
342
  const first = await fetch(url, init);
337
- if (first.status !== 402) return { response: first, paid: false };
343
+ if (first.status !== 402) {
344
+ return { response: first, paid: false, subscription: Boolean(sub?.key && first.ok) };
345
+ }
338
346
 
339
347
  const quote = parse402(await first.json());
340
348
  // config.rail (OPENZOO_RAIL) steers every front — proxy, demo, MCP — since
@@ -394,7 +402,7 @@ export class PayClient {
394
402
  onStage?.('paying');
395
403
  const response = await fetch(url, {
396
404
  ...init,
397
- headers: { ...(init.headers || {}), 'X-PAYMENT': payment.header },
405
+ headers: { ...stripAuthorization(init.headers || {}), 'X-PAYMENT': payment.header },
398
406
  });
399
407
  const settle = decodeSettleHeader(response.headers.get('x-payment-response'))
400
408
  || { signature: payment.ownerSignature };
package/lib/proxy.js CHANGED
@@ -24,6 +24,7 @@ import { anthropicToOpenAI, openAIToAnthropic, streamOpenAIToAnthropic, writeAnt
24
24
  import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
25
25
  import { loadSessionSpend, saveSessionSpend } from './session.js';
26
26
  import { creditBalance, quotedPrices } from './info.js';
27
+ import { subscriptionPublicView } from './subscription.js';
27
28
  import { priceHoldings } from './livestatus.js';
28
29
 
29
30
  const HOP_BY_HOP = new Set([
@@ -891,6 +892,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
891
892
  res.end(JSON.stringify({
892
893
  spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
893
894
  creditUsd, chainUsd: money.chainUsd,
895
+ subscription: subscriptionPublicView(),
894
896
  }));
895
897
  return;
896
898
  }
@@ -914,6 +916,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
914
916
  creditUsd,
915
917
  chainUsd: money.chainUsd,
916
918
  holdings: money.holdings,
919
+ subscription: subscriptionPublicView(),
917
920
  }));
918
921
  return;
919
922
  }
@@ -1021,7 +1024,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1021
1024
  },
1022
1025
  mcp: `${self.replace(/\/v1$/, '')}/mcp`,
1023
1026
  upstream: config.apiBase,
1024
- payment: 'x402 per request from the operator\'s local burner wallet — no API key, no account',
1027
+ payment: subscriptionPublicView().active
1028
+ ? 'subscription key � no x402 � wallet/x402 remains the other method'
1029
+ : 'x402 per request from the operator\'s local burner wallet � no API key, no account',
1025
1030
  auth: viaTunnel
1026
1031
  ? 'this public URL requires the oz_… bearer for paid endpoints; /v1/models and /v1/hrr/bind are free'
1027
1032
  : 'localhost is keyless',
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Stripe subscription keys for the zoo API — the other pay lane next to
3
+ * wallet/x402. The live billing API is zoo.openzoo.fun; this file does not
4
+ * invent a second backend.
5
+ *
6
+ * After checkout the site lands on /billing/done?session=<cs_…> and polls
7
+ * GET /api/billing/key?session=… until Stripe confirms. That response is how
8
+ * a desktop client receives the key (no Stripe cookie on Electron). A user
9
+ * who already subscribed can paste the same key, or that success URL.
10
+ *
11
+ * Use: Authorization: Bearer <key> against x402-tokens.fly.dev — no 402
12
+ * signing. Wallet/x402 stays if no key is stored.
13
+ */
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ export const BILLING_ORIGIN = 'https://zoo.openzoo.fun';
19
+ export const SUBSCRIPTIONS_PAGE = 'https://zoo.openzoo.fun/subscriptions';
20
+
21
+ export function subscriptionFile(home = os.homedir()) {
22
+ return process.env.OPENZOO_SUBSCRIPTION_PATH
23
+ || path.join(home, '.openzoo', 'subscription.json');
24
+ }
25
+
26
+ function titleCase(id) {
27
+ const s = String(id || '').trim();
28
+ if (!s) return '';
29
+ return s.charAt(0).toUpperCase() + s.slice(1);
30
+ }
31
+
32
+ function asKey(v) {
33
+ return String(v || '').trim();
34
+ }
35
+
36
+ /** Persist a subscription key (chmod 600). Never log the value. */
37
+ export function saveSubscription(rec, file = subscriptionFile()) {
38
+ const key = asKey(rec?.key);
39
+ if (!key) return null;
40
+ const payload = {
41
+ key,
42
+ tier: rec.tier ? String(rec.tier) : null,
43
+ tierName: rec.tierName ? String(rec.tierName) : (rec.tier ? titleCase(rec.tier) : null),
44
+ sessionId: rec.sessionId ? String(rec.sessionId) : null,
45
+ savedAt: Date.now(),
46
+ };
47
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
48
+ const tmp = `${file}.tmp`;
49
+ fs.writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
50
+ fs.renameSync(tmp, file);
51
+ cached = { file, mtime: fs.statSync(file).mtimeMs, data: payload };
52
+ return payload;
53
+ }
54
+
55
+ export function clearSubscription(file = subscriptionFile()) {
56
+ try { fs.unlinkSync(file); } catch { /* already gone */ }
57
+ if (cached.file === file) cached = { file: '', mtime: 0, data: null };
58
+ }
59
+
60
+ let cached = { file: '', mtime: 0, data: null };
61
+
62
+ export function loadSubscription(file = subscriptionFile()) {
63
+ const envKey = asKey(process.env.OPENZOO_SUBSCRIPTION_KEY);
64
+ if (envKey) {
65
+ return {
66
+ key: envKey,
67
+ tier: process.env.OPENZOO_SUBSCRIPTION_TIER || null,
68
+ tierName: process.env.OPENZOO_SUBSCRIPTION_TIER
69
+ ? titleCase(process.env.OPENZOO_SUBSCRIPTION_TIER)
70
+ : null,
71
+ sessionId: null,
72
+ source: 'env',
73
+ };
74
+ }
75
+ try {
76
+ const st = fs.statSync(file);
77
+ if (cached.file === file && cached.mtime === st.mtimeMs && cached.data) return cached.data;
78
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
79
+ if (!asKey(data?.key)) {
80
+ cached = { file, mtime: st.mtimeMs, data: null };
81
+ return null;
82
+ }
83
+ const rec = {
84
+ key: asKey(data.key),
85
+ tier: data.tier || null,
86
+ tierName: data.tierName || (data.tier ? titleCase(data.tier) : null),
87
+ sessionId: data.sessionId || null,
88
+ source: 'file',
89
+ };
90
+ cached = { file, mtime: st.mtimeMs, data: rec };
91
+ return rec;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /** Public HUD/wallet view — never includes the secret. */
98
+ export function subscriptionPublicView(sub = loadSubscription()) {
99
+ if (!asKey(sub?.key)) return { active: false };
100
+ const name = String(sub.tierName || titleCase(sub.tier) || '').trim();
101
+ return {
102
+ active: true,
103
+ tier: sub.tier || null,
104
+ tierName: name || null,
105
+ label: name ? `${name} · no x402` : 'Subscription key · no x402',
106
+ };
107
+ }
108
+
109
+ /**
110
+ * A paste is either the bearer key itself, or the site's success URL
111
+ * (`/billing/done?session=cs_…`). session_id is accepted too — Stripe's
112
+ * default query name — but the live page uses `session`.
113
+ */
114
+ export function parseSubscriptionPaste(text) {
115
+ const raw = String(text || '').trim();
116
+ if (!raw) return { error: 'empty' };
117
+ let session = '';
118
+ try {
119
+ if (/^https?:\/\//i.test(raw) || raw.includes('session=')) {
120
+ const url = new URL(raw, BILLING_ORIGIN);
121
+ session = url.searchParams.get('session') || url.searchParams.get('session_id') || '';
122
+ }
123
+ } catch { /* not a URL */ }
124
+ if (!session) {
125
+ const m = /(?:session_id|session)=([A-Za-z0-9_]+)/.exec(raw);
126
+ if (m) session = m[1];
127
+ }
128
+ if (session) return { session };
129
+ if (/^https?:\/\//i.test(raw)) return { error: 'no session in URL' };
130
+ if (raw.length < 8 || /\s/.test(raw)) return { error: 'not a key' };
131
+ return { key: raw };
132
+ }
133
+
134
+ export function applySubscriptionHeaders(headers = {}, sub = loadSubscription()) {
135
+ const key = asKey(sub?.key);
136
+ if (!key) return headers;
137
+ return { ...headers, authorization: `Bearer ${key}` };
138
+ }
139
+
140
+ export function stripAuthorization(headers = {}) {
141
+ const out = { ...headers };
142
+ delete out.authorization;
143
+ delete out.Authorization;
144
+ return out;
145
+ }
146
+
147
+ async function billingJson(url, init) {
148
+ const r = await fetch(url, init);
149
+ const body = await r.json().catch(() => ({}));
150
+ return { http: r.status, body };
151
+ }
152
+
153
+ export async function billingTiers() {
154
+ const { http, body } = await billingJson(`${BILLING_ORIGIN}/api/billing/tiers`);
155
+ if (!body?.ok || !Array.isArray(body.tiers)) {
156
+ throw new Error(body?.error || `tiers HTTP ${http}`);
157
+ }
158
+ return body;
159
+ }
160
+
161
+ export async function billingCheckout(tier) {
162
+ const id = String(tier || '').trim();
163
+ if (!id) throw new Error('tier required');
164
+ const { http, body } = await billingJson(`${BILLING_ORIGIN}/api/billing/checkout`, {
165
+ method: 'POST',
166
+ headers: { 'content-type': 'application/json' },
167
+ body: JSON.stringify({ tier: id }),
168
+ });
169
+ if (!body?.ok || !body.url) {
170
+ throw new Error(body?.error || `checkout HTTP ${http}`);
171
+ }
172
+ return { ok: true, url: body.url, sessionId: body.sessionId || null, tier: id };
173
+ }
174
+
175
+ /** Poll the same endpoint the public /billing/done page uses. */
176
+ export async function fetchBillingKey(session) {
177
+ const sid = String(session || '').trim();
178
+ if (!sid) return { ok: false, error: 'session required' };
179
+ const { body } = await billingJson(
180
+ `${BILLING_ORIGIN}/api/billing/key?session=${encodeURIComponent(sid)}`,
181
+ );
182
+ return body && typeof body === 'object' ? body : { ok: false, error: 'empty key response' };
183
+ }
184
+
185
+ /**
186
+ * If the live key endpoint returned a key, persist it and return a public
187
+ * view (the secret stays on disk). Pending/error bodies pass through.
188
+ */
189
+ export function ingestBillingKeyResponse(body, extra = {}, file = subscriptionFile()) {
190
+ const key = asKey(body?.key);
191
+ if (!key) {
192
+ if (body?.pending) return { ok: true, pending: true, saved: false };
193
+ return {
194
+ ok: false,
195
+ pending: false,
196
+ saved: false,
197
+ error: body?.error || 'no key yet',
198
+ };
199
+ }
200
+ const rec = saveSubscription({
201
+ key,
202
+ tier: body.tier || extra.tier || null,
203
+ tierName: body.tierName || body.name || extra.tierName || null,
204
+ sessionId: extra.sessionId || extra.session || null,
205
+ }, file);
206
+ return { ok: true, pending: false, saved: true, ...subscriptionPublicView(rec) };
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.89",
3
+ "version": "0.48.92",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",