openzoo 0.43.0 → 0.43.2

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,1124 @@
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 { exec } from 'node:child_process';
9
+ import http from 'node:http';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import path from 'node:path';
14
+ import { brain, brainStream, PROXY } from './podagent.mjs';
15
+
16
+ const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
17
+ const STORE_DIR = path.join(homedir(), '.openzoo');
18
+ const STORE_FILE = path.join(STORE_DIR, 'grokui-threads.json');
19
+
20
+ // Real but SANDBOXED filesystem access for the bots — each THREAD has its own
21
+ // root dir (default: a dedicated workspace, never the user's whole disk), and
22
+ // the user can point a thread at a real project folder with "/dir <path>" in
23
+ // chat. safeResolveIn rejects any path that would escape that thread's root
24
+ // (../, absolute paths, symlink tricks via normalize) — access is real, but
25
+ // always contained to whatever root was explicitly chosen for that thread.
26
+ const WORKSPACE_DIR = path.join(homedir(), '.openzoo', 'grokui-workspace');
27
+ mkdirSync(WORKSPACE_DIR, { recursive: true });
28
+ function expandHome(p) { return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p; }
29
+ function dirFor(threadId) { return threads.get(threadId)?.dir || WORKSPACE_DIR; }
30
+ function safeResolveIn(base, rel) {
31
+ const full = path.normalize(path.join(base, rel));
32
+ if (full !== base && !full.startsWith(base + path.sep)) {
33
+ throw new Error("path escapes this thread's directory");
34
+ }
35
+ return full;
36
+ }
37
+ const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
38
+ mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
39
+ jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
40
+ let workspacePort = null;
41
+ // route: /<threadId>/<relpath...> — each thread is served from ITS OWN dir
42
+ const workspaceServer = http.createServer((req, res) => {
43
+ try {
44
+ const urlPath = decodeURIComponent((req.url || '/').split('?')[0]).replace(/^\/+/, '');
45
+ const slash = urlPath.indexOf('/');
46
+ const threadId = slash === -1 ? urlPath : urlPath.slice(0, slash);
47
+ let rel = slash === -1 ? '' : urlPath.slice(slash + 1);
48
+ if (!rel) rel = 'index.html';
49
+ const full = safeResolveIn(dirFor(threadId), rel);
50
+ const data = readFileSync(full);
51
+ const ext = full.split('.').pop();
52
+ res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' });
53
+ res.end(data);
54
+ } catch {
55
+ res.writeHead(404, { 'content-type': 'text/plain' });
56
+ res.end('not found');
57
+ }
58
+ });
59
+ workspaceServer.listen(0, '127.0.0.1', () => { workspacePort = workspaceServer.address().port; });
60
+
61
+ const PALETTE = ['#e91e8c', '#34c759', '#ff9500', '#5e5ce6', '#ff3b30', '#0a84ff', '#00c7be'];
62
+ function colorFor(name) {
63
+ let h = 0;
64
+ for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
65
+ return PALETTE[h % PALETTE.length];
66
+ }
67
+
68
+ const SYSTEM = `You are a helpful assistant served over openzoo (pay-per-call access to ~435
69
+ models, no API key, no account). Reply normally in plain text, concisely.
70
+
71
+ When a question has a genuine "which tool/service should I use" angle, prefer openzoo's own
72
+ capabilities where they actually fit (this proxy for inference, leCore/zoo_bind for memory,
73
+ the directives below for files/shell/delegation) over generic third-party suggestions. Don't
74
+ force it where it doesn't apply — a real "just use X" answer beats a forced openzoo plug.
75
+
76
+ If — and only if — the request genuinely calls for independent parallel work (the user
77
+ asks you to spawn/delegate/create agents, or a task splits cleanly into independent
78
+ subtasks), you may instead reply with EXACTLY one line, no prose, using one of:
79
+ SPAWN: <short name> | <task for the new agent> create a new thread with its own
80
+ independent agent and give it a task
81
+ SEND: <name> | <message> message an agent thread that already
82
+ exists (yours or one you spawned)
83
+ PING: <name> one-line status: still working, or
84
+ its last result
85
+ PEEK: <name> a fuller look — its last few messages,
86
+ not just the latest one
87
+ You are given the result before your next line, so none of these block you — check back
88
+ later if it's still working.
89
+
90
+ You ALSO have real (sandboxed) filesystem access, scoped to THIS thread's own directory —
91
+ the user sets or changes it by sending "/dir <path>" in chat; until they do, it's a private
92
+ workspace folder, not their real project. Same one-line-no-prose reply format:
93
+ WRITE: <relative path> | <content> create or overwrite a file
94
+ READ: <relative path> read a file back
95
+ SERVE: <relative path, or blank for the dir root> get a real http:// URL for a file —
96
+ use this instead of claiming you
97
+ "can't expose a port": you can serve
98
+ static files, just not run a process
99
+ FETCH: <url> actually fetch and read a page's real
100
+ text — web search only gives you short
101
+ snippets; use FETCH when asked to
102
+ "read" or quote something specific
103
+ RUN: <shell command> run a REAL shell command in this
104
+ thread's directory — by default this
105
+ pauses and waits for the user to
106
+ approve or deny it before anything
107
+ executes ("/mode auto" in chat skips
108
+ that wait). Use this for anything a
109
+ file write/read/serve can't do —
110
+ installing packages, running a build,
111
+ starting a real process, checking
112
+ actual CLI/login state, etc. — instead
113
+ of guessing or saying you can't.
114
+ For normal questions just answer directly — do not use any of these unless the request
115
+ actually calls for delegation or file work.`;
116
+
117
+ // id -> { id, name, color, parent, messages: [{role,content}], history: [{who,text}], status }
118
+ const threads = new Map();
119
+
120
+ // Threads are the whole point of the app — losing them on every restart (the
121
+ // server got restarted a lot while iterating this session) is a real bug, not
122
+ // a nice-to-have. Plain JSON on disk; the volume of chat here never justifies
123
+ // a database.
124
+ function saveThreads() {
125
+ try {
126
+ mkdirSync(STORE_DIR, { recursive: true });
127
+ writeFileSync(STORE_FILE, JSON.stringify([...threads.values()]));
128
+ } catch { /* best effort */ }
129
+ }
130
+
131
+ function loadThreads() {
132
+ try {
133
+ if (!existsSync(STORE_FILE)) return false;
134
+ const arr = JSON.parse(readFileSync(STORE_FILE, 'utf8'));
135
+ if (!Array.isArray(arr) || !arr.length) return false;
136
+ for (const t of arr) threads.set(t.id, t);
137
+ return true;
138
+ } catch { return false; }
139
+ }
140
+
141
+ function newThread(name, parent, members) {
142
+ const id = randomUUID();
143
+ const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
144
+ messages: members ? null : [{ role: 'system', content: SYSTEM }],
145
+ members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now() };
146
+ threads.set(id, t);
147
+ saveThreads();
148
+ return t;
149
+ }
150
+
151
+ function makeMember(name) {
152
+ return { name, color: colorFor(name), systemPrompt:
153
+ `You are ${name}, one of several bots in a shared group chat served over openzoo
154
+ (pay-per-call access to ~435 models, no API key, no account). Reply normally and concisely
155
+ as yourself. You can see the WHOLE shared conversation, including what the other bots in
156
+ this group already said — their messages are prefixed "[Name]:" so you can tell them apart
157
+ from the human. A message addressed "@everyone" is meant for the whole group — give your
158
+ own take even if brief ("Passed." is fine when you have nothing to add). COORDINATE: if
159
+ another bot already handled or is handling the request (e.g. already spawned the exact
160
+ agent being asked for), do NOT repeat it — just acknowledge, or add something genuinely new.
161
+
162
+ When a question has a genuine "which tool/service" angle, prefer openzoo's own capabilities
163
+ where they actually fit over generic third-party suggestions — but don't force it.
164
+
165
+ You can ALSO delegate, same as any other agent here. If — and only if — asked to
166
+ spawn/delegate/create agents AND no other bot has already done it this round, reply with
167
+ EXACTLY one line, no prose, using one of:
168
+ SPAWN: <short name> | <task for the new agent> create a new thread with its own agent
169
+ SEND: <name> | <message> message an existing agent thread
170
+ PING: <name> one-line status, or its last result
171
+ PEEK: <name> a fuller look at its last few messages
172
+
173
+ You ALSO have real (sandboxed) filesystem access, scoped to THIS group's own directory —
174
+ the user sets or changes it with "/dir <path>" in chat. Same format:
175
+ WRITE: <relative path> | <content> create or overwrite a file
176
+ READ: <relative path> read a file back
177
+ SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
178
+ this instead of saying you can't
179
+ expose a port
180
+ FETCH: <url> actually fetch and read a page's real
181
+ text — web search only gives snippets
182
+ RUN: <shell command> run a REAL shell command in this
183
+ group's shared directory — pauses the
184
+ WHOLE round for the user's approval
185
+ before anything executes ("/mode auto"
186
+ in chat skips that wait). Use this
187
+ instead of guessing or saying you
188
+ can't do something real.
189
+ For normal replies just answer directly — do not use any of these unless the request
190
+ actually calls for delegation or file work.` };
191
+ }
192
+
193
+ // Rebuilds a member's context fresh from the shared thread history every turn
194
+ // (instead of a private per-member log) so each bot sees what the others in
195
+ // the group already said — including earlier replies from THIS round, since
196
+ // runTurn pushes to t.history sequentially, one member at a time.
197
+ function buildMemberMessages(t, member) {
198
+ const msgs = [{ role: 'system', content: member.systemPrompt || SYSTEM }];
199
+ for (const h of t.history) {
200
+ if (h.who === 'user') msgs.push({ role: 'user', content: h.text });
201
+ else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
202
+ else msgs.push({ role: 'user', content: `[${h.name}]: ${h.text}` });
203
+ }
204
+ return msgs;
205
+ }
206
+
207
+ function newGroupThread(names) {
208
+ const members = names.map(makeMember);
209
+ return newThread(names.join(', '), null, members);
210
+ }
211
+
212
+ // Real leCore binding — POST /v1/hrr/bind on the local proxy, same free
213
+ // passthrough the wiki documents. Fire-and-forget after each turn: the next
214
+ // turn's brain()/brainStream() call picks up t.contextId once it lands, via
215
+ // the X-HRR-Context header, so retrieval is real and automatic, not a prompt
216
+ // claim about a mechanism that doesn't exist.
217
+ async function bindThread(t) {
218
+ const corpus = t.history.map((h) => (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
219
+ if (!corpus.trim()) return;
220
+ try {
221
+ const r = await fetch(`${PROXY}/hrr/bind`, {
222
+ method: 'POST',
223
+ headers: { 'content-type': 'application/json' },
224
+ body: JSON.stringify({ corpus }),
225
+ });
226
+ const j = await r.json().catch(() => ({}));
227
+ if (j?.context_id) { t.contextId = j.context_id; saveThreads(); }
228
+ } catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
229
+ }
230
+
231
+ // REAL shell execution, scoped to the thread's own directory. 'ask' mode
232
+ // (default) pauses and waits for an explicit approve/deny over HTTP before
233
+ // anything runs; 'auto' mode (set via "/mode auto" in chat) runs immediately.
234
+ // Either way this is not sandboxed like WRITE/READ — it can do anything the
235
+ // signed-in user's shell can — so 'ask' is the default, not 'auto'.
236
+ function execCommand(command, cwd) {
237
+ return new Promise((resolve) => {
238
+ exec(command, { cwd, timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
239
+ let out = (stdout || '') + (stderr ? '\n' + stderr : '');
240
+ if (err) out += `\n(exit ${err.code ?? 1})`;
241
+ resolve(out.slice(0, 6000) || '(no output)');
242
+ });
243
+ });
244
+ }
245
+
246
+ function findByName(name) {
247
+ let best = null;
248
+ for (const t of threads.values()) {
249
+ if (t.name.toLowerCase() === name.toLowerCase() && (!best || t.createdAt > best.createdAt)) best = t;
250
+ }
251
+ return best;
252
+ }
253
+
254
+ if (!loadThreads()) newThread('openzoo', null);
255
+
256
+ // Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
257
+ // (creating or messaging another thread), and returns the ack text to show in
258
+ // place of the raw directive line — or null if the reply wasn't a directive.
259
+ async function tryDirective(reply, originId) {
260
+ const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
261
+ if (spawn) {
262
+ const name = spawn[1].trim();
263
+ const task = spawn[2].trim();
264
+ const sub = newThread(name, originId);
265
+ runTurn(sub.id, task).catch(() => {}); // fire and forget — runs independently
266
+ return `Spawned ${name} — working on it.`;
267
+ }
268
+ const sendM = /^SEND:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
269
+ if (sendM) {
270
+ const name = sendM[1].trim();
271
+ const msg = sendM[2].trim();
272
+ const target = findByName(name);
273
+ if (target) runTurn(target.id, msg).catch(() => {});
274
+ return target ? `Messaged ${name}.` : `No thread named "${name}" to message.`;
275
+ }
276
+ const ping = /^PING:\s*(.+)/.exec(reply);
277
+ if (ping) {
278
+ const name = ping[1].trim();
279
+ const target = findByName(name);
280
+ const last = target?.history[target.history.length - 1];
281
+ return !target ? `No thread named "${name}".`
282
+ : target.status === 'thinking' ? `${name} is still working.`
283
+ : last ? `${name}: ${last.text}` : `${name} hasn't replied yet.`;
284
+ }
285
+ const peek = /^PEEK:\s*(.+)/.exec(reply);
286
+ if (peek) {
287
+ const name = peek[1].trim();
288
+ const target = findByName(name);
289
+ if (!target) return `No thread named "${name}".`;
290
+ const recent = target.history.slice(-4)
291
+ .map((h) => (h.who === 'user' ? 'you' : (h.name || target.name)) + ': ' + h.text).join('\n');
292
+ return `${name} (${target.status}):\n${recent || '(nothing yet)'}`;
293
+ }
294
+ const write = /^WRITE:\s*([^|]+)\|([\s\S]+)/.exec(reply);
295
+ if (write) {
296
+ const rel = write[1].trim();
297
+ const content = write[2].replace(/^\n/, '');
298
+ try {
299
+ const full = safeResolveIn(dirFor(originId), rel);
300
+ mkdirSync(path.dirname(full), { recursive: true });
301
+ writeFileSync(full, content);
302
+ return `Wrote ${rel} (${Buffer.byteLength(content)} bytes) to ${dirFor(originId)}.`;
303
+ } catch (e) { return `Couldn't write ${rel}: ${e.message}`; }
304
+ }
305
+ const readD = /^READ:\s*(.+)/.exec(reply);
306
+ if (readD) {
307
+ const rel = readD[1].trim();
308
+ try {
309
+ const data = readFileSync(safeResolveIn(dirFor(originId), rel), 'utf8');
310
+ return `${rel}:\n${data.slice(0, 4000)}${data.length > 4000 ? '\n…(truncated)' : ''}`;
311
+ } catch (e) { return `Couldn't read ${rel}: ${e.message}`; }
312
+ }
313
+ const serve = /^SERVE:\s*(.*)$/.exec(reply);
314
+ if (serve) {
315
+ const rel = serve[1].trim();
316
+ if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
317
+ return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
318
+ }
319
+ const fetchD = /^FETCH:\s*(\S+)/.exec(reply);
320
+ if (fetchD) {
321
+ const url = fetchD[1].trim();
322
+ try {
323
+ const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (openzoo grokui)' } });
324
+ const ct = r.headers.get('content-type') || '';
325
+ let text = await r.text();
326
+ if (ct.includes('html')) {
327
+ text = text
328
+ .replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '')
329
+ .replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&')
330
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
331
+ }
332
+ return `${url} (${r.status}):\n${text.slice(0, 8000)}${text.length > 8000 ? '\n…(truncated)' : ''}`;
333
+ } catch (e) { return `Couldn't fetch ${url}: ${e.message}`; }
334
+ }
335
+ return null;
336
+ }
337
+
338
+ // onEvent (optional) gets live progress for whoever's actually watching this
339
+ // call: {type:'start',name,color} when a bot begins its turn, {type:'delta',
340
+ // name,color,delta} per streamed token, {type:'final',name,color,text} once
341
+ // its full reply (or directive ack) is settled. Background turns — a SPAWNed
342
+ // subagent nobody's looking at yet — run with onEvent omitted and just use
343
+ // the plain non-streaming brain(), which is cheaper when nothing renders it.
344
+ async function runTurn(threadId, userText, onEvent) {
345
+ const t = threads.get(threadId);
346
+ if (!t) return;
347
+ t.history.push({ who: 'user', text: userText });
348
+ t.lastActivityAt = Date.now();
349
+ if (t.members) {
350
+ t.status = 'thinking';
351
+ // sequential, not parallel: each member's context is rebuilt from
352
+ // t.history right before its turn, so it sees every reply (including
353
+ // spawns/sends) the earlier members in THIS round already made
354
+ for (const m of t.members) {
355
+ const msgs = buildMemberMessages(t, m);
356
+ let r = '';
357
+ onEvent?.({ type: 'start', name: m.name, color: m.color });
358
+ try {
359
+ r = onEvent
360
+ ? (await brainStream(msgs, (delta) => onEvent({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId)).trim()
361
+ : (await brain(msgs, t.contextId)).trim();
362
+ } catch (e) { r = `error: ${e.message}`; }
363
+ const runMatch = /^RUN:\s*([\s\S]+)/.exec(r);
364
+ if (runMatch) {
365
+ const command = runMatch[1].trim();
366
+ if (t.runMode === 'auto') {
367
+ const output = await execCommand(command, dirFor(t.id));
368
+ const shown = `$ ${command}\n${output}`;
369
+ t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
370
+ onEvent?.({ type: 'final', name: m.name, color: m.color, text: shown });
371
+ // this member's turn is done; the round continues to the next member
372
+ continue;
373
+ }
374
+ const runId = randomUUID();
375
+ t.pendingRun = { runId, command, cwd: dirFor(t.id) };
376
+ t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
377
+ onEvent?.({ type: 'run-pending', runId, command, name: m.name, color: m.color });
378
+ // pauses the WHOLE round here — the rest of the group gets their turn
379
+ // on the round that runs after the user approves/denies
380
+ t.status = 'idle';
381
+ t.lastActivityAt = Date.now();
382
+ saveThreads();
383
+ return;
384
+ }
385
+ const ack = await tryDirective(r, t.id);
386
+ const finalText = ack ?? (r || '(no response)');
387
+ t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
388
+ onEvent?.({ type: 'final', name: m.name, color: m.color, text: finalText });
389
+ }
390
+ t.status = 'idle';
391
+ saveThreads();
392
+ bindThread(t).catch(() => {});
393
+ return;
394
+ }
395
+ t.messages.push({ role: 'user', content: userText });
396
+ t.status = 'thinking';
397
+ let reply = '';
398
+ onEvent?.({ type: 'start', name: t.name, color: t.color });
399
+ try {
400
+ reply = onEvent
401
+ ? (await brainStream(t.messages, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId)).trim()
402
+ : (await brain(t.messages, t.contextId)).trim();
403
+ } catch (e) {
404
+ reply = `error: ${e.message}`;
405
+ }
406
+ t.messages.push({ role: 'assistant', content: reply });
407
+ const runMatch = /^RUN:\s*([\s\S]+)/.exec(reply);
408
+ if (runMatch) {
409
+ const command = runMatch[1].trim();
410
+ if (t.runMode === 'auto') {
411
+ const output = await execCommand(command, dirFor(t.id));
412
+ const shown = `$ ${command}\n${output}`;
413
+ t.messages.push({ role: 'user', content: `output:\n${output}` });
414
+ t.history.push({ who: 'bot', text: shown });
415
+ onEvent?.({ type: 'final', name: t.name, color: t.color, text: shown });
416
+ } else {
417
+ const runId = randomUUID();
418
+ t.pendingRun = { runId, command, cwd: dirFor(t.id) };
419
+ t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
420
+ onEvent?.({ type: 'run-pending', runId, command, name: t.name, color: t.color });
421
+ }
422
+ t.status = 'idle';
423
+ t.lastActivityAt = Date.now();
424
+ saveThreads();
425
+ return;
426
+ }
427
+ const ack = await tryDirective(reply, t.id);
428
+ const finalText = ack ?? (reply || '(no response)');
429
+ t.history.push({ who: 'bot', text: finalText });
430
+ onEvent?.({ type: 'final', name: t.name, color: t.color, text: finalText });
431
+ t.status = 'idle';
432
+ t.lastActivityAt = Date.now();
433
+ saveThreads();
434
+ bindThread(t).catch(() => {});
435
+ }
436
+
437
+ function threadSummary(t) {
438
+ const last = t.history[t.history.length - 1];
439
+ return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
440
+ preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
441
+ createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
442
+ dir: t.dir || WORKSPACE_DIR };
443
+ }
444
+
445
+ const APP_HTML = `<!doctype html>
446
+ <html><head><meta charset="utf-8"><title>openzoo</title>
447
+ <meta name="viewport" content="width=device-width,initial-scale=1">
448
+ <style>
449
+ :root { color-scheme: dark; }
450
+ * { box-sizing: border-box; }
451
+ html, body { margin: 0; height: 100%; background: #000; }
452
+ body { color: #ececec; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
453
+ display: flex; }
454
+ #dragbar { -webkit-app-region: drag; position: fixed; top: 0; left: 0; right: 0; height: 28px; z-index: 1000; }
455
+ #sidebar { width: 280px; flex: 0 0 280px; border-right: 1px solid #1c1c1e; display: flex; flex-direction: column;
456
+ height: 100vh; padding-top: 28px; }
457
+ #main { padding-top: 28px; }
458
+ #sideTop { display: flex; align-items: center; gap: 4px; padding: 0 8px; }
459
+ #sideTop #search { flex: 1; }
460
+ #search { margin: 12px; padding: 8px 12px; background: #1c1c1e; border-radius: 10px; color: #ececec;
461
+ border: none; font: inherit; }
462
+ #search::placeholder { color: #8e8e93; }
463
+ #threads { flex: 1; overflow-y: auto; }
464
+ .trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
465
+ margin: 0 6px 2px; }
466
+ .trow:hover { background: #17171a; }
467
+ .trow.active { background: #1c1c1e; }
468
+ .tclose { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 50%; border: none; background: transparent;
469
+ color: #8e8e93; display: none; align-items: center; justify-content: center; cursor: pointer;
470
+ font-size: 13px; }
471
+ .trow:hover .tclose { display: flex; }
472
+ .tclose:hover { background: #3a3a3c; color: #ececec; }
473
+ .tavatar { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: flex; align-items: center;
474
+ justify-content: center; color: #fff; font-weight: 600; font-size: 14px; }
475
+ .tmeta { min-width: 0; flex: 1; }
476
+ .tname { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
477
+ .tprev { font-size: 12px; color: #8e8e93; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
478
+ .tdot { width: 8px; height: 8px; border-radius: 50%; background: #0a84ff; flex: 0 0 8px; }
479
+ #main { flex: 1; display: flex; flex-direction: column; height: 100vh; }
480
+ #chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
481
+ font-weight: 600; }
482
+ #chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
483
+ .hname { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
484
+ .hdir { font-weight: 400; font-size: 11px; color: #8e8e93; white-space: nowrap; overflow: hidden;
485
+ text-overflow: ellipsis; max-width: 420px; }
486
+ #hudBtn { margin-left: auto; }
487
+ #chatHeaderId { display: flex; align-items: center; gap: 10px; }
488
+ #hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
489
+ border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
490
+ display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
491
+ #hud.show { display: block; }
492
+ #hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
493
+ #hud .hrow { display: flex; justify-content: space-between; margin: 6px 0; color: #f0f0eb; font-size: 12px; }
494
+ #hud .hrow span:first-child { color: #999aa8; font-size: 10.5px; }
495
+ #hud .hlime { color: #b8f240; }
496
+ #hud .hember { color: #f28c4d; }
497
+ #hud .hfoot { border-top: 1px solid #333340; margin-top: 10px; padding-top: 8px; color: #999aa8; font-size: 10px; }
498
+ #sidebar, #main { -webkit-app-region: no-drag; }
499
+ #log { flex: 1; overflow-y: auto; padding: 20px 24px 12px; display: flex; flex-direction: column; gap: 6px;
500
+ -webkit-user-select: text; user-select: text; }
501
+ .hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
502
+ color: #8e8e93; font-size: 13px; }
503
+ .hdr .avatar { width: 18px; height: 18px; border-radius: 5px; display: flex; align-items: center;
504
+ justify-content: center; color: #fff; font-size: 9px; font-weight: 700; }
505
+ .row { display: flex; max-width: 78%; margin: 2px 0; }
506
+ .row.user { align-self: flex-end; }
507
+ .row.bot { align-self: flex-start; }
508
+ .bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word;
509
+ -webkit-user-select: text; user-select: text; cursor: text; }
510
+ .bubble a { color: #6ab0ff; text-decoration: underline; cursor: pointer; }
511
+ .runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px; max-width: 100%; }
512
+ .runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
513
+ word-break: break-word; margin-bottom: 8px; }
514
+ .runactions { display: flex; gap: 8px; }
515
+ .runbtn { border: none; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; }
516
+ .runbtn.approve { background: #34c759; color: #000; }
517
+ .runbtn.deny { background: #3a3a3c; color: #ececec; }
518
+ .runbtn:disabled { opacity: .5; cursor: default; }
519
+ .runstatus { font-size: 12px; color: #8e8e93; margin-bottom: 6px; }
520
+ .runoutput { font-family: Menlo, monospace; font-size: 11.5px; color: #b8b8b8; white-space: pre-wrap;
521
+ word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
522
+ .row.user .bubble { background: #57575c; }
523
+ .row.bot .bubble { background: #262626; color: #ececec; }
524
+ .row.bot.pending .bubble { color: #8e8e93; }
525
+ .dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
526
+ background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
527
+ .dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
528
+ @keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
529
+ #bar { padding: 10px 16px 18px; position: relative; }
530
+ #row-input { display: flex; align-items: center; gap: 8px; }
531
+ #plusMenu { position: absolute; bottom: 62px; left: 16px; background: #1c1c1e; border-radius: 14px;
532
+ padding: 6px; display: none; flex-direction: column; min-width: 190px;
533
+ box-shadow: 0 8px 24px rgba(0,0,0,.5); z-index: 10; }
534
+ #plusMenu.show { display: flex; }
535
+ .pop-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px;
536
+ cursor: pointer; color: #ececec; font-size: 14px; }
537
+ .pop-item:hover { background: #2c2c2e; }
538
+ .pop-item svg { width: 18px; height: 18px; flex: 0 0 18px; }
539
+ .pop-item.record svg { color: #ff3b30; }
540
+ #pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
541
+ padding: 8px 10px 8px 14px; }
542
+ .icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
543
+ color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
544
+ flex: 0 0 32px; }
545
+ .icon-btn:hover { background: #3a3a3c; }
546
+ .icon-btn svg { width: 18px; height: 18px; }
547
+ #attachChips { display: flex; gap: 6px; flex-wrap: wrap; padding: 0 16px 6px; }
548
+ .achip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 10px;
549
+ padding: 4px 8px; font-size: 12px; }
550
+ .achip .ax { cursor: pointer; color: #8e8e93; }
551
+ #inp { flex: 1; background: transparent; border: none; color: #ececec; font: inherit;
552
+ padding: 6px 0; min-width: 0; }
553
+ #inp::placeholder { color: #8e8e93; }
554
+ #inp:focus { outline: none; }
555
+ #send { width: 34px; height: 34px; border-radius: 50%; border: none; background: #fff; color: #000;
556
+ display: none; align-items: center; justify-content: center; cursor: pointer; flex: 0 0 34px; }
557
+ #send.show { display: flex; }
558
+ #send svg { width: 16px; height: 16px; }
559
+ #composeOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; align-items: flex-start;
560
+ justify-content: center; padding-top: 90px; z-index: 200; }
561
+ #composeOverlay.show { display: flex; }
562
+ #composeBox { width: 460px; max-height: 65vh; background: #1c1c1e; border-radius: 16px; overflow: hidden;
563
+ display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,.6); }
564
+ #composeTo { display: flex; align-items: center; gap: 8px; padding: 14px 16px; border-bottom: 1px solid #2c2c2e;
565
+ color: #8e8e93; flex-wrap: wrap; }
566
+ #chips { display: flex; gap: 6px; flex-wrap: wrap; }
567
+ .chip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 14px;
568
+ padding: 3px 8px 3px 4px; font-size: 13px; }
569
+ .chip .cav { width: 16px; height: 16px; border-radius: 4px; display: inline-block; }
570
+ .chip .cx { cursor: pointer; color: #8e8e93; margin-left: 2px; }
571
+ #composeTo input { flex: 1; min-width: 100px; background: transparent; border: none; color: #ececec; font: inherit; }
572
+ #composeTo input:focus { outline: none; }
573
+ #composeList { overflow-y: auto; padding: 8px; }
574
+ .crow { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 10px; cursor: pointer; }
575
+ .crow:hover { background: #2c2c2e; }
576
+ .crow .kbd { margin-left: auto; display: flex; gap: 4px; }
577
+ kbd { background: #2c2c2e; border-radius: 5px; padding: 2px 6px; font-size: 11px; color: #8e8e93; }
578
+ #composeFoot { display: flex; gap: 16px; padding: 10px 16px; border-top: 1px solid #2c2c2e; color: #8e8e93;
579
+ font-size: 12px; }
580
+ #composeFoot kbd { margin-right: 4px; }
581
+ .mention { background: #3a3a3c; border-radius: 10px; padding: 1px 8px; font-size: 0.92em; }
582
+ </style></head>
583
+ <body>
584
+ <div id="dragbar"></div>
585
+ <div id="sidebar">
586
+ <div id="sideTop">
587
+ <button class="icon-btn" id="newMsgBtn">
588
+ <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>
589
+ </button>
590
+ <input id="search" placeholder="Search">
591
+ </div>
592
+ <div id="threads"></div>
593
+ </div>
594
+ <div id="composeOverlay">
595
+ <div id="composeBox">
596
+ <div id="composeTo">
597
+ <span>To:</span>
598
+ <span id="chips"></span>
599
+ <input id="composeInp" placeholder="Search or create Bots">
600
+ <button class="icon-btn" id="composeClose">
601
+ <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>
602
+ </button>
603
+ </div>
604
+ <div id="composeList"></div>
605
+ <div id="composeFoot"><span><kbd>Tab</kbd> add</span><span><kbd>Enter</kbd> open</span></div>
606
+ </div>
607
+ </div>
608
+ <div id="main">
609
+ <div id="chatHeader">
610
+ <div id="chatHeaderId"></div>
611
+ <button class="icon-btn" id="hudBtn">◎</button>
612
+ </div>
613
+ <div id="hud">
614
+ <div class="htitle">YOUR WALLET · THIS SESSION</div>
615
+ <div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
616
+ <div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
617
+ <div class="hrow"><span>margin</span><span id="hYouMargin" class="hlime">—</span></div>
618
+ <div class="hrow"><span>direct would be</span><span id="hYouDirect" class="hember">—</span></div>
619
+ <div class="hfoot" id="hFoot">loading…</div>
620
+ </div>
621
+ <div id="log"></div>
622
+ <div id="bar">
623
+ <div id="plusMenu">
624
+ <div class="pop-item" id="attachBtn">
625
+ <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>
626
+ <span>Attach files</span>
627
+ </div>
628
+ </div>
629
+ <input id="fileInp" type="file" multiple style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;">
630
+ <div id="attachChips"></div>
631
+ <div id="row-input">
632
+ <div id="pill">
633
+ <button class="icon-btn" id="plusBtn">
634
+ <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>
635
+ </button>
636
+ <input id="inp" placeholder="Message" autofocus>
637
+ <button class="icon-btn" tabindex="-1">
638
+ <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>
639
+ </button>
640
+ </div>
641
+ <button id="send">
642
+ <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>
643
+ </button>
644
+ </div>
645
+ </div>
646
+ </div>
647
+ <script>
648
+ const threadsEl = document.getElementById('threads');
649
+ const chatHeader = document.getElementById('chatHeader');
650
+ const log = document.getElementById('log');
651
+ const inp = document.getElementById('inp');
652
+ const send = document.getElementById('send');
653
+ let activeId = null;
654
+ let knownThreads = [];
655
+
656
+ function initials(name) { return name.slice(0, 2).toUpperCase(); }
657
+
658
+ async function loadThreads() {
659
+ const list = await (await fetch('/threads')).json();
660
+ knownThreads = list;
661
+ if (!activeId && list.length) activeId = list[0].id;
662
+ threadsEl.innerHTML = '';
663
+ for (const t of list) {
664
+ const row = document.createElement('div');
665
+ row.className = 'trow' + (t.id === activeId ? ' active' : '');
666
+ row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
667
+ '<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
668
+ (t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
669
+ (t.status === 'thinking' ? '<div class="tdot"></div>' : '') +
670
+ '<button class="tclose" title="Remove">✕</button>';
671
+ row.addEventListener('click', () => { activeId = t.id; render(); });
672
+ row.querySelector('.tclose').addEventListener('click', async (e) => {
673
+ e.stopPropagation();
674
+ await fetch('/threads/' + t.id, { method: 'DELETE' });
675
+ if (activeId === t.id) activeId = null;
676
+ await loadThreads();
677
+ if (activeId) render();
678
+ });
679
+ threadsEl.appendChild(row);
680
+ }
681
+ }
682
+
683
+ async function loadActiveMessages() {
684
+ if (!activeId) return null;
685
+ return await (await fetch('/threads/' + activeId)).json();
686
+ }
687
+
688
+ function renderHeader(t) {
689
+ document.getElementById('chatHeaderId').innerHTML =
690
+ '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
691
+ '<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
692
+ '">' + escapeHtml(t.dir || '') + ' · type /dir &lt;path&gt; to change</div></div>';
693
+ }
694
+
695
+ function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
696
+ function renderMentions(text) {
697
+ let out = escapeHtml(text);
698
+ out = out.replace(/(https?:\\/\\/[^\\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
699
+ out = out.replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>');
700
+ return out;
701
+ }
702
+
703
+ let lastSpeaker = null;
704
+ function addRow(who, text, color, name, run) {
705
+ const speakerKey = who + '|' + name;
706
+ if (who === 'bot' && speakerKey !== lastSpeaker) {
707
+ const hdr = document.createElement('div');
708
+ hdr.className = 'hdr';
709
+ hdr.innerHTML = '<span class="avatar" style="background:' + color + '">' + initials(name) + '</span><span>' + name + '</span>';
710
+ log.appendChild(hdr);
711
+ }
712
+ lastSpeaker = speakerKey;
713
+ const row = document.createElement('div');
714
+ row.className = 'row ' + who;
715
+ if (run) {
716
+ const card = document.createElement('div');
717
+ card.className = 'runcard';
718
+ const cmdEl = document.createElement('div');
719
+ cmdEl.className = 'runcmd';
720
+ cmdEl.textContent = '$ ' + text;
721
+ card.appendChild(cmdEl);
722
+ if (run.status === 'pending') {
723
+ const actions = document.createElement('div');
724
+ actions.className = 'runactions';
725
+ const approve = document.createElement('button');
726
+ approve.className = 'runbtn approve';
727
+ approve.textContent = 'Approve';
728
+ const deny = document.createElement('button');
729
+ deny.className = 'runbtn deny';
730
+ deny.textContent = 'Deny';
731
+ approve.addEventListener('click', async () => {
732
+ approve.disabled = true; deny.disabled = true;
733
+ await fetch('/threads/' + activeId + '/run/' + run.id + '/approve', { method: 'POST' });
734
+ render();
735
+ });
736
+ deny.addEventListener('click', async () => {
737
+ approve.disabled = true; deny.disabled = true;
738
+ await fetch('/threads/' + activeId + '/run/' + run.id + '/deny', { method: 'POST' });
739
+ render();
740
+ });
741
+ actions.appendChild(approve);
742
+ actions.appendChild(deny);
743
+ card.appendChild(actions);
744
+ } else {
745
+ const status = document.createElement('div');
746
+ status.className = 'runstatus';
747
+ status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
748
+ card.appendChild(status);
749
+ if (run.output) {
750
+ const out = document.createElement('pre');
751
+ out.className = 'runoutput';
752
+ out.textContent = run.output;
753
+ card.appendChild(out);
754
+ }
755
+ }
756
+ row.appendChild(card);
757
+ } else {
758
+ const bubble = document.createElement('div');
759
+ bubble.className = 'bubble';
760
+ bubble.innerHTML = renderMentions(text);
761
+ row.appendChild(bubble);
762
+ }
763
+ log.appendChild(row);
764
+ }
765
+
766
+ async function render() {
767
+ const t = knownThreads.find((x) => x.id === activeId);
768
+ if (!t) return;
769
+ renderHeader(t);
770
+ inp.placeholder = 'Message ' + t.name;
771
+ const full = await loadActiveMessages();
772
+ if (!full || full.id !== activeId) return;
773
+ // only re-pin to bottom if the reader was already there — otherwise a
774
+ // background poll (tick() runs every 1.2s) yanks them back mid-scroll
775
+ const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
776
+ log.innerHTML = '';
777
+ lastSpeaker = null;
778
+ for (const h of full.history) {
779
+ addRow(h.who, h.text, h.color || t.color, h.name || t.name,
780
+ h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined);
781
+ }
782
+ if (full.status === 'thinking') addRow('bot', '…', t.color, t.name);
783
+ if (wasNearBottom) log.scrollTop = log.scrollHeight;
784
+ }
785
+
786
+ let pendingFiles = [];
787
+ const attachChips = document.getElementById('attachChips');
788
+ function renderAttachChips() {
789
+ attachChips.innerHTML = '';
790
+ pendingFiles.forEach((f, i) => {
791
+ const chip = document.createElement('span');
792
+ chip.className = 'achip';
793
+ chip.innerHTML = '<span>' + escapeHtml(f.name) + (f.content === null ? ' (binary — name only)' : '') + '</span><span class="ax">✕</span>';
794
+ chip.querySelector('.ax').addEventListener('click', () => { pendingFiles.splice(i, 1); renderAttachChips(); });
795
+ attachChips.appendChild(chip);
796
+ });
797
+ }
798
+ function readFileAsText(file) {
799
+ return new Promise((resolve) => {
800
+ const r = new FileReader();
801
+ r.onload = () => resolve(r.result);
802
+ r.onerror = () => resolve(null);
803
+ r.readAsText(file);
804
+ });
805
+ }
806
+
807
+ async function submit() {
808
+ const task = inp.value.trim();
809
+ if ((!task && !pendingFiles.length) || !activeId) return;
810
+ inp.value = '';
811
+ send.classList.remove('show');
812
+ let full = task;
813
+ for (const f of pendingFiles) {
814
+ full += f.content !== null
815
+ ? '\\n\\n--- attached: ' + f.name + ' ---\\n' + f.content
816
+ : '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
817
+ }
818
+ pendingFiles = [];
819
+ renderAttachChips();
820
+ await fetch('/drive', {
821
+ method: 'POST', headers: { 'content-type': 'application/json' },
822
+ body: JSON.stringify({ threadId: activeId, task: full }),
823
+ });
824
+ render();
825
+ }
826
+
827
+ inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0); });
828
+ send.addEventListener('click', submit);
829
+ inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
830
+
831
+ const plusBtn = document.getElementById('plusBtn');
832
+ const plusMenu = document.getElementById('plusMenu');
833
+ const fileInp = document.getElementById('fileInp');
834
+ plusBtn.addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.toggle('show'); });
835
+ document.addEventListener('click', () => plusMenu.classList.remove('show'));
836
+ document.getElementById('attachBtn').addEventListener('click', (e) => { e.stopPropagation(); plusMenu.classList.remove('show'); fileInp.click(); });
837
+ fileInp.addEventListener('change', async () => {
838
+ for (const f of Array.from(fileInp.files)) {
839
+ 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);
840
+ const content = (looksText && f.size < 200000) ? await readFileAsText(f) : null;
841
+ pendingFiles.push({ name: f.name, size: f.size, content });
842
+ }
843
+ fileInp.value = '';
844
+ renderAttachChips();
845
+ send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0);
846
+ });
847
+
848
+ // --- compose overlay ("+" next to search: pick/create Bots, single or group) ---
849
+ const newMsgBtn = document.getElementById('newMsgBtn');
850
+ const composeOverlay = document.getElementById('composeOverlay');
851
+ const composeInp = document.getElementById('composeInp');
852
+ const composeList = document.getElementById('composeList');
853
+ const chipsEl = document.getElementById('chips');
854
+ const composeClose = document.getElementById('composeClose');
855
+ let composeSel = [];
856
+
857
+ function openCompose() {
858
+ composeSel = [];
859
+ chipsEl.innerHTML = '';
860
+ composeInp.value = '';
861
+ renderComposeList();
862
+ composeOverlay.classList.add('show');
863
+ composeInp.focus();
864
+ }
865
+ function closeCompose() { composeOverlay.classList.remove('show'); }
866
+
867
+ function addChip(t) {
868
+ composeSel.push({ name: t.name, color: t.color });
869
+ const chip = document.createElement('span');
870
+ chip.className = 'chip';
871
+ chip.innerHTML = '<span class="cav" style="background:' + t.color + '"></span>' + t.name + '<span class="cx">✕</span>';
872
+ chip.querySelector('.cx').addEventListener('click', () => {
873
+ composeSel = composeSel.filter((c) => c.name !== t.name);
874
+ chip.remove();
875
+ renderComposeList();
876
+ });
877
+ chipsEl.appendChild(chip);
878
+ composeInp.value = '';
879
+ renderComposeList();
880
+ composeInp.focus();
881
+ }
882
+
883
+ function renderComposeList() {
884
+ const q = composeInp.value.trim().toLowerCase();
885
+ const chosen = new Set(composeSel.map((c) => c.name));
886
+ const candidates = knownThreads.filter((t) => !chosen.has(t.name) && t.name.toLowerCase().includes(q));
887
+ composeList.innerHTML = '';
888
+ const createRow = document.createElement('div');
889
+ createRow.className = 'crow';
890
+ createRow.innerHTML = '<div class="tavatar" style="background:#3a3a3c;width:28px;height:28px;border-radius:8px;font-size:15px">+</div>' +
891
+ '<div>Create new Bot' + (q ? ': ' + escapeHtml(composeInp.value.trim()) : '') + '</div>' +
892
+ '<div class="kbd"><kbd>⌘</kbd><kbd>1</kbd></div>';
893
+ createRow.addEventListener('click', async () => {
894
+ const name = composeInp.value.trim() || prompt('Bot name?');
895
+ if (!name) return;
896
+ const t = await (await fetch('/threads', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })).json();
897
+ activeId = t.id;
898
+ closeCompose();
899
+ await loadThreads(); await render();
900
+ });
901
+ composeList.appendChild(createRow);
902
+ candidates.slice(0, 8).forEach((t, i) => {
903
+ const row = document.createElement('div');
904
+ row.className = 'crow';
905
+ row.innerHTML = '<div class="tavatar" style="background:' + t.color + ';width:28px;height:28px;border-radius:8px;font-size:11px">' + initials(t.name) + '</div>' +
906
+ '<div>' + escapeHtml(t.name) + '</div><div class="kbd"><kbd>⌘</kbd><kbd>' + (i + 2) + '</kbd></div>';
907
+ row.addEventListener('click', () => addChip(t));
908
+ composeList.appendChild(row);
909
+ });
910
+ }
911
+
912
+ async function openOrCreateFromCompose() {
913
+ if (composeSel.length === 1) {
914
+ const t = knownThreads.find((x) => x.name === composeSel[0].name);
915
+ if (t) { activeId = t.id; closeCompose(); await loadThreads(); await render(); return; }
916
+ }
917
+ if (composeSel.length > 1) {
918
+ const t = await (await fetch('/threads/group', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names: composeSel.map((c) => c.name) }) })).json();
919
+ activeId = t.id;
920
+ closeCompose();
921
+ await loadThreads(); await render();
922
+ }
923
+ }
924
+
925
+ newMsgBtn.addEventListener('click', (e) => { e.stopPropagation(); openCompose(); });
926
+ composeClose.addEventListener('click', closeCompose);
927
+ composeOverlay.addEventListener('click', (e) => { if (e.target === composeOverlay) closeCompose(); });
928
+ composeInp.addEventListener('input', renderComposeList);
929
+ composeInp.addEventListener('keydown', (e) => {
930
+ if (e.key === 'Escape') closeCompose();
931
+ if (e.key === 'Enter') openOrCreateFromCompose();
932
+ if (e.key === 'Tab') {
933
+ e.preventDefault();
934
+ const q = composeInp.value.trim().toLowerCase();
935
+ const chosen = new Set(composeSel.map((c) => c.name));
936
+ const cand = knownThreads.find((t) => !chosen.has(t.name) && t.name.toLowerCase().includes(q));
937
+ if (cand) addChip(cand);
938
+ }
939
+ });
940
+
941
+ async function tick() { await loadThreads(); await render(); }
942
+ tick();
943
+ setInterval(tick, 1200);
944
+
945
+ // --- cost HUD (ported from the Hammerspoon menu-bar widget, same source) ---
946
+ const hudBtn = document.getElementById('hudBtn');
947
+ const hud = document.getElementById('hud');
948
+ function usd(n) {
949
+ if (n === null || n === undefined) return '—';
950
+ if (n === 0) return '$0';
951
+ if (n < 0.01) return '$' + n.toFixed(4);
952
+ return '$' + n.toFixed(2);
953
+ }
954
+ async function refreshHud() {
955
+ try {
956
+ // fetched server-side by US (see /hud-summary below) — a renderer fetch
957
+ // straight to localhost:8402 would work fine, but routing it through
958
+ // our own backend keeps one fetch path if that ever needs to change.
959
+ const you = await (await fetch('/hud-summary')).json();
960
+ const spent = Number(you.spentUsd) || 0;
961
+ const cogs = Number(you.cogsUsd) || 0;
962
+ const direct = Number(you.directUsd) || 0;
963
+ const margin = spent > 0 ? Math.round((spent - cogs) / spent * 100) + '%' : '—';
964
+ document.getElementById('hYouSpent').textContent = usd(spent);
965
+ document.getElementById('hYouCogs').textContent = usd(cogs);
966
+ document.getElementById('hYouMargin').textContent = margin;
967
+ document.getElementById('hYouDirect').textContent = usd(direct);
968
+ document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
969
+ } catch (e) {
970
+ document.getElementById('hFoot').textContent = 'error: ' + e.message;
971
+ }
972
+ }
973
+ let hudTimer = null;
974
+ hudBtn.addEventListener('click', (e) => {
975
+ e.stopPropagation();
976
+ hud.classList.toggle('show');
977
+ if (hud.classList.contains('show')) {
978
+ refreshHud();
979
+ hudTimer = setInterval(refreshHud, 30000);
980
+ } else if (hudTimer) {
981
+ clearInterval(hudTimer); hudTimer = null;
982
+ }
983
+ });
984
+ document.addEventListener('click', (e) => { if (!hud.contains(e.target)) hud.classList.remove('show'); });
985
+ </script>
986
+ </body></html>`;
987
+
988
+ const server = http.createServer((req, res) => {
989
+ if (req.method === 'GET' && req.url === '/hud-summary') {
990
+ (async () => {
991
+ let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0 };
992
+ try { you = await (await fetch('http://127.0.0.1:8402/v1/session')).json(); }
993
+ catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
994
+ res.writeHead(200, { 'content-type': 'application/json' });
995
+ res.end(JSON.stringify(you));
996
+ })();
997
+ return;
998
+ }
999
+ if (req.method === 'GET' && req.url === '/threads') {
1000
+ res.writeHead(200, { 'content-type': 'application/json' });
1001
+ res.end(JSON.stringify([...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).map(threadSummary)));
1002
+ return;
1003
+ }
1004
+ if (req.method === 'GET' && req.url.startsWith('/threads/')) {
1005
+ const t = threads.get(req.url.split('/')[2]);
1006
+ res.writeHead(200, { 'content-type': 'application/json' });
1007
+ res.end(t ? JSON.stringify({ id: t.id, history: t.history, status: t.status }) : '{}');
1008
+ return;
1009
+ }
1010
+ if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
1011
+ threads.delete(req.url.split('/')[2]);
1012
+ saveThreads();
1013
+ res.writeHead(200, { 'content-type': 'application/json' });
1014
+ res.end('{"ok":true}');
1015
+ return;
1016
+ }
1017
+ {
1018
+ const runMatch = /^\/threads\/([^/]+)\/run\/([^/]+)\/(approve|deny)$/.exec(req.url || '');
1019
+ if (req.method === 'POST' && runMatch) {
1020
+ const [, id, runId, action] = runMatch;
1021
+ const t = threads.get(id);
1022
+ const entry = t?.history.find((h) => h.runId === runId && h.runStatus === 'pending');
1023
+ if (!t || !t.pendingRun || t.pendingRun.runId !== runId || !entry) {
1024
+ res.writeHead(404, { 'content-type': 'application/json' });
1025
+ res.end('{"ok":false}');
1026
+ return;
1027
+ }
1028
+ const { command, cwd } = t.pendingRun;
1029
+ delete t.pendingRun;
1030
+ if (action === 'deny') {
1031
+ entry.runStatus = 'denied';
1032
+ saveThreads();
1033
+ res.writeHead(200, { 'content-type': 'application/json' });
1034
+ res.end('{"ok":true}');
1035
+ runTurn(t.id, '(you denied running that command)').catch(() => {});
1036
+ return;
1037
+ }
1038
+ entry.runStatus = 'running';
1039
+ saveThreads();
1040
+ res.writeHead(200, { 'content-type': 'application/json' });
1041
+ res.end('{"ok":true}');
1042
+ execCommand(command, cwd).then((output) => {
1043
+ entry.runStatus = 'done';
1044
+ entry.runOutput = output;
1045
+ saveThreads();
1046
+ runTurn(t.id, `(command output)\n${output}`).catch(() => {});
1047
+ });
1048
+ return;
1049
+ }
1050
+ }
1051
+ if (req.method === 'POST' && req.url === '/threads') {
1052
+ const chunks = [];
1053
+ req.on('data', (d) => chunks.push(d));
1054
+ req.on('end', () => {
1055
+ let name = 'New Bot';
1056
+ try { name = (JSON.parse(Buffer.concat(chunks).toString('utf8')).name || name).toString().trim() || name; }
1057
+ catch { /* ignore */ }
1058
+ const t = newThread(name, null);
1059
+ res.writeHead(200, { 'content-type': 'application/json' });
1060
+ res.end(JSON.stringify(threadSummary(t)));
1061
+ });
1062
+ return;
1063
+ }
1064
+ if (req.method === 'POST' && req.url === '/threads/group') {
1065
+ const chunks = [];
1066
+ req.on('data', (d) => chunks.push(d));
1067
+ req.on('end', () => {
1068
+ let names = [];
1069
+ try { names = JSON.parse(Buffer.concat(chunks).toString('utf8')).names || []; } catch { /* ignore */ }
1070
+ names = names.filter(Boolean);
1071
+ if (!names.length) { res.writeHead(400, { 'content-type': 'application/json' }); res.end('{}'); return; }
1072
+ const t = newGroupThread(names);
1073
+ res.writeHead(200, { 'content-type': 'application/json' });
1074
+ res.end(JSON.stringify(threadSummary(t)));
1075
+ });
1076
+ return;
1077
+ }
1078
+ if (req.method === 'POST' && req.url === '/drive') {
1079
+ const chunks = [];
1080
+ req.on('data', (d) => chunks.push(d));
1081
+ req.on('end', async () => {
1082
+ let threadId = '', task = '';
1083
+ try {
1084
+ const j = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1085
+ threadId = j.threadId; task = (j.task || '').toString();
1086
+ } catch { /* ignore */ }
1087
+ res.writeHead(200, { 'content-type': 'application/json' });
1088
+ res.end(JSON.stringify({ ok: true }));
1089
+ // "/dir <path>" is a LOCAL control command, not sent to the model at
1090
+ // all — free, instant, sets which folder this thread's WRITE/READ/SERVE
1091
+ // are scoped to. Respecify any time by sending it again.
1092
+ const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
1093
+ const t = threads.get(threadId);
1094
+ if (dirCmd && t) {
1095
+ const full = path.resolve(expandHome(dirCmd[1].trim()));
1096
+ let ok = false;
1097
+ try { ok = statSync(full).isDirectory(); } catch { /* not a dir / doesn't exist */ }
1098
+ if (ok) {
1099
+ t.dir = full;
1100
+ t.history.push({ who: 'bot', text: `Working directory set to ${full}` });
1101
+ } else {
1102
+ t.history.push({ who: 'bot', text: `"${full}" isn't a directory that exists.` });
1103
+ }
1104
+ saveThreads();
1105
+ return;
1106
+ }
1107
+ // "/mode auto|ask" toggles whether RUN: commands execute immediately
1108
+ // or wait for an explicit approve/deny — also free/instant, no model call
1109
+ const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
1110
+ if (modeCmd && t) {
1111
+ t.runMode = modeCmd[1];
1112
+ t.history.push({ who: 'bot', text: `Run mode set to ${modeCmd[1]}${modeCmd[1] === 'auto' ? ' — commands execute immediately, no approval.' : ' — commands wait for your approval.'}` });
1113
+ saveThreads();
1114
+ return;
1115
+ }
1116
+ runTurn(threadId, task).catch(() => {});
1117
+ });
1118
+ return;
1119
+ }
1120
+ res.writeHead(200, { 'content-type': 'text/html' });
1121
+ res.end(APP_HTML);
1122
+ });
1123
+
1124
+ server.listen(PORT, '127.0.0.1', () => console.log(`[grokui] http://localhost:${PORT}`));