openzoo 0.42.0 → 0.43.1

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 ADDED
@@ -0,0 +1,856 @@
1
+ // Standalone Grok-Bot-lookalike desktop chat client, backed directly by
2
+ // openzoo — no sandbox/daemon hijack required. Sidebar of threads + a
3
+ // message canvas, styled to match /Applications/Grok Bot.app. The twist
4
+ // Grok Bot actually has: any thread's agent can SPAWN a new thread with its
5
+ // own independent agent (and that agent can spawn further threads too) —
6
+ // reusing the same SPAWN/SEND pattern podagent.mjs built for shell delegation,
7
+ // adapted here for plain chat.
8
+ import http from 'node:http';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import path from 'node:path';
13
+ import { brain } from './podagent.mjs';
14
+
15
+ const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
16
+ const STORE_DIR = path.join(homedir(), '.openzoo');
17
+ const STORE_FILE = path.join(STORE_DIR, 'grokui-threads.json');
18
+
19
+ // Real but SANDBOXED filesystem access for the bots — each THREAD has its own
20
+ // root dir (default: a dedicated workspace, never the user's whole disk), and
21
+ // the user can point a thread at a real project folder with "/dir <path>" in
22
+ // chat. safeResolveIn rejects any path that would escape that thread's root
23
+ // (../, absolute paths, symlink tricks via normalize) — access is real, but
24
+ // always contained to whatever root was explicitly chosen for that thread.
25
+ const WORKSPACE_DIR = path.join(homedir(), '.openzoo', 'grokui-workspace');
26
+ mkdirSync(WORKSPACE_DIR, { recursive: true });
27
+ function expandHome(p) { return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p; }
28
+ function dirFor(threadId) { return threads.get(threadId)?.dir || WORKSPACE_DIR; }
29
+ function safeResolveIn(base, rel) {
30
+ const full = path.normalize(path.join(base, rel));
31
+ if (full !== base && !full.startsWith(base + path.sep)) {
32
+ throw new Error("path escapes this thread's directory");
33
+ }
34
+ return full;
35
+ }
36
+ const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
37
+ mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
38
+ jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
39
+ let workspacePort = null;
40
+ // route: /<threadId>/<relpath...> — each thread is served from ITS OWN dir
41
+ const workspaceServer = http.createServer((req, res) => {
42
+ try {
43
+ const urlPath = decodeURIComponent((req.url || '/').split('?')[0]).replace(/^\/+/, '');
44
+ const slash = urlPath.indexOf('/');
45
+ const threadId = slash === -1 ? urlPath : urlPath.slice(0, slash);
46
+ let rel = slash === -1 ? '' : urlPath.slice(slash + 1);
47
+ if (!rel) rel = 'index.html';
48
+ const full = safeResolveIn(dirFor(threadId), rel);
49
+ const data = readFileSync(full);
50
+ const ext = full.split('.').pop();
51
+ res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' });
52
+ res.end(data);
53
+ } catch {
54
+ res.writeHead(404, { 'content-type': 'text/plain' });
55
+ res.end('not found');
56
+ }
57
+ });
58
+ workspaceServer.listen(0, '127.0.0.1', () => { workspacePort = workspaceServer.address().port; });
59
+
60
+ const PALETTE = ['#e91e8c', '#34c759', '#ff9500', '#5e5ce6', '#ff3b30', '#0a84ff', '#00c7be'];
61
+ function colorFor(name) {
62
+ let h = 0;
63
+ for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
64
+ return PALETTE[h % PALETTE.length];
65
+ }
66
+
67
+ const SYSTEM = `You are a helpful assistant served over openzoo (pay-per-call access to ~435
68
+ models, no API key, no account). Reply normally in plain text, concisely.
69
+
70
+ If — and only if — the request genuinely calls for independent parallel work (the user
71
+ asks you to spawn/delegate/create agents, or a task splits cleanly into independent
72
+ subtasks), you may instead reply with EXACTLY one line, no prose, using one of:
73
+ SPAWN: <short name> | <task for the new agent> create a new thread with its own
74
+ independent agent and give it a task
75
+ SEND: <name> | <message> message an agent thread that already
76
+ exists (yours or one you spawned)
77
+ PING: <name> one-line status: still working, or
78
+ its last result
79
+ PEEK: <name> a fuller look — its last few messages,
80
+ not just the latest one
81
+ You are given the result before your next line, so none of these block you — check back
82
+ later if it's still working.
83
+
84
+ You ALSO have real (sandboxed) filesystem access, scoped to THIS thread's own directory —
85
+ the user sets or changes it by sending "/dir <path>" in chat; until they do, it's a private
86
+ workspace folder, not their real project. Same one-line-no-prose reply format:
87
+ WRITE: <relative path> | <content> create or overwrite a file
88
+ READ: <relative path> read a file back
89
+ SERVE: <relative path, or blank for the dir root> get a real http:// URL for a file —
90
+ use this instead of claiming you
91
+ "can't expose a port": you can serve
92
+ static files, just not run a process
93
+ For normal questions just answer directly — do not use any of these unless the request
94
+ actually calls for delegation or file work.`;
95
+
96
+ // id -> { id, name, color, parent, messages: [{role,content}], history: [{who,text}], status }
97
+ const threads = new Map();
98
+
99
+ // Threads are the whole point of the app — losing them on every restart (the
100
+ // server got restarted a lot while iterating this session) is a real bug, not
101
+ // a nice-to-have. Plain JSON on disk; the volume of chat here never justifies
102
+ // a database.
103
+ function saveThreads() {
104
+ try {
105
+ mkdirSync(STORE_DIR, { recursive: true });
106
+ writeFileSync(STORE_FILE, JSON.stringify([...threads.values()]));
107
+ } catch { /* best effort */ }
108
+ }
109
+
110
+ function loadThreads() {
111
+ try {
112
+ if (!existsSync(STORE_FILE)) return false;
113
+ const arr = JSON.parse(readFileSync(STORE_FILE, 'utf8'));
114
+ if (!Array.isArray(arr) || !arr.length) return false;
115
+ for (const t of arr) threads.set(t.id, t);
116
+ return true;
117
+ } catch { return false; }
118
+ }
119
+
120
+ function newThread(name, parent, members) {
121
+ const id = randomUUID();
122
+ const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
123
+ messages: members ? null : [{ role: 'system', content: SYSTEM }],
124
+ members: members || null, history: [], status: 'idle', createdAt: Date.now() };
125
+ threads.set(id, t);
126
+ saveThreads();
127
+ return t;
128
+ }
129
+
130
+ function makeMember(name) {
131
+ return { name, color: colorFor(name), systemPrompt:
132
+ `You are ${name}, one of several bots in a shared group chat served over openzoo
133
+ (pay-per-call access to ~435 models, no API key, no account). Reply normally and concisely
134
+ as yourself. You can see the WHOLE shared conversation, including what the other bots in
135
+ this group already said — their messages are prefixed "[Name]:" so you can tell them apart
136
+ from the human. A message addressed "@everyone" is meant for the whole group — give your
137
+ own take even if brief ("Passed." is fine when you have nothing to add). COORDINATE: if
138
+ another bot already handled or is handling the request (e.g. already spawned the exact
139
+ agent being asked for), do NOT repeat it — just acknowledge, or add something genuinely new.
140
+
141
+ You can ALSO delegate, same as any other agent here. If — and only if — asked to
142
+ spawn/delegate/create agents AND no other bot has already done it this round, reply with
143
+ EXACTLY one line, no prose, using one of:
144
+ SPAWN: <short name> | <task for the new agent> create a new thread with its own agent
145
+ SEND: <name> | <message> message an existing agent thread
146
+ PING: <name> one-line status, or its last result
147
+ PEEK: <name> a fuller look at its last few messages
148
+
149
+ You ALSO have real (sandboxed) filesystem access, scoped to THIS group's own directory —
150
+ the user sets or changes it with "/dir <path>" in chat. Same format:
151
+ WRITE: <relative path> | <content> create or overwrite a file
152
+ READ: <relative path> read a file back
153
+ SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
154
+ this instead of saying you can't
155
+ expose a port
156
+ For normal replies just answer directly — do not use any of these unless the request
157
+ actually calls for delegation or file work.` };
158
+ }
159
+
160
+ // Rebuilds a member's context fresh from the shared thread history every turn
161
+ // (instead of a private per-member log) so each bot sees what the others in
162
+ // the group already said — including earlier replies from THIS round, since
163
+ // runTurn pushes to t.history sequentially, one member at a time.
164
+ function buildMemberMessages(t, member) {
165
+ const msgs = [{ role: 'system', content: member.systemPrompt || SYSTEM }];
166
+ for (const h of t.history) {
167
+ if (h.who === 'user') msgs.push({ role: 'user', content: h.text });
168
+ else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
169
+ else msgs.push({ role: 'user', content: `[${h.name}]: ${h.text}` });
170
+ }
171
+ return msgs;
172
+ }
173
+
174
+ function newGroupThread(names) {
175
+ const members = names.map(makeMember);
176
+ return newThread(names.join(', '), null, members);
177
+ }
178
+
179
+ function findByName(name) {
180
+ let best = null;
181
+ for (const t of threads.values()) {
182
+ if (t.name.toLowerCase() === name.toLowerCase() && (!best || t.createdAt > best.createdAt)) best = t;
183
+ }
184
+ return best;
185
+ }
186
+
187
+ if (!loadThreads()) newThread('openzoo', null);
188
+
189
+ // Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
190
+ // (creating or messaging another thread), and returns the ack text to show in
191
+ // place of the raw directive line — or null if the reply wasn't a directive.
192
+ function tryDirective(reply, originId) {
193
+ const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
194
+ if (spawn) {
195
+ const name = spawn[1].trim();
196
+ const task = spawn[2].trim();
197
+ const sub = newThread(name, originId);
198
+ runTurn(sub.id, task).catch(() => {}); // fire and forget — runs independently
199
+ return `Spawned ${name} — working on it.`;
200
+ }
201
+ const sendM = /^SEND:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
202
+ if (sendM) {
203
+ const name = sendM[1].trim();
204
+ const msg = sendM[2].trim();
205
+ const target = findByName(name);
206
+ if (target) runTurn(target.id, msg).catch(() => {});
207
+ return target ? `Messaged ${name}.` : `No thread named "${name}" to message.`;
208
+ }
209
+ const ping = /^PING:\s*(.+)/.exec(reply);
210
+ if (ping) {
211
+ const name = ping[1].trim();
212
+ const target = findByName(name);
213
+ const last = target?.history[target.history.length - 1];
214
+ return !target ? `No thread named "${name}".`
215
+ : target.status === 'thinking' ? `${name} is still working.`
216
+ : last ? `${name}: ${last.text}` : `${name} hasn't replied yet.`;
217
+ }
218
+ const peek = /^PEEK:\s*(.+)/.exec(reply);
219
+ if (peek) {
220
+ const name = peek[1].trim();
221
+ const target = findByName(name);
222
+ if (!target) return `No thread named "${name}".`;
223
+ const recent = target.history.slice(-4)
224
+ .map((h) => (h.who === 'user' ? 'you' : (h.name || target.name)) + ': ' + h.text).join('\n');
225
+ return `${name} (${target.status}):\n${recent || '(nothing yet)'}`;
226
+ }
227
+ const write = /^WRITE:\s*([^|]+)\|([\s\S]+)/.exec(reply);
228
+ if (write) {
229
+ const rel = write[1].trim();
230
+ const content = write[2].replace(/^\n/, '');
231
+ try {
232
+ const full = safeResolveIn(dirFor(originId), rel);
233
+ mkdirSync(path.dirname(full), { recursive: true });
234
+ writeFileSync(full, content);
235
+ return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.`;
236
+ } catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
237
+ }
238
+ const readD = /^READ:\s*(.+)/.exec(reply);
239
+ if (readD) {
240
+ const rel = readD[1].trim();
241
+ try {
242
+ const data = readFileSync(safeResolveIn(dirFor(originId), rel), 'utf8');
243
+ return `${rel}:\n${data.slice(0, 4000)}${data.length > 4000 ? '\n…(truncated)' : ''}`;
244
+ } catch (e) { return `Couldn't read ${rel}: ${e.message}`; }
245
+ }
246
+ const serve = /^SERVE:\s*(.*)$/.exec(reply);
247
+ if (serve) {
248
+ const rel = serve[1].trim();
249
+ if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
250
+ return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
251
+ }
252
+ return null;
253
+ }
254
+
255
+ async function runTurn(threadId, userText) {
256
+ const t = threads.get(threadId);
257
+ if (!t) return;
258
+ t.history.push({ who: 'user', text: userText });
259
+ if (t.members) {
260
+ t.status = 'thinking';
261
+ // sequential, not parallel: each member's context is rebuilt from
262
+ // t.history right before its turn, so it sees every reply (including
263
+ // spawns/sends) the earlier members in THIS round already made
264
+ for (const m of t.members) {
265
+ const msgs = buildMemberMessages(t, m);
266
+ let r = '';
267
+ try { r = (await brain(msgs)).trim(); } catch (e) { r = `error: ${e.message}`; }
268
+ const ack = tryDirective(r, t.id);
269
+ t.history.push({ who: 'bot', text: ack ?? (r || '(no response)'), name: m.name, color: m.color });
270
+ }
271
+ t.status = 'idle';
272
+ saveThreads();
273
+ return;
274
+ }
275
+ t.messages.push({ role: 'user', content: userText });
276
+ t.status = 'thinking';
277
+ let reply = '';
278
+ try {
279
+ reply = (await brain(t.messages)).trim();
280
+ } catch (e) {
281
+ reply = `error: ${e.message}`;
282
+ }
283
+ t.messages.push({ role: 'assistant', content: reply });
284
+ const ack = tryDirective(reply, t.id);
285
+ t.history.push({ who: 'bot', text: ack ?? (reply || '(no response)') });
286
+ t.status = 'idle';
287
+ saveThreads();
288
+ }
289
+
290
+ function threadSummary(t) {
291
+ const last = t.history[t.history.length - 1];
292
+ return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
293
+ preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '', createdAt: t.createdAt };
294
+ }
295
+
296
+ const APP_HTML = `<!doctype html>
297
+ <html><head><meta charset="utf-8"><title>openzoo</title>
298
+ <meta name="viewport" content="width=device-width,initial-scale=1">
299
+ <style>
300
+ :root { color-scheme: dark; }
301
+ * { box-sizing: border-box; }
302
+ html, body { margin: 0; height: 100%; background: #000; }
303
+ body { color: #ececec; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
304
+ display: flex; }
305
+ #dragbar { -webkit-app-region: drag; position: fixed; top: 0; left: 0; right: 0; height: 28px; z-index: 1000; }
306
+ #sidebar { width: 280px; flex: 0 0 280px; border-right: 1px solid #1c1c1e; display: flex; flex-direction: column;
307
+ height: 100vh; padding-top: 28px; }
308
+ #main { padding-top: 28px; }
309
+ #sideTop { display: flex; align-items: center; gap: 4px; padding: 0 8px; }
310
+ #sideTop #search { flex: 1; }
311
+ #search { margin: 12px; padding: 8px 12px; background: #1c1c1e; border-radius: 10px; color: #ececec;
312
+ border: none; font: inherit; }
313
+ #search::placeholder { color: #8e8e93; }
314
+ #threads { flex: 1; overflow-y: auto; }
315
+ .trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
316
+ margin: 0 6px 2px; }
317
+ .trow:hover { background: #17171a; }
318
+ .trow.active { background: #1c1c1e; }
319
+ .tavatar { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: flex; align-items: center;
320
+ justify-content: center; color: #fff; font-weight: 600; font-size: 14px; }
321
+ .tmeta { min-width: 0; flex: 1; }
322
+ .tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
323
+ .tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
324
+ .tdot { width: 8px; height: 8px; border-radius: 50%; background: #0a84ff; flex: 0 0 8px; }
325
+ #main { flex: 1; display: flex; flex-direction: column; height: 100vh; }
326
+ #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
327
+ font-weight: 600; }
328
+ #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
329
+ #hudBtn { margin-left: auto; }
330
+ #chatHeaderId { display: flex; align-items: center; gap: 10px; }
331
+ #hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
332
+ border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
333
+ display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
334
+ #hud.show { display: block; }
335
+ #hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
336
+ #hud .hrow { display: flex; justify-content: space-between; margin: 6px 0; color: #f0f0eb; font-size: 12px; }
337
+ #hud .hrow span:first-child { color: #999aa8; font-size: 10.5px; }
338
+ #hud .hlime { color: #b8f240; }
339
+ #hud .hember { color: #f28c4d; }
340
+ #hud .hfoot { border-top: 1px solid #333340; margin-top: 10px; padding-top: 8px; color: #999aa8; font-size: 10px; }
341
+ #log { flex: 1; overflow-y: auto; padding: 20px 24px 12px; display: flex; flex-direction: column; gap: 6px; }
342
+ .hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
343
+ color: #8e8e93; font-size: 13px; }
344
+ .hdr .avatar { width: 18px; height: 18px; border-radius: 5px; display: flex; align-items: center;
345
+ justify-content: center; color: #fff; font-size: 9px; font-weight: 700; }
346
+ .row { display: flex; max-width: 78%; margin: 2px 0; }
347
+ .row.user { align-self: flex-end; }
348
+ .row.bot { align-self: flex-start; }
349
+ .bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word; }
350
+ .row.user .bubble { background: #57575c; }
351
+ .row.bot .bubble { background: #262626; color: #ececec; }
352
+ .row.bot.pending .bubble { color: #8e8e93; }
353
+ .dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
354
+ background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
355
+ .dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
356
+ @keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
357
+ #bar { padding: 10px 16px 18px; position: relative; }
358
+ #row-input { display: flex; align-items: center; gap: 8px; }
359
+ #plusMenu { position: absolute; bottom: 62px; left: 16px; background: #1c1c1e; border-radius: 14px;
360
+ padding: 6px; display: none; flex-direction: column; min-width: 190px;
361
+ box-shadow: 0 8px 24px rgba(0,0,0,.5); z-index: 10; }
362
+ #plusMenu.show { display: flex; }
363
+ .pop-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px;
364
+ cursor: pointer; color: #ececec; font-size: 14px; }
365
+ .pop-item:hover { background: #2c2c2e; }
366
+ .pop-item svg { width: 18px; height: 18px; flex: 0 0 18px; }
367
+ .pop-item.record svg { color: #ff3b30; }
368
+ #pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
369
+ padding: 8px 10px 8px 14px; }
370
+ .icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
371
+ color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
372
+ flex: 0 0 32px; }
373
+ .icon-btn:hover { background: #3a3a3c; }
374
+ .icon-btn svg { width: 18px; height: 18px; }
375
+ #attachChips { display: flex; gap: 6px; flex-wrap: wrap; padding: 0 16px 6px; }
376
+ .achip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 10px;
377
+ padding: 4px 8px; font-size: 12px; }
378
+ .achip .ax { cursor: pointer; color: #8e8e93; }
379
+ #inp { flex: 1; background: transparent; border: none; color: #ececec; font: inherit;
380
+ padding: 6px 0; min-width: 0; }
381
+ #inp::placeholder { color: #8e8e93; }
382
+ #inp:focus { outline: none; }
383
+ #send { width: 34px; height: 34px; border-radius: 50%; border: none; background: #fff; color: #000;
384
+ display: none; align-items: center; justify-content: center; cursor: pointer; flex: 0 0 34px; }
385
+ #send.show { display: flex; }
386
+ #send svg { width: 16px; height: 16px; }
387
+ #composeOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; align-items: flex-start;
388
+ justify-content: center; padding-top: 90px; z-index: 200; }
389
+ #composeOverlay.show { display: flex; }
390
+ #composeBox { width: 460px; max-height: 65vh; background: #1c1c1e; border-radius: 16px; overflow: hidden;
391
+ display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,.6); }
392
+ #composeTo { display: flex; align-items: center; gap: 8px; padding: 14px 16px; border-bottom: 1px solid #2c2c2e;
393
+ color: #8e8e93; flex-wrap: wrap; }
394
+ #chips { display: flex; gap: 6px; flex-wrap: wrap; }
395
+ .chip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 14px;
396
+ padding: 3px 8px 3px 4px; font-size: 13px; }
397
+ .chip .cav { width: 16px; height: 16px; border-radius: 4px; display: inline-block; }
398
+ .chip .cx { cursor: pointer; color: #8e8e93; margin-left: 2px; }
399
+ #composeTo input { flex: 1; min-width: 100px; background: transparent; border: none; color: #ececec; font: inherit; }
400
+ #composeTo input:focus { outline: none; }
401
+ #composeList { overflow-y: auto; padding: 8px; }
402
+ .crow { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 10px; cursor: pointer; }
403
+ .crow:hover { background: #2c2c2e; }
404
+ .crow .kbd { margin-left: auto; display: flex; gap: 4px; }
405
+ kbd { background: #2c2c2e; border-radius: 5px; padding: 2px 6px; font-size: 11px; color: #8e8e93; }
406
+ #composeFoot { display: flex; gap: 16px; padding: 10px 16px; border-top: 1px solid #2c2c2e; color: #8e8e93;
407
+ font-size: 12px; }
408
+ #composeFoot kbd { margin-right: 4px; }
409
+ .mention { background: #3a3a3c; border-radius: 10px; padding: 1px 8px; font-size: 0.92em; }
410
+ </style></head>
411
+ <body>
412
+ <div id="dragbar"></div>
413
+ <div id="sidebar">
414
+ <div id="sideTop">
415
+ <button class="icon-btn" id="newMsgBtn">
416
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
417
+ </button>
418
+ <input id="search" placeholder="Search">
419
+ </div>
420
+ <div id="threads"></div>
421
+ </div>
422
+ <div id="composeOverlay">
423
+ <div id="composeBox">
424
+ <div id="composeTo">
425
+ <span>To:</span>
426
+ <span id="chips"></span>
427
+ <input id="composeInp" placeholder="Search or create Bots">
428
+ <button class="icon-btn" id="composeClose">
429
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
430
+ </button>
431
+ </div>
432
+ <div id="composeList"></div>
433
+ <div id="composeFoot"><span><kbd>Tab</kbd> add</span><span><kbd>Enter</kbd> open</span></div>
434
+ </div>
435
+ </div>
436
+ <div id="main">
437
+ <div id="chatHeader">
438
+ <div id="chatHeaderId"></div>
439
+ <button class="icon-btn" id="hudBtn">◎</button>
440
+ </div>
441
+ <div id="hud">
442
+ <div class="htitle">ALL OF OPENZOO · TODAY</div>
443
+ <div class="hrow"><span>paid (metered)</span><span id="hPaid">—</span></div>
444
+ <div class="hrow"><span>our cost (cogs)</span><span id="hCogs">—</span></div>
445
+ <div class="hrow"><span>margin</span><span id="hMargin" class="hlime">—</span></div>
446
+ <div class="hrow"><span>direct would be</span><span id="hDirect" class="hember">—</span></div>
447
+ <div class="hrow"><span>leCore saving</span><span id="hSaved" class="hlime">—</span></div>
448
+ <div class="hfoot" id="hFoot">loading…</div>
449
+ <div class="htitle" style="margin-top:10px">YOUR WALLET · THIS SESSION</div>
450
+ <div class="hrow"><span>you've spent</span><span id="hYouSpent">—</span></div>
451
+ <div class="hrow"><span>your paid calls</span><span id="hYouCalls">—</span></div>
452
+ </div>
453
+ <div id="log"></div>
454
+ <div id="bar">
455
+ <div id="plusMenu">
456
+ <div class="pop-item" id="attachBtn">
457
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
458
+ <span>Attach files</span>
459
+ </div>
460
+ </div>
461
+ <input id="fileInp" type="file" multiple style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;">
462
+ <div id="attachChips"></div>
463
+ <div id="row-input">
464
+ <div id="pill">
465
+ <button class="icon-btn" id="plusBtn">
466
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
467
+ </button>
468
+ <input id="inp" placeholder="Message" autofocus>
469
+ <button class="icon-btn" tabindex="-1">
470
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
471
+ </button>
472
+ </div>
473
+ <button id="send">
474
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M6 11l6-6 6 6"/></svg>
475
+ </button>
476
+ </div>
477
+ </div>
478
+ </div>
479
+ <script>
480
+ const threadsEl = document.getElementById('threads');
481
+ const chatHeader = document.getElementById('chatHeader');
482
+ const log = document.getElementById('log');
483
+ const inp = document.getElementById('inp');
484
+ const send = document.getElementById('send');
485
+ let activeId = null;
486
+ let knownThreads = [];
487
+
488
+ function initials(name) { return name.slice(0, 2).toUpperCase(); }
489
+
490
+ async function loadThreads() {
491
+ const list = await (await fetch('/threads')).json();
492
+ knownThreads = list;
493
+ if (!activeId && list.length) activeId = list[0].id;
494
+ threadsEl.innerHTML = '';
495
+ for (const t of list) {
496
+ const row = document.createElement('div');
497
+ row.className = 'trow' + (t.id === activeId ? ' active' : '');
498
+ row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
499
+ '<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
500
+ (t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
501
+ (t.status === 'thinking' ? '<div class="tdot"></div>' : '');
502
+ row.addEventListener('click', () => { activeId = t.id; render(); });
503
+ threadsEl.appendChild(row);
504
+ }
505
+ }
506
+
507
+ async function loadActiveMessages() {
508
+ if (!activeId) return null;
509
+ return await (await fetch('/threads/' + activeId)).json();
510
+ }
511
+
512
+ function renderHeader(t) {
513
+ document.getElementById('chatHeaderId').innerHTML =
514
+ '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div><div>' + t.name + '</div>';
515
+ }
516
+
517
+ function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
518
+ function renderMentions(text) { return escapeHtml(text).replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>'); }
519
+
520
+ let lastSpeaker = null;
521
+ function addRow(who, text, color, name) {
522
+ const speakerKey = who + '|' + name;
523
+ if (who === 'bot' && speakerKey !== lastSpeaker) {
524
+ const hdr = document.createElement('div');
525
+ hdr.className = 'hdr';
526
+ hdr.innerHTML = '<span class="avatar" style="background:' + color + '">' + initials(name) + '</span><span>' + name + '</span>';
527
+ log.appendChild(hdr);
528
+ }
529
+ lastSpeaker = speakerKey;
530
+ const row = document.createElement('div');
531
+ row.className = 'row ' + who;
532
+ const bubble = document.createElement('div');
533
+ bubble.className = 'bubble';
534
+ bubble.innerHTML = renderMentions(text);
535
+ row.appendChild(bubble);
536
+ log.appendChild(row);
537
+ }
538
+
539
+ async function render() {
540
+ const t = knownThreads.find((x) => x.id === activeId);
541
+ if (!t) return;
542
+ renderHeader(t);
543
+ inp.placeholder = 'Message ' + t.name;
544
+ const full = await loadActiveMessages();
545
+ if (!full || full.id !== activeId) return;
546
+ // only re-pin to bottom if the reader was already there — otherwise a
547
+ // background poll (tick() runs every 1.2s) yanks them back mid-scroll
548
+ const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
549
+ log.innerHTML = '';
550
+ lastSpeaker = null;
551
+ for (const h of full.history) addRow(h.who, h.text, h.color || t.color, h.name || t.name);
552
+ if (full.status === 'thinking') addRow('bot', '…', t.color, t.name);
553
+ if (wasNearBottom) log.scrollTop = log.scrollHeight;
554
+ }
555
+
556
+ let pendingFiles = [];
557
+ const attachChips = document.getElementById('attachChips');
558
+ function renderAttachChips() {
559
+ attachChips.innerHTML = '';
560
+ pendingFiles.forEach((f, i) => {
561
+ const chip = document.createElement('span');
562
+ chip.className = 'achip';
563
+ chip.innerHTML = '<span>' + escapeHtml(f.name) + (f.content === null ? ' (binary — name only)' : '') + '</span><span class="ax">✕</span>';
564
+ chip.querySelector('.ax').addEventListener('click', () => { pendingFiles.splice(i, 1); renderAttachChips(); });
565
+ attachChips.appendChild(chip);
566
+ });
567
+ }
568
+ function readFileAsText(file) {
569
+ return new Promise((resolve) => {
570
+ const r = new FileReader();
571
+ r.onload = () => resolve(r.result);
572
+ r.onerror = () => resolve(null);
573
+ r.readAsText(file);
574
+ });
575
+ }
576
+
577
+ async function submit() {
578
+ const task = inp.value.trim();
579
+ if ((!task && !pendingFiles.length) || !activeId) return;
580
+ inp.value = '';
581
+ send.classList.remove('show');
582
+ let full = task;
583
+ for (const f of pendingFiles) {
584
+ full += f.content !== null
585
+ ? '\\n\\n--- attached: ' + f.name + ' ---\\n' + f.content
586
+ : '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
587
+ }
588
+ pendingFiles = [];
589
+ renderAttachChips();
590
+ await fetch('/drive', {
591
+ method: 'POST', headers: { 'content-type': 'application/json' },
592
+ body: JSON.stringify({ threadId: activeId, task: full }),
593
+ });
594
+ render();
595
+ }
596
+
597
+ inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0); });
598
+ send.addEventListener('click', submit);
599
+ inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
600
+
601
+ const plusBtn = document.getElementById('plusBtn');
602
+ const plusMenu = document.getElementById('plusMenu');
603
+ const fileInp = document.getElementById('fileInp');
604
+ plusBtn.addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.toggle('show'); });
605
+ document.addEventListener('click', () => plusMenu.classList.remove('show'));
606
+ document.getElementById('attachBtn').addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.remove('show'); fileInp.click(); });
607
+ fileInp.addEventListener('change', async () => {
608
+ for (const f of Array.from(fileInp.files)) {
609
+ const looksText = /^text\\//.test(f.type) || /\\.(txt|md|js|mjs|ts|tsx|jsx|py|json|css|html|csv|log|ya?ml|sh)$/i.test(f.name);
610
+ const content = (looksText && f.size < 200000) ? await readFileAsText(f) : null;
611
+ pendingFiles.push({ name: f.name, size: f.size, content });
612
+ }
613
+ fileInp.value = '';
614
+ renderAttachChips();
615
+ send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0);
616
+ });
617
+
618
+ // --- compose overlay ("+" next to search: pick/create Bots, single or group) ---
619
+ const newMsgBtn = document.getElementById('newMsgBtn');
620
+ const composeOverlay = document.getElementById('composeOverlay');
621
+ const composeInp = document.getElementById('composeInp');
622
+ const composeList = document.getElementById('composeList');
623
+ const chipsEl = document.getElementById('chips');
624
+ const composeClose = document.getElementById('composeClose');
625
+ let composeSel = [];
626
+
627
+ function openCompose() {
628
+ composeSel = [];
629
+ chipsEl.innerHTML = '';
630
+ composeInp.value = '';
631
+ renderComposeList();
632
+ composeOverlay.classList.add('show');
633
+ composeInp.focus();
634
+ }
635
+ function closeCompose() { composeOverlay.classList.remove('show'); }
636
+
637
+ function addChip(t) {
638
+ composeSel.push({ name: t.name, color: t.color });
639
+ const chip = document.createElement('span');
640
+ chip.className = 'chip';
641
+ chip.innerHTML = '<span class="cav" style="background:' + t.color + '"></span>' + t.name + '<span class="cx">✕</span>';
642
+ chip.querySelector('.cx').addEventListener('click', () => {
643
+ composeSel = composeSel.filter((c) => c.name !== t.name);
644
+ chip.remove();
645
+ renderComposeList();
646
+ });
647
+ chipsEl.appendChild(chip);
648
+ composeInp.value = '';
649
+ renderComposeList();
650
+ composeInp.focus();
651
+ }
652
+
653
+ function renderComposeList() {
654
+ const q = composeInp.value.trim().toLowerCase();
655
+ const chosen = new Set(composeSel.map((c) => c.name));
656
+ const candidates = knownThreads.filter((t) => !chosen.has(t.name) && t.name.toLowerCase().includes(q));
657
+ composeList.innerHTML = '';
658
+ const createRow = document.createElement('div');
659
+ createRow.className = 'crow';
660
+ createRow.innerHTML = '<div class="tavatar" style="background:#3a3a3c;width:28px;height:28px;border-radius:8px;font-size:15px">+</div>' +
661
+ '<div>Create new Bot' + (q ? ': ' + escapeHtml(composeInp.value.trim()) : '') + '</div>' +
662
+ '<div class="kbd"><kbd>⌘</kbd><kbd>1</kbd></div>';
663
+ createRow.addEventListener('click', async () => {
664
+ const name = composeInp.value.trim() || prompt('Bot name?');
665
+ if (!name) return;
666
+ const t = await (await fetch('/threads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })).json();
667
+ activeId = t.id;
668
+ closeCompose();
669
+ await loadThreads(); await render();
670
+ });
671
+ composeList.appendChild(createRow);
672
+ candidates.slice(0, 8).forEach((t, i) => {
673
+ const row = document.createElement('div');
674
+ row.className = 'crow';
675
+ row.innerHTML = '<div class="tavatar" style="background:' + t.color + ';width:28px;height:28px;border-radius:8px;font-size:11px">' + initials(t.name) + '</div>' +
676
+ '<div>' + escapeHtml(t.name) + '</div><div class="kbd"><kbd>⌘</kbd><kbd>' + (i + 2) + '</kbd></div>';
677
+ row.addEventListener('click', () => addChip(t));
678
+ composeList.appendChild(row);
679
+ });
680
+ }
681
+
682
+ async function openOrCreateFromCompose() {
683
+ if (composeSel.length === 1) {
684
+ const t = knownThreads.find((x) => x.name === composeSel[0].name);
685
+ if (t) { activeId = t.id; closeCompose(); await loadThreads(); await render(); return; }
686
+ }
687
+ if (composeSel.length > 1) {
688
+ const t = await (await fetch('/threads/group', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names: composeSel.map((c) => c.name) }) })).json();
689
+ activeId = t.id;
690
+ closeCompose();
691
+ await loadThreads(); await render();
692
+ }
693
+ }
694
+
695
+ newMsgBtn.addEventListener('click', (e) => { e.stopPropagation(); openCompose(); });
696
+ composeClose.addEventListener('click', closeCompose);
697
+ composeOverlay.addEventListener('click', (e) => { if (e.target === composeOverlay) closeCompose(); });
698
+ composeInp.addEventListener('input', renderComposeList);
699
+ composeInp.addEventListener('keydown', (e) => {
700
+ if (e.key === 'Escape') closeCompose();
701
+ if (e.key === 'Enter') openOrCreateFromCompose();
702
+ if (e.key === 'Tab') {
703
+ e.preventDefault();
704
+ const q = composeInp.value.trim().toLowerCase();
705
+ const chosen = new Set(composeSel.map((c) => c.name));
706
+ const cand = knownThreads.find((t) => !chosen.has(t.name) && t.name.toLowerCase().includes(q));
707
+ if (cand) addChip(cand);
708
+ }
709
+ });
710
+
711
+ async function tick() { await loadThreads(); await render(); }
712
+ tick();
713
+ setInterval(tick, 1200);
714
+
715
+ // --- cost HUD (ported from the Hammerspoon menu-bar widget, same source) ---
716
+ const hudBtn = document.getElementById('hudBtn');
717
+ const hud = document.getElementById('hud');
718
+ function usd(n) {
719
+ if (n === null || n === undefined) return '—';
720
+ if (n === 0) return '$0';
721
+ if (n < 0.01) return '$' + n.toFixed(4);
722
+ return '$' + n.toFixed(2);
723
+ }
724
+ async function refreshHud() {
725
+ try {
726
+ // fetched server-side by US (see /hud-summary below) — a renderer fetch
727
+ // straight to x402-tokens.fly.dev fails as an opaque "Failed to fetch":
728
+ // no Access-Control-Allow-Origin on that response, so Chromium blocks
729
+ // reading it even though the request itself succeeds. Our own backend
730
+ // has no such restriction.
731
+ const j = await (await fetch('/hud-summary')).json();
732
+ const t = j.today || {};
733
+ const matched = Number(t.usdPaidWithCogs) || null;
734
+ const cogs = Number(t.usdCogs) || null;
735
+ const direct = Number(t.usdDirect) || null;
736
+ const margin = (cogs !== null && matched) ? Math.round((matched - cogs) / matched * 100) + '%' : '—';
737
+ const saved = (direct !== null && matched) ? (direct / matched).toFixed(1) + 'x' : '—';
738
+ document.getElementById('hPaid').textContent = usd(matched);
739
+ document.getElementById('hCogs').textContent = usd(cogs);
740
+ document.getElementById('hMargin').textContent = margin;
741
+ document.getElementById('hDirect').textContent = usd(direct);
742
+ document.getElementById('hSaved').textContent = saved;
743
+ document.getElementById('hFoot').textContent =
744
+ (t.calls || 0) + ' calls · ' + (t.paid || 0) + ' paid · ' + (t.distinctPayers || 0) + ' payers';
745
+ const you = j.you;
746
+ document.getElementById('hYouSpent').textContent = you ? usd(you.spentUsd) : '— (local proxy not reachable)';
747
+ document.getElementById('hYouCalls').textContent = you ? String(you.paidCalls) : '—';
748
+ } catch (e) {
749
+ document.getElementById('hFoot').textContent = 'error: ' + e.message;
750
+ }
751
+ }
752
+ let hudTimer = null;
753
+ hudBtn.addEventListener('click', (e) => {
754
+ e.stopPropagation();
755
+ hud.classList.toggle('show');
756
+ if (hud.classList.contains('show')) {
757
+ refreshHud();
758
+ hudTimer = setInterval(refreshHud, 30000);
759
+ } else if (hudTimer) {
760
+ clearInterval(hudTimer); hudTimer = null;
761
+ }
762
+ });
763
+ document.addEventListener('click', (e) => { if (!hud.contains(e.target)) hud.classList.remove('show'); });
764
+ </script>
765
+ </body></html>`;
766
+
767
+ const server = http.createServer((req, res) => {
768
+ if (req.method === 'GET' && req.url === '/hud-summary') {
769
+ (async () => {
770
+ let today = {};
771
+ try { today = (await (await fetch('https://x402-tokens.fly.dev/v1/usage/summary')).json()).today || {}; }
772
+ catch { /* gateway unreachable — HUD shows — for the global rows */ }
773
+ let you = null;
774
+ try { you = await (await fetch('http://127.0.0.1:8402/v1/session')).json(); }
775
+ catch { /* local proxy not running — HUD says so instead of guessing */ }
776
+ res.writeHead(200, { 'content-type': 'application/json' });
777
+ res.end(JSON.stringify({ today, you }));
778
+ })();
779
+ return;
780
+ }
781
+ if (req.method === 'GET' && req.url === '/threads') {
782
+ res.writeHead(200, { 'content-type': 'application/json' });
783
+ res.end(JSON.stringify([...threads.values()].sort((a, b) => b.createdAt - a.createdAt).map(threadSummary)));
784
+ return;
785
+ }
786
+ if (req.method === 'GET' && req.url.startsWith('/threads/')) {
787
+ const t = threads.get(req.url.split('/')[2]);
788
+ res.writeHead(200, { 'content-type': 'application/json' });
789
+ res.end(t ? JSON.stringify({ id: t.id, history: t.history, status: t.status }) : '{}');
790
+ return;
791
+ }
792
+ if (req.method === 'POST' && req.url === '/threads') {
793
+ const chunks = [];
794
+ req.on('data', (d) => chunks.push(d));
795
+ req.on('end', () => {
796
+ let name = 'New Bot';
797
+ try { name = (JSON.parse(Buffer.concat(chunks).toString('utf8')).name || name).toString().trim() || name; }
798
+ catch { /* ignore */ }
799
+ const t = newThread(name, null);
800
+ res.writeHead(200, { 'content-type': 'application/json' });
801
+ res.end(JSON.stringify(threadSummary(t)));
802
+ });
803
+ return;
804
+ }
805
+ if (req.method === 'POST' && req.url === '/threads/group') {
806
+ const chunks = [];
807
+ req.on('data', (d) => chunks.push(d));
808
+ req.on('end', () => {
809
+ let names = [];
810
+ try { names = JSON.parse(Buffer.concat(chunks).toString('utf8')).names || []; } catch { /* ignore */ }
811
+ names = names.filter(Boolean);
812
+ if (!names.length) { res.writeHead(400, { 'content-type': 'application/json' }); res.end('{}'); return; }
813
+ const t = newGroupThread(names);
814
+ res.writeHead(200, { 'content-type': 'application/json' });
815
+ res.end(JSON.stringify(threadSummary(t)));
816
+ });
817
+ return;
818
+ }
819
+ if (req.method === 'POST' && req.url === '/drive') {
820
+ const chunks = [];
821
+ req.on('data', (d) => chunks.push(d));
822
+ req.on('end', async () => {
823
+ let threadId = '', task = '';
824
+ try {
825
+ const j = JSON.parse(Buffer.concat(chunks).toString('utf8'));
826
+ threadId = j.threadId; task = (j.task || '').toString();
827
+ } catch { /* ignore */ }
828
+ res.writeHead(200, { 'content-type': 'application/json' });
829
+ res.end(JSON.stringify({ ok: true }));
830
+ // "/dir <path>" is a LOCAL control command, not sent to the model at
831
+ // all — free, instant, sets which folder this thread's WRITE/READ/SERVE
832
+ // are scoped to. Respecify any time by sending it again.
833
+ const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
834
+ const t = threads.get(threadId);
835
+ if (dirCmd && t) {
836
+ const full = path.resolve(expandHome(dirCmd[1].trim()));
837
+ let ok = false;
838
+ try { ok = statSync(full).isDirectory(); } catch { /* not a dir / doesn't exist */ }
839
+ if (ok) {
840
+ t.dir = full;
841
+ t.history.push({ who: 'bot', text: `Working directory set to ${full}` });
842
+ } else {
843
+ t.history.push({ who: 'bot', text: `"${full}" isn't a directory that exists.` });
844
+ }
845
+ saveThreads();
846
+ return;
847
+ }
848
+ runTurn(threadId, task).catch(() => {});
849
+ });
850
+ return;
851
+ }
852
+ res.writeHead(200, { 'content-type': 'text/html' });
853
+ res.end(APP_HTML);
854
+ });
855
+
856
+ server.listen(PORT, '127.0.0.1', () => console.log(`[grokui] http://localhost:${PORT}`));
package/lib/podagent.mjs CHANGED
@@ -27,9 +27,130 @@ const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
27
27
  .split(',').map((s) => Number(s.trim())).filter(Boolean);
28
28
  const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
29
29
  const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
30
- const MODEL = process.env.OZ_BRAIN_MODEL || 'x-ai/grok-4.6';
30
+ const MODEL = process.env.OZ_BRAIN_MODEL || 'deepseek/deepseek-v4-pro-0813';
31
31
  const MAX_STEPS = Number(process.env.OZ_MAX_STEPS || 10);
32
32
 
33
+ // Matches the Grok Bot chat surface itself (dark canvas, right-aligned grey
34
+ // user pills, pink-avatar left-aligned replies, pill input bar with + / mic) —
35
+ // this renders INSIDE Grok Bot's own sandbox panel (its sidebar/titlebar are
36
+ // the app's own chrome, not ours), so only the message canvas needs to match.
37
+ export const VNC_CHAT_HTML = `<!doctype html>
38
+ <html><head><meta charset="utf-8"><title>openzoo box</title>
39
+ <meta name="viewport" content="width=device-width,initial-scale=1">
40
+ <style>
41
+ :root { color-scheme: dark; }
42
+ * { box-sizing: border-box; }
43
+ html, body { margin: 0; height: 100%; background: #000; }
44
+ body { color: #ececec; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
45
+ display: flex; flex-direction: column; }
46
+ #log { flex: 1; overflow-y: auto; padding: 28px 24px 12px; display: flex; flex-direction: column; gap: 6px; }
47
+ .hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
48
+ color: #8e8e93; font-size: 13px; }
49
+ .hdr .avatar { width: 18px; height: 18px; border-radius: 5px; }
50
+ .hdr .avatar svg { width: 10px; height: 10px; }
51
+ .row { display: flex; max-width: 78%; margin: 2px 0; }
52
+ .row.user { align-self: flex-end; }
53
+ .row.bot { align-self: flex-start; }
54
+ .avatar { width: 30px; height: 30px; border-radius: 8px; flex: 0 0 30px; background: #e91e8c;
55
+ display: flex; align-items: center; justify-content: center; }
56
+ .avatar svg { width: 16px; height: 16px; }
57
+ .bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word; }
58
+ .row.user .bubble { background: #57575c; }
59
+ .row.bot .bubble { background: #262626; color: #ececec; }
60
+ .row.bot.pending .bubble { color: #8e8e93; }
61
+ .dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
62
+ background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
63
+ .dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
64
+ @keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
65
+ #bar { padding: 10px 16px 18px; }
66
+ #row-input { display: flex; align-items: center; gap: 8px; }
67
+ #pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
68
+ padding: 8px 10px 8px 14px; }
69
+ .icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
70
+ color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
71
+ flex: 0 0 32px; }
72
+ .icon-btn:hover { background: #3a3a3c; }
73
+ .icon-btn svg { width: 18px; height: 18px; }
74
+ #inp { flex: 1; background: transparent; border: none; color: #ececec; font: inherit;
75
+ padding: 6px 0; min-width: 0; }
76
+ #inp::placeholder { color: #8e8e93; }
77
+ #inp:focus { outline: none; }
78
+ #send { width: 34px; height: 34px; border-radius: 50%; border: none; background: #fff; color: #000;
79
+ display: none; align-items: center; justify-content: center; cursor: pointer; flex: 0 0 34px; }
80
+ #send.show { display: flex; }
81
+ #send svg { width: 16px; height: 16px; }
82
+ </style></head>
83
+ <body>
84
+ <div id="log"></div>
85
+ <div id="bar">
86
+ <div id="row-input">
87
+ <div id="pill">
88
+ <button class="icon-btn" tabindex="-1">
89
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
90
+ </button>
91
+ <input id="inp" placeholder="Message openzoo" autofocus>
92
+ <button class="icon-btn" tabindex="-1">
93
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v1a7 7 0 0 1-14 0v-1"/><line x1="12" y1="18" x2="12" y2="22"/></svg>
94
+ </button>
95
+ </div>
96
+ <button id="send">
97
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M6 11l6-6 6 6"/></svg>
98
+ </button>
99
+ </div>
100
+ </div>
101
+ <script>
102
+ const log = document.getElementById('log');
103
+ const inp = document.getElementById('inp');
104
+ const send = document.getElementById('send');
105
+ const AVATAR = '<svg viewBox="0 0 24 24" fill="none"><path d="M6 10c0-1 1-2 2-1l2 2 2-2c1-1 2 0 2 1v2c0 2-2 3-4 3s-4-1-4-3v-2z" fill="#fff"/></svg>';
106
+ let lastWho = null;
107
+ function addRow(who, text) {
108
+ if (who === 'bot' && lastWho !== 'bot') {
109
+ const hdr = document.createElement('div');
110
+ hdr.className = 'hdr';
111
+ hdr.innerHTML = '<span class="avatar">' + AVATAR + '</span><span>openzoo</span>';
112
+ log.appendChild(hdr);
113
+ }
114
+ lastWho = who;
115
+ const row = document.createElement('div');
116
+ row.className = 'row ' + who + (text === null ? ' pending' : '');
117
+ const bubble = document.createElement('div');
118
+ bubble.className = 'bubble';
119
+ bubble.innerHTML = text === null ? '<span class="dots"><span></span><span></span><span></span></span>' : '';
120
+ if (text !== null) bubble.textContent = text;
121
+ row.appendChild(bubble);
122
+ log.appendChild(row);
123
+ log.scrollTop = log.scrollHeight;
124
+ return { row, bubble };
125
+ }
126
+ async function submit() {
127
+ const task = inp.value.trim();
128
+ if (!task) return;
129
+ inp.value = '';
130
+ send.classList.remove('show');
131
+ inp.disabled = true;
132
+ addRow('user', task);
133
+ const pending = addRow('bot', null);
134
+ try {
135
+ const r = await fetch('/drive', {
136
+ method: 'POST', headers: { 'content-type': 'application/json' },
137
+ body: JSON.stringify({ task }),
138
+ });
139
+ const j = await r.json();
140
+ pending.row.classList.remove('pending');
141
+ pending.bubble.textContent = j.text || '(no response)';
142
+ } catch (e) {
143
+ pending.row.classList.remove('pending');
144
+ pending.bubble.textContent = 'error: ' + e.message;
145
+ }
146
+ inp.disabled = false; inp.focus();
147
+ }
148
+ inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0); });
149
+ send.addEventListener('click', submit);
150
+ inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
151
+ </script>
152
+ </body></html>`;
153
+
33
154
  function record(entry) {
34
155
  try { appendFileSync(LOG, JSON.stringify(entry) + '\n'); } catch { /* best effort */ }
35
156
  console.log(`[agent:${entry.port ?? '-'}] ${entry.method || entry.ev} ${entry.path || ''} ${entry.bodyBytes ?? ''}${entry.frames ? ' frames=' + entry.frames : ''}`);
@@ -60,11 +181,15 @@ function execFrame(command, cwd = '/tmp') {
60
181
 
61
182
  /** One openzoo chat turn. Paid per call by the box's own wallet via the local
62
183
  * proxy — no key, no account. */
63
- async function brain(messages) {
184
+ export async function brain(messages) {
64
185
  const r = await fetch(`${PROXY}/chat/completions`, {
65
186
  method: 'POST',
66
187
  headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
67
- body: JSON.stringify({ model: MODEL, max_tokens: 900, messages }),
188
+ // explicit, not relying on the gateway's "inject when caller said nothing"
189
+ // default — an explicit plugins array is always respected as-is, so this
190
+ // guarantees every bot on every model actually has web search, instead of
191
+ // hoping nothing upstream (local proxy, gateway config) already set one.
192
+ body: JSON.stringify({ model: MODEL, max_tokens: 900, messages, plugins: [{ id: 'web' }] }),
68
193
  });
69
194
  const j = await r.json().catch(() => ({}));
70
195
  return j?.choices?.[0]?.message?.content ?? '';
@@ -83,9 +208,29 @@ be careful, never destructive, prefer read-before-write, and explain nothing to
83
208
  PROTOCOL — reply with EXACTLY one line, no prose, no code fences:
84
209
  RUN: <a single shell command> to execute a step
85
210
  DONE: <a short natural-language answer> when the task is complete (this text is shown in the UI)
86
- You are given each command's output before your next line. If the task needs no shell (a question,
87
- an explanation), answer it directly with a single DONE: line. Keep DONE summaries human and useful —
88
- they are the assistant's reply to the user, not a log.`;
211
+ SPAWN: <name> | <task> to delegate an independent subtask to a fresh subagent
212
+ SEND: <name> | <message> to message a subagent you (or the user) already spawned
213
+ PING: <name> to check whether a subagent is still working or has finished
214
+ You are given each command's output before your next line. SPAWN does not block you — the subagent
215
+ runs independently and you continue your own reasoning immediately; check on it later with PING or
216
+ wait for it to SEND you its result. All subagents share the ONE real exec channel (the user's Mac),
217
+ so their shell commands queue safely behind each other automatically — you never need to worry about
218
+ that part. If the task needs no shell (a question, an explanation), answer it directly with a single
219
+ DONE: line. Keep DONE summaries human and useful — they are the assistant's reply to the user, not a
220
+ log.`;
221
+
222
+ // ---------------------------------------------------------------- subagents --
223
+
224
+ // name -> { mailbox: string[], done: boolean, result: string|null }. In-process
225
+ // registry — every agent (root task + SPAWNed subagents) shares the ONE real
226
+ // exec channel (queueDrive), but reason (call the brain) independently and
227
+ // concurrently, so SPAWN genuinely doesn't block the spawning agent.
228
+ const agents = new Map();
229
+
230
+ function ensureAgent(name) {
231
+ if (!agents.has(name)) agents.set(name, { mailbox: [], done: false, result: null });
232
+ return agents.get(name);
233
+ }
89
234
 
90
235
  /** The agent loop for one task, executed through the connected daemon. Each
91
236
  * RUN is pushed as an exec frame; the daemon's result frames (captured in
@@ -205,9 +350,16 @@ for (const port of PORTS) {
205
350
  }
206
351
  }
207
352
 
353
+ // THE REAL TRIGGER. Grok Bot's own chat (StreamUnifiedChat) never reaches
354
+ // us — inference happens server-side inside whatever pod EnsureSandBox
355
+ // named, and since we hijacked that to a pod that doesn't exist on
356
+ // Cursor's side, the UI's chat box just sits silent forever. But the app
357
+ // ALSO opens this vnc.html URL — OUR box — inside its own sandbox panel.
358
+ // Serve a real chat page here instead of a stub: the user types inside
359
+ // Grok Bot's own window, it POSTs straight to /drive on this box.
208
360
  if (req.url && req.url.includes('/vnc')) {
209
361
  res.writeHead(200, { 'content-type': 'text/html' });
210
- res.end('<!doctype html><title>openzoo box</title>');
362
+ res.end(VNC_CHAT_HTML);
211
363
  } else {
212
364
  res.writeHead(200, { 'content-type': 'application/json' });
213
365
  res.end('{"ok":true}');
package/lib/proxy.js CHANGED
@@ -342,6 +342,16 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
342
342
  // both surfaces: point base_url at /v1 for transparent context spilling,
343
343
  // or add /mcp for tools (zoo_bind, zoo_ask...). Running two commands to
344
344
  // get both was friction nobody should pay.
345
+ // This wallet's own running total for THIS proxy process — every paid
346
+ // call through this port counts (GUI, MCP, CLI, any harness), not just
347
+ // whichever surface happens to be asking. Local-only, no auth needed:
348
+ // it's a number, not a capability.
349
+ if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
350
+ res.writeHead(200, { 'content-type': 'application/json' });
351
+ res.end(JSON.stringify({ spentUsd: sessionSpent, paidCalls }));
352
+ return;
353
+ }
354
+
345
355
  if ((req.url || '').split('?')[0] === '/mcp') {
346
356
  try {
347
357
  const { handleMcpRequest } = await import('./mcphttp.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.42.0",
3
+ "version": "0.43.1",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — 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",