openzoo 0.43.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,14 +27,14 @@ 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
33
  // Matches the Grok Bot chat surface itself (dark canvas, right-aligned grey
34
34
  // user pills, pink-avatar left-aligned replies, pill input bar with + / mic) —
35
35
  // this renders INSIDE Grok Bot's own sandbox panel (its sidebar/titlebar are
36
36
  // the app's own chrome, not ours), so only the message canvas needs to match.
37
- const VNC_CHAT_HTML = `<!doctype html>
37
+ export const VNC_CHAT_HTML = `<!doctype html>
38
38
  <html><head><meta charset="utf-8"><title>openzoo box</title>
39
39
  <meta name="viewport" content="width=device-width,initial-scale=1">
40
40
  <style>
@@ -43,23 +43,28 @@ const VNC_CHAT_HTML = `<!doctype html>
43
43
  html, body { margin: 0; height: 100%; background: #000; }
44
44
  body { color: #ececec; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
45
45
  display: flex; flex-direction: column; }
46
- #log { flex: 1; overflow-y: auto; padding: 28px 24px 12px; display: flex; flex-direction: column; gap: 18px; }
47
- .row { display: flex; align-items: flex-start; gap: 10px; max-width: 78%; }
48
- .row.user { align-self: flex-end; flex-direction: row-reverse; }
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; }
49
53
  .row.bot { align-self: flex-start; }
50
54
  .avatar { width: 30px; height: 30px; border-radius: 8px; flex: 0 0 30px; background: #e91e8c;
51
55
  display: flex; align-items: center; justify-content: center; }
52
56
  .avatar svg { width: 16px; height: 16px; }
53
- .bubble { padding: 11px 15px; border-radius: 18px; white-space: pre-wrap; word-break: break-word; }
54
- .row.user .bubble { background: #3a3a3c; border-bottom-right-radius: 5px; }
55
- .row.bot .bubble { background: transparent; padding: 6px 0; color: #ececec; }
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; }
56
60
  .row.bot.pending .bubble { color: #8e8e93; }
57
61
  .dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
58
62
  background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
59
63
  .dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
60
64
  @keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
61
65
  #bar { padding: 10px 16px 18px; }
62
- #pill { display: flex; align-items: center; gap: 10px; background: #2c2c2e; border-radius: 26px;
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;
63
68
  padding: 8px 10px 8px 14px; }
64
69
  .icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
65
70
  color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
@@ -70,17 +75,26 @@ const VNC_CHAT_HTML = `<!doctype html>
70
75
  padding: 6px 0; min-width: 0; }
71
76
  #inp::placeholder { color: #8e8e93; }
72
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; }
73
82
  </style></head>
74
83
  <body>
75
84
  <div id="log"></div>
76
85
  <div id="bar">
77
- <div id="pill">
78
- <button class="icon-btn" tabindex="-1">
79
- <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>
80
- </button>
81
- <input id="inp" placeholder="Message pods" autofocus>
82
- <button class="icon-btn" id="send">
83
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="M6 11l6-6 6 6"/></svg>
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>
84
98
  </button>
85
99
  </div>
86
100
  </div>
@@ -89,15 +103,17 @@ const VNC_CHAT_HTML = `<!doctype html>
89
103
  const inp = document.getElementById('inp');
90
104
  const send = document.getElementById('send');
91
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;
92
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;
93
115
  const row = document.createElement('div');
94
116
  row.className = 'row ' + who + (text === null ? ' pending' : '');
95
- if (who === 'bot') {
96
- const av = document.createElement('div');
97
- av.className = 'avatar';
98
- av.innerHTML = AVATAR;
99
- row.appendChild(av);
100
- }
101
117
  const bubble = document.createElement('div');
102
118
  bubble.className = 'bubble';
103
119
  bubble.innerHTML = text === null ? '<span class="dots"><span></span><span></span><span></span></span>' : '';
@@ -111,7 +127,8 @@ const VNC_CHAT_HTML = `<!doctype html>
111
127
  const task = inp.value.trim();
112
128
  if (!task) return;
113
129
  inp.value = '';
114
- inp.disabled = true; send.disabled = true;
130
+ send.classList.remove('show');
131
+ inp.disabled = true;
115
132
  addRow('user', task);
116
133
  const pending = addRow('bot', null);
117
134
  try {
@@ -126,8 +143,9 @@ const VNC_CHAT_HTML = `<!doctype html>
126
143
  pending.row.classList.remove('pending');
127
144
  pending.bubble.textContent = 'error: ' + e.message;
128
145
  }
129
- inp.disabled = false; send.disabled = false; inp.focus();
146
+ inp.disabled = false; inp.focus();
130
147
  }
148
+ inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0); });
131
149
  send.addEventListener('click', submit);
132
150
  inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
133
151
  </script>
@@ -163,11 +181,15 @@ function execFrame(command, cwd = '/tmp') {
163
181
 
164
182
  /** One openzoo chat turn. Paid per call by the box's own wallet via the local
165
183
  * proxy — no key, no account. */
166
- async function brain(messages) {
184
+ export async function brain(messages) {
167
185
  const r = await fetch(`${PROXY}/chat/completions`, {
168
186
  method: 'POST',
169
187
  headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
170
- 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' }] }),
171
193
  });
172
194
  const j = await r.json().catch(() => ({}));
173
195
  return j?.choices?.[0]?.message?.content ?? '';
@@ -186,9 +208,29 @@ be careful, never destructive, prefer read-before-write, and explain nothing to
186
208
  PROTOCOL — reply with EXACTLY one line, no prose, no code fences:
187
209
  RUN: <a single shell command> to execute a step
188
210
  DONE: <a short natural-language answer> when the task is complete (this text is shown in the UI)
189
- You are given each command's output before your next line. If the task needs no shell (a question,
190
- an explanation), answer it directly with a single DONE: line. Keep DONE summaries human and useful —
191
- 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
+ }
192
234
 
193
235
  /** The agent loop for one task, executed through the connected daemon. Each
194
236
  * RUN is pushed as an exec frame; the daemon's result frames (captured in
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.43.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",