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 +736 -81
- package/lib/mcp.js +2 -0
- package/lib/pay.js +10 -2
- package/lib/proxy.js +6 -1
- package/lib/subscription.js +207 -0
- package/package.json +1 -1
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
|
|
32
|
-
// (../,
|
|
33
|
-
//
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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>
|
|
181
|
-
|
|
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>
|
|
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
|
|
751
|
-
|
|
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: '
|
|
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
|
|
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
|
-
//
|
|
1131
|
-
//
|
|
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
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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 {
|
|
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 —
|
|
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
|
-
|
|
1703
|
-
|
|
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')
|
|
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
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
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 =
|
|
1893
|
-
const entries =
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
2639
|
-
|
|
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) {
|
|
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:
|
|
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:
|
|
2772
|
-
|
|
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>
|
|
2972
|
-
<div class="wsub">
|
|
2973
|
-
<div id="
|
|
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="
|
|
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">↻</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
|
-
|
|
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"
|
|
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="
|
|
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
|
-
|
|
3187
|
-
//
|
|
3188
|
-
//
|
|
3189
|
-
|
|
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: '/
|
|
3197
|
-
pingBtn.textContent = '
|
|
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"
|
|
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 <path> 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
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
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
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>' }[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"
|
|
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
|
|
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
|
|
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 {
|
|
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
|
+
};
|