openzoo 0.50.95 → 0.50.97

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.
@@ -0,0 +1,1425 @@
1
+ // Pod agent — runs INSIDE our box. It IS the Grok Bot sandbox brain, on the
2
+ // ports the app expects (1337 agent, 6080 vnc, 1340 local-exec), but the brain
3
+ // is openzoo (x402-paid) and the sandbox is ours.
4
+ //
5
+ // PROTOCOL, REVERSE-ENGINEERED FROM local-exec-daemon/main.cjs (2026-08-16):
6
+ // GET /local-exec/requests the daemon opens an SSE stream to RECEIVE frames
7
+ // POST /local-exec/responses the daemon SENDS frames: hello, ping, output,
8
+ // stdout, result, exit
9
+ // frames the daemon RECEIVES (we push down the SSE):
10
+ // {kind:"welcome", providerId}
11
+ // {kind:"exec", requestId, approvalId, serverMessage:{shellArgs:{command,
12
+ // workingDirectory, timeout}}} (serverMessage = agent.v1.ExecServerMessage,
13
+ // shellArgs = agent.v1.ShellArgs; the daemon assigns id itself)
14
+ // {kind:"upload"|"download"|"cancel", ...}
15
+ // exec is APPROVAL-GATED on the user's Mac (isLocalUseBlocked / supervised).
16
+ //
17
+ // So: the daemon runs commands on the USER's machine (variant "sand",
18
+ // localRoot=/Users/…), and WE — from the box — decide what to run, paying for
19
+ // the reasoning via openzoo. That is the whole product: a Grok-Bot-shaped agent
20
+ // whose brain is the zoo.
21
+
22
+ import http from 'node:http';
23
+ import { appendFileSync } from 'node:fs';
24
+ import { randomUUID } from 'node:crypto';
25
+ import {
26
+ formatPayStatus, startModelWait, readWithIdleTimeout, STREAM_IDLE_MS,
27
+ createRaceFeed, pickRaceWinner, parseClassifyScore, RACE_MIN_SCORE,
28
+ isRaceCountable, raceLastShip, shouldRetryRaceArrival, raceFailKind,
29
+ summarizeRaceFailures,
30
+ } from './livestatus.js';
31
+ import { messageReasoning, wrapThink, stripThinkTags, reasoningPresent, splitThink } from './think.js';
32
+ import { guardFindCwd } from './runguard.js';
33
+
34
+ function emitReply(onDelta, raw) {
35
+ const parts = splitThink(raw);
36
+ if (parts.thinking) onDelta(parts.thinking, { think: true });
37
+ if (parts.visible) onDelta(parts.visible);
38
+ }
39
+ import {
40
+ probeGatewayRace, capRaceByCredit, inferRaceTier, RACE_NO_CREDIT,
41
+ recutRaceByHud, sessionDollarX,
42
+ } from './racesettle.js';
43
+ import { homedir } from 'node:os';
44
+
45
+ const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
46
+ .split(',').map((s) => Number(s.trim())).filter(Boolean);
47
+ const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
48
+ export const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
49
+ /** Live value — tests point OZ_PROXY at a mock after this module loads. */
50
+ function completionsProxy() {
51
+ return process.env.OZ_PROXY || PROXY;
52
+ }
53
+ export const MODEL = process.env.OZ_BRAIN_MODEL || 'openzoo/auto';
54
+ const MAX_STEPS = Number(process.env.OZ_MAX_STEPS || 10);
55
+
56
+ // Matches the Grok Bot chat surface itself (dark canvas, right-aligned grey
57
+ // user pills, pink-avatar left-aligned replies, pill input bar with + / mic) —
58
+ // this renders INSIDE Grok Bot's own sandbox panel (its sidebar/titlebar are
59
+ // the app's own chrome, not ours), so only the message canvas needs to match.
60
+ export const VNC_CHAT_HTML = `<!doctype html>
61
+ <html><head><meta charset="utf-8"><title>openzoo box</title>
62
+ <meta name="viewport" content="width=device-width,initial-scale=1">
63
+ <style>
64
+ :root { color-scheme: dark; }
65
+ * { box-sizing: border-box; }
66
+ html, body { margin: 0; height: 100%; background: #000; }
67
+ body { color: #ececec; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
68
+ display: flex; flex-direction: column; }
69
+ #log { flex: 1; overflow-y: auto; padding: 28px 24px 12px; display: flex; flex-direction: column; gap: 6px; }
70
+ .hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
71
+ color: #8e8e93; font-size: 13px; }
72
+ .hdr .avatar { width: 18px; height: 18px; border-radius: 5px; }
73
+ .hdr .avatar svg { width: 10px; height: 10px; }
74
+ .row { display: flex; max-width: 78%; margin: 2px 0; }
75
+ .row.user { align-self: flex-end; }
76
+ .row.bot { align-self: flex-start; }
77
+ .avatar { width: 30px; height: 30px; border-radius: 8px; flex: 0 0 30px; background: #e91e8c;
78
+ display: flex; align-items: center; justify-content: center; }
79
+ .avatar svg { width: 16px; height: 16px; }
80
+ .bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word; }
81
+ .row.user .bubble { background: #57575c; }
82
+ .row.bot .bubble { background: #262626; color: #ececec; }
83
+ .row.bot.pending .bubble { color: #8e8e93; }
84
+ .dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
85
+ background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
86
+ .dots span:nth-child(2) { animation-delay: .2s; } .dots span:nth-child(3) { animation-delay: .4s; }
87
+ @keyframes blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
88
+ #bar { padding: 10px 16px 18px; }
89
+ #row-input { display: flex; align-items: center; gap: 8px; }
90
+ #pill { flex: 1; display: flex; align-items: center; gap: 6px; background: #2c2c2e; border-radius: 26px;
91
+ padding: 8px 10px 8px 14px; }
92
+ .icon-btn { width: 32px; height: 32px; border-radius: 50%; border: none; background: transparent;
93
+ color: #ececec; display: flex; align-items: center; justify-content: center; cursor: pointer;
94
+ flex: 0 0 32px; }
95
+ .icon-btn:hover { background: #3a3a3c; }
96
+ .icon-btn svg { width: 18px; height: 18px; }
97
+ #inp { flex: 1; background: transparent; border: none; color: #ececec; font: inherit;
98
+ padding: 6px 0; min-width: 0; }
99
+ #inp::placeholder { color: #8e8e93; }
100
+ #inp:focus { outline: none; }
101
+ #send { width: 34px; height: 34px; border-radius: 50%; border: none; background: #fff; color: #000;
102
+ display: none; align-items: center; justify-content: center; cursor: pointer; flex: 0 0 34px; }
103
+ #send.show { display: flex; }
104
+ #send svg { width: 16px; height: 16px; }
105
+ </style></head>
106
+ <body>
107
+ <div id="log"></div>
108
+ <div id="bar">
109
+ <div id="row-input">
110
+ <div id="pill">
111
+ <button class="icon-btn" tabindex="-1">
112
+ <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>
113
+ </button>
114
+ <input id="inp" placeholder="Message openzoo" autofocus>
115
+ <button class="icon-btn" tabindex="-1">
116
+ <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>
117
+ </button>
118
+ </div>
119
+ <button id="send">
120
+ <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>
121
+ </button>
122
+ </div>
123
+ </div>
124
+ <script>
125
+ const log = document.getElementById('log');
126
+ const inp = document.getElementById('inp');
127
+ const send = document.getElementById('send');
128
+ 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>';
129
+ let lastWho = null;
130
+ function addRow(who, text) {
131
+ if (who === 'bot' && lastWho !== 'bot') {
132
+ const hdr = document.createElement('div');
133
+ hdr.className = 'hdr';
134
+ hdr.innerHTML = '<span class="avatar">' + AVATAR + '</span><span>openzoo</span>';
135
+ log.appendChild(hdr);
136
+ }
137
+ lastWho = who;
138
+ const row = document.createElement('div');
139
+ row.className = 'row ' + who + (text === null ? ' pending' : '');
140
+ const bubble = document.createElement('div');
141
+ bubble.className = 'bubble';
142
+ bubble.innerHTML = text === null ? '<span class="dots"><span></span><span></span><span></span></span>' : '';
143
+ if (text !== null) bubble.textContent = text;
144
+ row.appendChild(bubble);
145
+ log.appendChild(row);
146
+ log.scrollTop = log.scrollHeight;
147
+ return { row, bubble };
148
+ }
149
+ async function submit() {
150
+ const task = inp.value.trim();
151
+ if (!task) return;
152
+ inp.value = '';
153
+ send.classList.remove('show');
154
+ inp.disabled = true;
155
+ addRow('user', task);
156
+ const pending = addRow('bot', null);
157
+ try {
158
+ const r = await fetch('/drive', {
159
+ method: 'POST', headers: { 'content-type': 'application/json' },
160
+ body: JSON.stringify({ task }),
161
+ });
162
+ const j = await r.json();
163
+ pending.row.classList.remove('pending');
164
+ pending.bubble.textContent = j.text || '(no response)';
165
+ } catch (e) {
166
+ pending.row.classList.remove('pending');
167
+ pending.bubble.textContent = 'error: ' + e.message;
168
+ }
169
+ inp.disabled = false; inp.focus();
170
+ }
171
+ inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0); });
172
+ send.addEventListener('click', submit);
173
+ inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
174
+ </script>
175
+ </body></html>`;
176
+
177
+ function record(entry) {
178
+ try { appendFileSync(LOG, JSON.stringify(entry) + '\n'); } catch { /* best effort */ }
179
+ console.log(`[agent:${entry.port ?? '-'}] ${entry.method || entry.ev} ${entry.path || ''} ${entry.bodyBytes ?? ''}${entry.frames ? ' frames=' + entry.frames : ''}`);
180
+ }
181
+
182
+ // ------------------------------------------------------------------ frames --
183
+
184
+ /** Send frames to the daemon down its SSE. The daemon reads the stream with a
185
+ * TextDecoder and parses per-line JSON, so one frame per `data:` line. We wrap
186
+ * as {frames:[…]} to mirror the response side; a bare frame is the fallback. */
187
+ function sseSend(res, frame) {
188
+ const line = `data: ${JSON.stringify({ frames: [frame] })}\n\n`;
189
+ try { res.write(line); return true; } catch { return false; }
190
+ }
191
+
192
+ function execFrame(command, cwd = '/tmp') {
193
+ return {
194
+ kind: 'exec',
195
+ requestId: randomUUID(),
196
+ approvalId: randomUUID(),
197
+ // agent.v1.ExecServerMessage as protobuf-es JSON (camelCase). The daemon
198
+ // assigns `id`; we only supply the shell variant.
199
+ serverMessage: { shellArgs: { command: guardFindCwd(command, cwd), workingDirectory: cwd, timeout: 120 } },
200
+ };
201
+ }
202
+
203
+ // ------------------------------------------------------------------- brain --
204
+
205
+ // deepseek-v4-pro-0813 (the default MODEL) doesn't expose modality info via
206
+ // /v1/models, and it's not something we can verify blind — rather than gamble
207
+ // on a text-only model silently ignoring pasted images, any message with
208
+ // multimodal (image_url) content routes to a model KNOWN to support vision.
209
+ const VISION_MODEL = process.env.OZ_VISION_MODEL || 'anthropic/claude-sonnet-5';
210
+ // Output budget per turn. Reasoning models spend this on their chain of
211
+ // thought BEFORE emitting any content, so a budget that is merely "enough for
212
+ // the answer" produces no answer at all on a hard prompt.
213
+ const MAX_TOKENS = Number(process.env.OZ_MAX_TOKENS || 4096);
214
+ const msgHasImage = (m) => Array.isArray(m.content) && m.content.some((c) => c?.type === 'image_url');
215
+
216
+ // How far back an image still counts as "being discussed". Beyond this the
217
+ // thread drops back to the cheaper text model.
218
+ const VISION_WINDOW = Number(process.env.OZ_VISION_WINDOW || 8);
219
+
220
+ // This used to scan the WHOLE history, so a single pasted image pinned every
221
+ // later turn in that thread to the vision model forever — permanently, and
222
+ // invisibly, at 1.5x input and 2.5x output (claude-sonnet-5 $6/$30 per M vs
223
+ // deepseek-v4-pro $3.96/$11.88, priced live off /v1/models). A thread where
224
+ // someone shared one screenshot an hour ago was still paying vision rates for
225
+ // pure text chat.
226
+ function hasImages(messages) {
227
+ return messages.slice(-VISION_WINDOW).some(msgHasImage);
228
+ }
229
+
230
+ // When we DO fall back to the text model, the old image_url blocks must not go
231
+ // with it — a non-vision model either errors on them or silently ignores them
232
+ // while still being billed for the payload. Replace them with a marker so the
233
+ // model knows an image was there rather than seeing a gap it might deny.
234
+ function stripImages(messages) {
235
+ return messages.map((m) => {
236
+ if (!msgHasImage(m)) return m;
237
+ const text = m.content.filter((c) => c?.type === 'text').map((c) => c.text).join(' ').trim();
238
+ return { ...m, content: `${text}${text ? ' ' : ''}[image sent earlier, no longer in context]` };
239
+ });
240
+ }
241
+
242
+ // A non-ok response with no usable content used to silently become '', which
243
+ // grokui.mjs then renders as a generic "(no response)" — indistinguishable
244
+ // from a model that genuinely had nothing to say. Callers should know WHY.
245
+ async function httpErrorNote(status) {
246
+ if (status === 402) {
247
+ // print the ACTUAL address in the chat, not a link elsewhere to go find it.
248
+ // fetch() does NOT reject on 4xx/5xx, so an older proxy without /wallet
249
+ // returns an error body that parses fine and yields "undefined" fields —
250
+ // check r.ok and the fields themselves before interpolating them.
251
+ try {
252
+ const r = await fetch(`${PROXY}/wallet`);
253
+ const w = r.ok ? await r.json() : null;
254
+ if (w?.funding && w?.evm) {
255
+ // funded === false is the genuinely-empty case; funded === true after
256
+ // the retries above means the rail/quote failed, not the balance
257
+ if (w.funded === false) {
258
+ return `(payment required — HTTP 402, the wallet is empty. ${w.funding}. EVM (Base/Robinhood): ${w.evm}.)`;
259
+ }
260
+ return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries, though the wallet holds ${w.balances || 'a balance'}. Send it again; if it keeps failing the quoted asset may not be convertible right now. Fund with: ${w.funding})`;
261
+ }
262
+ } catch { /* proxy unreachable — fall through to the generic note */ }
263
+ return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries. Run \`npx openzoo\` to check wallet balances.)`;
264
+ }
265
+ if (status === 429) return '(rate limited — HTTP 429, try again in a moment)';
266
+ if (status >= 500) return `(upstream error — HTTP ${status}, try again)`;
267
+ return status ? `(request failed — HTTP ${status})` : '';
268
+ }
269
+
270
+ /** Do not dump raw Solana wrap-sim logs into the chat bubble. */
271
+ function sanitizeProxiedError(msg) {
272
+ if (!msg) return msg;
273
+ const s = String(msg);
274
+ if (/0x6a\b|custom program error:\s*106\b|NotEnoughAccounts/i.test(s)) {
275
+ return 'wrap ix has too few accounts (need 9); old 5-account wrap is dead';
276
+ }
277
+ if (/0x70\b|custom program error:\s*112\b|TokenProgramMismatch/i.test(s)) {
278
+ return 'unwrap ix is missing the unwrapped token program (account 8); 8-account unwrap is dead';
279
+ }
280
+ return s;
281
+ }
282
+
283
+ // A 402 that reached this layer means the proxy's own x402 retry gave up on
284
+ // this attempt, but the NEXT attempt usually settles (measured: same wallet,
285
+ // same rail, second call pays fine). Surfacing that as a chat message makes
286
+ // the user do the retry by hand — so do it here instead.
287
+ // Empty/underfunded is NOT that handshake: retrying just re-walks wrap.
288
+ const PAYMENT_RETRIES = 3;
289
+
290
+ export function isUnderfunded402Body(body) {
291
+ const msg = String(body?.error?.message || body?.error || body?.message || '');
292
+ return /\b(?:underfunded|wallet is empty|empty wallet)\b/i.test(msg);
293
+ }
294
+ /**
295
+ * ADAPTIVE top_k. We have learned this one the expensive way already.
296
+ *
297
+ * On leCore the miss was never the ranker — BM25 ranked correctly. It was
298
+ * top_k=16 against a 7,000-chunk corpus: we only ever ASKED for sixteen. Same
299
+ * shape here, and worse: grokui never set the header at all, so every call fell
300
+ * to the gateway default of EIGHT, while a whole project's bots write into one
301
+ * shared context. Six bots working for an hour and the model sees eight chunks
302
+ * of it.
303
+ *
304
+ * So scale with the corpus instead of picking a number. sqrt keeps it sane at
305
+ * both ends — 100 chunks -> 20, 1k -> 63, 7k -> 167, and it saturates at the
306
+ * gateway's 256 ceiling rather than growing without bound. Floor of 16 so a
307
+ * brand-new thread is never worse off than the old default.
308
+ *
309
+ * Cost is real and proportional (measured on leCore: top_k 16 = $0.0070,
310
+ * top_k 128 = $0.0489 on the same question) — which is the point. The extra
311
+ * spend IS the extra corpus actually being read.
312
+ */
313
+ export function adaptiveTopK(boundItems) {
314
+ const n = Math.max(0, Number(boundItems) || 0);
315
+ return Math.max(16, Math.min(256, Math.ceil(Math.sqrt(n) * 2)));
316
+ }
317
+
318
+ async function postChat(body, contextId, topK, onStatus, signal) {
319
+ void contextId; // spill path: never send as x-hrr-context
320
+ let r;
321
+ for (let attempt = 0; attempt <= PAYMENT_RETRIES; attempt++) {
322
+ if (signal?.aborted) {
323
+ const err = new Error(signal.reason?.message || 'aborted');
324
+ err.name = 'AbortError';
325
+ throw err;
326
+ }
327
+ r = await fetch(`${completionsProxy()}/chat/completions`, {
328
+ method: 'POST',
329
+ headers: {
330
+ 'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
331
+ // Only sent when we actually know the corpus size; without it the
332
+ // gateway keeps its own default rather than getting a made-up number.
333
+ ...(topK ? { 'x-hrr-top-k': String(topK) } : {}),
334
+ // Do NOT attach x-hrr-context. proxy.js maybeCacheCorpus bails
335
+ // (`if (req.headers['x-hrr-context']) return null`) so Claude CLI's
336
+ // bind-prefix / send-3/131-turns never ran for grokui. Tetris then
337
+ // shipped ~850k chars × race of 4 and every model failed in ~22s.
338
+ // Completions must hit the same sidecar spill path as `npx openzoo claude`.
339
+ // bindThread stays for other things; contextId is kept on the signature
340
+ // so brain / brainStream callers do not change.
341
+ },
342
+ body: JSON.stringify(body),
343
+ ...(signal ? { signal } : {}),
344
+ });
345
+ if (r.status !== 402 || attempt === PAYMENT_RETRIES) return r;
346
+ // Handshake 402 (funded, settle flake) → retry. Empty-wallet 402 → stop.
347
+ // Opening Pay / parking happens on the empty body, not on every 402.
348
+ const peek = await r.clone().json().catch(() => null);
349
+ if (isUnderfunded402Body(peek)) return r;
350
+ // A 402 retry used to be silent — grokui sat on mute "…" for the whole
351
+ // settle. Tell the watcher this attempt is paying, not wedged.
352
+ onStatus?.(formatPayStatus(attempt));
353
+ await new Promise((res) => setTimeout(res, 800 * (attempt + 1)));
354
+ }
355
+ return r;
356
+ }
357
+
358
+ /** One openzoo chat turn. Paid per call by the box's own wallet via the local
359
+ * proxy — no key, no account. */
360
+ // Which model actually serves a turn is decided HERE, not by the caller: a
361
+ // thread carrying an image routes to VISION_MODEL. A system prompt written
362
+ // once at thread creation therefore cannot name it correctly, and a bot left
363
+ // to guess asserts whatever its training prior says — MEASURED: a Claude-served
364
+ // turn insisted it was Claude while the app's default is DeepSeek, and burned
365
+ // several paid turns arguing. So the prompt carries a placeholder and the real
366
+ // id is substituted at call time, per turn.
367
+ function withModelId(messages, model) {
368
+ return messages.map((m) => (m.role === 'system' && typeof m.content === 'string' && m.content.includes('__OZ_MODEL__')
369
+ ? { ...m, content: m.content.replaceAll('__OZ_MODEL__', model) }
370
+ : m));
371
+ }
372
+
373
+ export async function brain(messages, contextId, modelOverride, topK, signal) {
374
+ // explicit plugins, not relying on the gateway's "inject when caller said
375
+ // nothing" default — an explicit array is always respected as-is, so every
376
+ // bot on every model actually has web search. max_tokens 900 was cutting
377
+ // real (especially web-search-backed) answers off mid-sentence.
378
+ //
379
+ // modelOverride is what "/model <id>" sets per thread. VISION still wins
380
+ // when the conversation actually contains images: a text-only model handed
381
+ // image parts just errors, so silently honouring the override there would
382
+ // turn a working thread into a broken one.
383
+ const vision = hasImages(messages);
384
+ const model = vision ? VISION_MODEL : (modelOverride || MODEL);
385
+ messages = vision ? messages : stripImages(messages);
386
+ const r = await postChat(
387
+ { model, max_tokens: 4096, messages: withModelId(messages, model), plugins: [{ id: 'web' }] },
388
+ contextId, topK, undefined, signal,
389
+ );
390
+ const j = await r.json().catch(() => ({}));
391
+ const msg = j?.choices?.[0]?.message;
392
+ const content = msg?.content;
393
+ const thinking = messageReasoning(msg);
394
+ // Same truncation catch as the streaming path (see brainStream): a reply that
395
+ // stops because the budget ran out is not a finished reply, and this path is
396
+ // what non-streaming callers — including every SPAWNed subagent — go through.
397
+ if (content && j?.choices?.[0]?.finish_reason === 'length') {
398
+ const rest = await brainContinue(messages, content, contextId, modelOverride, 0);
399
+ return wrapThink(thinking, content + rest);
400
+ }
401
+ return wrapThink(thinking, content || (r.ok ? '' : await httpErrorNote(r.status)));
402
+ }
403
+
404
+ /** Resume a reply that hit the output cap, non-streaming. Bounded by
405
+ * CONTINUE_ROUNDS for the same runaway reason brainStream is. */
406
+ async function brainContinue(messages, sofar, contextId, modelOverride, round) {
407
+ if (round >= CONTINUE_ROUNDS) return '';
408
+ const vision = hasImages(messages);
409
+ const model = vision ? VISION_MODEL : (modelOverride || MODEL);
410
+ const next = [...messages,
411
+ { role: 'assistant', content: sofar },
412
+ { role: 'user', content: CONTINUE_NUDGE }];
413
+ const r = await postChat(
414
+ { model, max_tokens: Math.min(4096 * (2 ** (round + 1)), MAX_CONTINUE_TOKENS),
415
+ messages: withModelId(vision ? next : stripImages(next), model), plugins: [{ id: 'web' }] },
416
+ contextId, undefined, undefined,
417
+ );
418
+ const j = await r.json().catch(() => ({}));
419
+ const more = j?.choices?.[0]?.message?.content || '';
420
+ if (more && j?.choices?.[0]?.finish_reason === 'length') {
421
+ return more + await brainContinue(messages, sofar + more, contextId, modelOverride, round + 1);
422
+ }
423
+ return more;
424
+ }
425
+
426
+ /** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
427
+ * live-typing UI) and resolves with the full accumulated text at the end, so
428
+ * callers that need to parse a directive out of the complete reply still can.
429
+ * onStatus(detail) is an optional second channel: paying / waiting on model /
430
+ * thinking, so a 20–40s settle is visibly alive instead of mute dots. */
431
+ export async function brainStream(messages, onDelta, contextId, modelOverride, maxTokens, round = 0, topK = 0, onStatus, signal) {
432
+ const vision = hasImages(messages);
433
+ const model = vision ? VISION_MODEL : (modelOverride || MODEL);
434
+ messages = vision ? messages : stripImages(messages);
435
+ const budget = maxTokens || MAX_TOKENS;
436
+ const r = await postChat(
437
+ { model, max_tokens: budget, messages: withModelId(messages, model), plugins: [{ id: 'web' }], stream: true },
438
+ contextId, topK, onStatus, signal,
439
+ );
440
+ if (!r.ok || !r.body) {
441
+ // fall back to the non-streaming path rather than fail outright
442
+ const j = await r.json().catch(() => ({}));
443
+ const msg = j?.choices?.[0]?.message;
444
+ const content = msg?.content;
445
+ const thinking = messageReasoning(msg);
446
+ const proxied = sanitizeProxiedError(j?.error?.message);
447
+ const text = content || (r.ok ? '' : (proxied ? `(request failed — HTTP ${r.status}: ${proxied})` : await httpErrorNote(r.status)));
448
+ if (thinking) onDelta(thinking, { think: true });
449
+ if (text) onDelta(text);
450
+ return wrapThink(thinking, text);
451
+ }
452
+ const reader = r.body.getReader();
453
+ const decoder = new TextDecoder();
454
+ let buf = '', full = '', reasonedChars = 0, reasonedText = '', finish = '';
455
+ let stopWait = startModelWait(onStatus);
456
+ const noteThinking = () => {
457
+ stopWait();
458
+ onStatus?.('thinking…');
459
+ };
460
+ const takeReasoning = (delta, message) => {
461
+ const chunk = messageReasoning(message, delta);
462
+ if (!chunk) return;
463
+ reasonedChars += chunk.length;
464
+ reasonedText += chunk;
465
+ onDelta(chunk, { think: true });
466
+ if (!full) noteThinking();
467
+ };
468
+ try {
469
+ for (;;) {
470
+ let chunk;
471
+ try {
472
+ chunk = await readWithIdleTimeout(reader, STREAM_IDLE_MS);
473
+ } catch (e) {
474
+ if (e?.code !== 'STREAM_IDLE') throw e;
475
+ try { await reader.cancel(); } catch { /* already closed */ }
476
+ stopWait();
477
+ // A quiet SSE used to hang this loop forever and leave grokui on
478
+ // thinking / "…". Prefer what we have; if we have nothing, one
479
+ // non-stream retry rather than a mute bubble.
480
+ if (full) {
481
+ const note = '\n\n(stream stalled — showing what arrived before the timeout)';
482
+ onDelta(note);
483
+ return wrapThink(reasonedText, full + note);
484
+ }
485
+ onStatus?.('waiting on model…');
486
+ const fallback = await brain(messages, contextId, modelOverride, topK, signal);
487
+ if (fallback) emitReply(onDelta, fallback);
488
+ return fallback || '(stream timed out — no tokens arrived)';
489
+ }
490
+ const { value, done } = chunk;
491
+ if (done) break;
492
+ buf += decoder.decode(value, { stream: true });
493
+ const lines = buf.split('\n');
494
+ buf = lines.pop(); // last line may be incomplete — keep it for next chunk
495
+ for (const line of lines) {
496
+ const s = line.trim();
497
+ if (!s.startsWith('data:')) continue;
498
+ const payload = s.slice(5).trim();
499
+ if (payload === '[DONE]') continue;
500
+ try {
501
+ const c = JSON.parse(payload)?.choices?.[0];
502
+ const d = c?.delta;
503
+ // The LAST chunk carries why generation stopped. "length" means the
504
+ // budget ran out mid-answer — the only way to tell a finished reply
505
+ // from a guillotined one.
506
+ if (c?.finish_reason) finish = c.finish_reason;
507
+ if (d?.content) {
508
+ stopWait();
509
+ full += d.content;
510
+ onDelta(d.content);
511
+ }
512
+ // Reasoning models emit their chain of thought on a SEPARATE field
513
+ // (reasoning_content / reasoning / thinking / thought, or a provider
514
+ // object with a plaintext summary). Forward the plaintext as
515
+ // onDelta(text, { think: true }) so the canvas can fold it — never
516
+ // as visible content. Encrypted blobs stay counted for the empty-
517
+ // after-think retry, but are not forwarded.
518
+ const before = reasonedChars;
519
+ takeReasoning(d, c?.message);
520
+ if (reasonedChars === before && reasoningPresent(d, c?.message)) {
521
+ // Encrypted-only: nothing to fold, but the model DID think —
522
+ // count it so an empty completion retries instead of "(no response)".
523
+ reasonedChars += 1;
524
+ if (!full) noteThinking();
525
+ }
526
+ } catch { /* keep-alive line or partial JSON — ignore */ }
527
+ }
528
+ }
529
+ } finally {
530
+ stopWait();
531
+ }
532
+
533
+ // EMPTY CONTENT AFTER HEAVY REASONING is a truncation, not an answer. It
534
+ // surfaced in the UI as a bare "(no response)" bubble that cost the user a
535
+ // turn and explained nothing — on exactly the long, complex prompts where a
536
+ // reasoning model thinks the most. Retry ONCE with a bigger budget.
537
+ if (!full && reasonedChars > 0 && !maxTokens) {
538
+ onStatus?.('retrying…');
539
+ return brainStream(messages, onDelta, contextId, modelOverride, budget * 4, round, topK, onStatus, signal);
540
+ }
541
+
542
+ // CUT OFF MID-ANSWER. finish_reason "length" means the model had more to say
543
+ // and the budget ended the sentence for it — seen live as a reply that stops
544
+ // inside `for (`. Nothing above catches this, because `full` is non-empty:
545
+ // by every other measure the turn succeeded.
546
+ //
547
+ // CONTINUE rather than retry. Re-running the turn with a bigger budget makes
548
+ // the user pay twice for the half we already have (and on a reasoning model,
549
+ // pay for the whole chain of thought again). Handing the model back its own
550
+ // partial and asking for the rest costs only the rest.
551
+ //
552
+ // Bounded, because a model that ignores the nudge would otherwise continue
553
+ // forever on the user's wallet.
554
+ if (full && finish === 'length' && round < CONTINUE_ROUNDS) {
555
+ const more = await brainStream(
556
+ [...messages,
557
+ { role: 'assistant', content: full },
558
+ { role: 'user', content: CONTINUE_NUDGE }],
559
+ onDelta, contextId, modelOverride,
560
+ Math.min(budget * 2, MAX_CONTINUE_TOKENS), round + 1, topK, onStatus,
561
+ );
562
+ return wrapThink(reasonedText, full + (more || ''));
563
+ }
564
+ return wrapThink(reasonedText, full);
565
+ }
566
+
567
+ // How many times a single answer may be resumed after hitting the cap. Three
568
+ // doublings off 4096 is ~57k tokens of answer, which is past any real reply and
569
+ // well short of a runaway.
570
+ const CONTINUE_ROUNDS = Number(process.env.OZ_CONTINUE_ROUNDS || 3);
571
+ const MAX_CONTINUE_TOKENS = Number(process.env.OZ_MAX_CONTINUE_TOKENS || 32768);
572
+ // Deliberately blunt about the seam: the partial usually ends mid-token, and a
573
+ // model that "helpfully" restarts the sentence produces a visible stutter in
574
+ // the middle of the user's code.
575
+ const CONTINUE_NUDGE = 'You were cut off — your previous message hit the output limit mid-way. '
576
+ + 'Continue from EXACTLY where it stopped. Do not repeat any of it, do not summarise it, '
577
+ + 'do not add a preamble or an apology, and do not re-open a code fence that is already open. '
578
+ + 'Resume mid-word if that is where it ended.';
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // MODEL TIERS · cheap / medium / expensive
582
+ // ---------------------------------------------------------------------------
583
+ // Ranking the live catalog by price alone picks garbage at both ends: the most
584
+ // expensive served model is o1-pro at $1800/Mtok (a bad coding model that would
585
+ // drain the box wallet in a handful of turns), and the cheapest is a roleplay
586
+ // finetune. Price is a proxy for capability only within a band, never across
587
+ // the whole catalog. So each tier is a CURATED, ordered preference list, and
588
+ // the catalog is used to check what is actually served today — the zoo's model
589
+ // list changes under us, and a tier that resolves to a 404 is worse than no
590
+ // tier at all.
591
+ //
592
+ // Each list is ordered best-first (that is what a non-racing "auto" picks) but
593
+ // deliberately WIDE, because a race samples from it at random: a pool of three
594
+ // would race the same three models every time, which is neither a real hedge
595
+ // against a single provider having a bad minute nor a real sample of the tier.
596
+ // Prices in the comments are completion USD per Mtok as served, measured.
597
+ const TIERS = {
598
+ // ≲ $3/Mtok. Fast, good enough for glue work, cheap enough to race widely.
599
+ cheap: [
600
+ 'deepseek/deepseek-v4-flash', // 0.45
601
+ 'meta-llama/llama-4-scout', // 0.90
602
+ 'z-ai/glm-4.7-flash', // 1.20
603
+ 'bytedance-seed/seed-2.0-mini', // 1.20
604
+ 'meta-llama/llama-4-maverick', // 2.40
605
+ 'z-ai/glm-4.5-air', // 2.55
606
+ 'minimax/minimax-m2.5', // 2.70
607
+ 'z-ai/glm-4.6v', // 2.70
608
+ 'minimax/minimax-m2', // 3.06
609
+ 'inclusionai/ling-3.0-flash', // 0.19
610
+ ],
611
+ // ~$4.5–11/Mtok. The default band; deepseek-v4-pro is the app default.
612
+ medium: [
613
+ 'deepseek/deepseek-v4-pro-0813', // 5.94
614
+ 'z-ai/glm-4.7', // 5.25
615
+ 'google/gemini-3.7-flash', // 5.63
616
+ 'x-ai/grok-4.3', // 7.50
617
+ 'moonshotai/kimi-k2.7-code', // 10.50
618
+ 'z-ai/glm-5', // 5.76
619
+ 'moonshotai/kimi-k2.6', // 7.08
620
+ 'mistralai/mistral-large-2512', // 4.50
621
+ 'bytedance-seed/seed-2.0-code', // 9.00
622
+ 'qwen/qwen3.8-27b', // 9.60
623
+ ],
624
+ // ≥ $18/Mtok. Frontier. NOTE the ceiling: o1-pro ($1800) and the *-pro tiers
625
+ // ($240–540) are deliberately NOT here. A race of four across that band can
626
+ // cost dollars per turn on a box funded with a few cents.
627
+ expensive: [
628
+ 'anthropic/claude-opus-5', // 75
629
+ 'openai/gpt-5.5', // 90
630
+ 'anthropic/claude-sonnet-5', // 30
631
+ 'x-ai/grok-4.6', // 18
632
+ 'moonshotai/kimi-k3', // 45
633
+ 'anthropic/claude-opus-4.8', // 75
634
+ 'openai/gpt-5.4', // 45
635
+ 'qwen/qwen3.8-max', // 18
636
+ 'x-ai/grok-4.5', // 18
637
+ ],
638
+ // Dedicated grok 4.6 band (tweeted as its own dial next to cheap/medium/expensive).
639
+ // One slug cannot fill a 4-wide race without replacement, so the pool is grok
640
+ // chat models with 4.6 first. No imagine / stt / tts / video.
641
+ 'grok4.6': [
642
+ 'x-ai/grok-4.6',
643
+ 'x-ai/grok-4.5',
644
+ 'x-ai/grok-4.3',
645
+ 'x-ai/grok-4.20',
646
+ ],
647
+ };
648
+ export const TIER_NAMES = Object.keys(TIERS);
649
+ export const TIER_ALIASES = {
650
+ grok: 'grok4.6',
651
+ 'grok 4.6': 'grok4.6',
652
+ 'grok-4.6': 'grok4.6',
653
+ 'grok4.6': 'grok4.6',
654
+ };
655
+ export function normalizeTier(s) {
656
+ const raw = String(s || '').trim().toLowerCase();
657
+ if (raw === 'auto') return 'auto';
658
+ if (TIER_NAMES.includes(raw)) return raw;
659
+ if (TIER_ALIASES[raw]) return TIER_ALIASES[raw];
660
+ const compact = raw.replace(/[\s_]/g, '');
661
+ if (TIER_NAMES.includes(compact)) return compact;
662
+ if (TIER_ALIASES[compact]) return TIER_ALIASES[compact];
663
+ return null;
664
+ }
665
+
666
+ let catalogCache = { at: 0, ids: null };
667
+ async function servedIds() {
668
+ // 5 minutes: long enough that a race does not re-fetch per model, short
669
+ // enough that a model coming back after an outage is picked up the same
670
+ // session.
671
+ if (catalogCache.ids && Date.now() - catalogCache.at < 300_000) return catalogCache.ids;
672
+ try {
673
+ const r = await fetch(`${PROXY}/models`);
674
+ const j = await r.json();
675
+ const ids = new Set((j?.data || []).map((m) => m.id).filter(Boolean));
676
+ if (ids.size) catalogCache = { at: Date.now(), ids };
677
+ } catch { /* proxy down — fall through to whatever we had, or null */ }
678
+ return catalogCache.ids;
679
+ }
680
+
681
+ /**
682
+ * The models a tier resolves to right now, only ones actually served.
683
+ *
684
+ * `random` is what a race uses: pick n from the whole tier at random rather
685
+ * than always the top n. Two reasons it must be random and not top-n — a fixed
686
+ * trio is not a hedge (they can share an upstream having a bad minute, which is
687
+ * precisely the failure racing is meant to survive), and it silently reduces a
688
+ * ten-model tier to three models the user never chose.
689
+ *
690
+ * Falls back to the curated list unchecked if the catalog is unreachable — a
691
+ * stale-but-plausible id beats refusing to answer.
692
+ */
693
+ export async function tierModels(tier, n = 1, random = false) {
694
+ const want = TIERS[tier] || TIERS.medium;
695
+ const ids = await servedIds();
696
+ const live = ids ? want.filter((m) => ids.has(m)) : want;
697
+ const pool = live.length ? live : want;
698
+ const take = Math.max(1, Math.min(n, pool.length));
699
+ if (!random) return pool.slice(0, take);
700
+ // Fisher-Yates on a copy: sampling without replacement, because racing a
701
+ // model against itself buys nothing and still bills twice.
702
+ const a = pool.slice();
703
+ for (let i = a.length - 1; i > 0; i--) {
704
+ const j = Math.floor(Math.random() * (i + 1));
705
+ [a[i], a[j]] = [a[j], a[i]];
706
+ }
707
+ return a.slice(0, take);
708
+ }
709
+
710
+ async function readProxySession(proxy = completionsProxy()) {
711
+ try {
712
+ const origin = String(proxy || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
713
+ const r = await fetch(`${origin}/v1/session`, { signal: AbortSignal.timeout(800) });
714
+ if (!r.ok) return null;
715
+ return await r.json();
716
+ } catch { return null; }
717
+ }
718
+
719
+ async function raceBudget(hooks) {
720
+ const injected = hooks.creditUsd != null || hooks.quoteUsd != null
721
+ || hooks.spentUsd != null || hooks.directUsd != null || hooks.dollarX != null;
722
+ if (injected) {
723
+ return {
724
+ creditUsd: hooks.creditUsd,
725
+ quoteUsd: hooks.quoteUsd,
726
+ spentUsd: hooks.spentUsd,
727
+ directUsd: hooks.directUsd,
728
+ dollarX: hooks.dollarX,
729
+ };
730
+ }
731
+ // Injected stream = unit-test N-parallel path. Do not poke :8402.
732
+ if (hooks.stream) return {};
733
+ const s = await readProxySession(hooks.proxy || completionsProxy());
734
+ return {
735
+ creditUsd: s?.creditUsd,
736
+ quoteUsd: s?.lastQuoteUsd ?? s?.quoteUsd,
737
+ spentUsd: s?.spentUsd,
738
+ directUsd: s?.directUsd,
739
+ };
740
+ }
741
+
742
+ function raceRacerId(obj, fallback) {
743
+ const r = obj?.race || obj?.racer || {};
744
+ if (r.model) return String(r.model);
745
+ if (r.i != null) return `racer-${r.i}`;
746
+ if (obj?.model) return String(obj.model);
747
+ const idx = obj?.choices?.[0]?.index;
748
+ if (idx != null && idx !== 0) return `racer-${idx}`;
749
+ return fallback || 'gateway';
750
+ }
751
+
752
+ function raceEventOf(obj) {
753
+ const r = obj?.race || obj?.racer;
754
+ if (!r || typeof r !== 'object') return null;
755
+ const ev = String(r.event || r.status || '').toLowerCase();
756
+ return { id: raceRacerId(obj, 'gateway'), ev, text: r.text, error: r.error };
757
+ }
758
+
759
+ /**
760
+ * One Fly (or race-capable mock) POST. Streams tokens. First-X-countable:
761
+ * empties / HTTP / pay / fetch-failed do not fill X.
762
+ */
763
+ async function brainGatewayRace(messages, onDelta, contextId, models, need, maxTokens, onStatus, hooks) {
764
+ const n = models.length;
765
+ const want = Math.max(1, Math.min(Number(need) || 1, n));
766
+ const tier = hooks.tier || inferRaceTier(models, 'medium');
767
+ const classify = hooks.classify || classifyRaceAnswer;
768
+ const pairwise = hooks.pairwise || pairwiseTied;
769
+ const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
770
+ const feed = createRaceFeed(onDelta, onStatus, want, hooks.onRace);
771
+ feed.start(models, hooks.raceRecut ? { recut: hooks.raceRecut } : undefined);
772
+
773
+ const arrivals = [];
774
+ const done = [];
775
+ const noteRace = (arr) => {
776
+ try {
777
+ hooks.onArrivals?.(arr);
778
+ const line = JSON.stringify({
779
+ at: new Date().toISOString(),
780
+ fail: summarizeRaceFailures(arr),
781
+ n: arr.length,
782
+ kinds: arr.map((a) => raceFailKind(a)),
783
+ door: 'gateway',
784
+ });
785
+ appendFileSync(`${homedir()}/.openzoo/grokui-race.log`, line + '\n');
786
+ } catch { /* diagnostic only */ }
787
+ };
788
+ const ship = (cand) => {
789
+ const out = cand && String(cand.text || '').trim() ? cand : raceLastShip(arrivals);
790
+ feed.settle(out);
791
+ noteRace(arrivals);
792
+ return out.text;
793
+ };
794
+
795
+ const vision = hasImages(messages);
796
+ const model = vision ? VISION_MODEL : (models[0] || MODEL);
797
+ const msgs = vision ? messages : stripImages(messages);
798
+ const budget = maxTokens || MAX_TOKENS;
799
+ const body = {
800
+ model,
801
+ max_tokens: budget,
802
+ messages: withModelId(msgs, model),
803
+ plugins: [{ id: 'web' }],
804
+ stream: true,
805
+ race: n,
806
+ race_need: want,
807
+ tier,
808
+ };
809
+
810
+ let lastFail = { model: 'gateway', text: '', error: 'empty body' };
811
+ for (let attempt = 0; attempt < 2; attempt++) {
812
+ try {
813
+ const r = await postChat(body, contextId, 0, onStatus, hooks.signal);
814
+ if (!r.ok || !r.body) {
815
+ const j = await r.json().catch(() => ({}));
816
+ const msg = j?.choices?.[0]?.message;
817
+ const content = msg?.content;
818
+ const proxied = sanitizeProxiedError(j?.error?.message);
819
+ const text = wrapThink(messageReasoning(msg), content || (r.ok ? '' : (proxied ? `(request failed — HTTP ${r.status}: ${proxied})` : await httpErrorNote(r.status))));
820
+ lastFail = { model: 'gateway', text: text || '', error: r.ok ? undefined : `HTTP ${r.status}` };
821
+ if (isRaceCountable(lastFail)) {
822
+ arrivals.push(lastFail);
823
+ done.push(lastFail);
824
+ feed.onBack(lastFail.model);
825
+ if (text) emitReply(onDelta, text);
826
+ break;
827
+ }
828
+ if (!shouldRetryRaceArrival(lastFail) || attempt === 1) break;
829
+ continue;
830
+ }
831
+
832
+ const parsed = await readGatewayRaceStream(r, feed, hooks.signal);
833
+ for (const a of parsed.arrivals) {
834
+ arrivals.push(a);
835
+ if (isRaceCountable(a)) {
836
+ done.push(a);
837
+ feed.onBack(a.model);
838
+ } else {
839
+ feed.onFail(a.model, a);
840
+ }
841
+ }
842
+ lastFail = parsed.arrivals[parsed.arrivals.length - 1] || lastFail;
843
+ if (done.length >= want || !shouldRetryRaceArrival(lastFail) || attempt === 1) break;
844
+ } catch (e) {
845
+ lastFail = { model: 'gateway', text: '', error: e?.message || 'error' };
846
+ arrivals.push(lastFail);
847
+ if (!shouldRetryRaceArrival(lastFail) || attempt === 1) break;
848
+ }
849
+ }
850
+
851
+ const cands = done.slice(0, want);
852
+ if (!cands.length) return ship(raceLastShip(arrivals));
853
+ if (cands.length === 1) return ship(cands[0]);
854
+
855
+ onStatus?.('judging…');
856
+ feed.judge();
857
+ const scored = await Promise.all(cands.map(async (c) => {
858
+ let score = 0;
859
+ try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
860
+ return { ...c, score };
861
+ }));
862
+ let picked = pickRaceWinner(scored, minScore);
863
+ if (picked.reason === 'tie' && picked.tied.length > 1) {
864
+ let broken = null;
865
+ try { broken = await pairwise(messages, picked.tied); } catch { /* last of the tie */ }
866
+ const usable = broken && String(broken.text || '').trim();
867
+ picked = { winner: usable ? broken : picked.tied[picked.tied.length - 1], reason: 'tiebreak', tied: picked.tied };
868
+ }
869
+ return ship(picked.winner || scored[scored.length - 1] || raceLastShip(arrivals));
870
+ }
871
+
872
+ async function readGatewayRaceStream(r, feed, signal) {
873
+ const reader = r.body.getReader();
874
+ const decoder = new TextDecoder();
875
+ let buf = '';
876
+ const texts = new Map();
877
+ const thinks = new Map();
878
+ const finished = new Map();
879
+ const live = { id: null };
880
+ const stopWait = startModelWait(() => {});
881
+
882
+ const pushText = (id, chunk) => {
883
+ if (chunk == null || chunk === '') return;
884
+ const key = id || live.id || 'gateway';
885
+ live.id = key;
886
+ texts.set(key, (texts.get(key) || '') + chunk);
887
+ feed.onToken(key, chunk);
888
+ };
889
+ const pushThink = (id, chunk) => {
890
+ if (chunk == null || chunk === '') return;
891
+ const key = id || live.id || 'gateway';
892
+ live.id = key;
893
+ thinks.set(key, (thinks.get(key) || '') + chunk);
894
+ };
895
+ const finishOne = (id, extra = {}) => {
896
+ const key = id || live.id || 'gateway';
897
+ if (finished.has(key)) return;
898
+ const text = extra.text != null ? String(extra.text) : (texts.get(key) || '');
899
+ const row = {
900
+ model: extra.model || key,
901
+ text: wrapThink(thinks.get(key) || '', text),
902
+ error: extra.error,
903
+ };
904
+ finished.set(key, row);
905
+ };
906
+
907
+ try {
908
+ for (;;) {
909
+ if (signal?.aborted) break;
910
+ let chunk;
911
+ try {
912
+ chunk = await readWithIdleTimeout(reader, STREAM_IDLE_MS);
913
+ } catch (e) {
914
+ if (e?.code !== 'STREAM_IDLE') throw e;
915
+ try { await reader.cancel(); } catch { /* already */ }
916
+ break;
917
+ }
918
+ const { value, done } = chunk;
919
+ if (done) break;
920
+ buf += decoder.decode(value, { stream: true });
921
+ const lines = buf.split('\n');
922
+ buf = lines.pop();
923
+ for (const line of lines) {
924
+ const s = line.trim();
925
+ if (s.startsWith(': race ')) {
926
+ try {
927
+ const ev = JSON.parse(s.slice(7));
928
+ const id = raceRacerId(ev, 'gateway');
929
+ if (ev.text && ev.event !== 'fail') pushText(id, ev.text);
930
+ if (ev.event === 'token' && ev.delta) pushText(id, ev.delta);
931
+ if (ev.event === 'back' || ev.event === 'done' || ev.finish) {
932
+ finishOne(id, { text: ev.text, model: ev.model });
933
+ }
934
+ if (ev.event === 'fail' || ev.error) {
935
+ finishOne(id, { text: ev.text || '', error: ev.error || 'empty body', model: ev.model });
936
+ }
937
+ } catch { /* keep-alive */ }
938
+ continue;
939
+ }
940
+ if (s.startsWith(': x402 ')) continue; // metered by the sidecar; never log
941
+ if (!s.startsWith('data:')) continue;
942
+ const payload = s.slice(5).trim();
943
+ if (payload === '[DONE]') continue;
944
+ try {
945
+ const obj = JSON.parse(payload);
946
+ const ev = raceEventOf(obj);
947
+ const c = obj?.choices?.[0];
948
+ const d = c?.delta;
949
+ const id = raceRacerId(obj, live.id || 'gateway');
950
+ if (d?.content) pushText(id, d.content);
951
+ // Reasoning is not a racer preview. Fold it onto the arrival so
952
+ // the winner's canvas row can show a thinking chip — never dump
953
+ // CoT into the spectator grid.
954
+ const think = messageReasoning(c?.message, d);
955
+ if (think) pushThink(id, think);
956
+ if (c?.finish_reason) finishOne(id, { model: obj.model });
957
+ if (ev?.ev === 'back' || ev?.ev === 'done') finishOne(ev.id, { text: ev.text, model: ev.id });
958
+ if (ev?.ev === 'fail' || ev?.error) finishOne(ev.id, { text: ev.text || '', error: ev.error || 'empty body' });
959
+ const msg = c?.message?.content || obj?.choices?.[0]?.message?.content;
960
+ if (msg && (c?.finish_reason || obj.object === 'chat.completion')) {
961
+ pushText(id, texts.has(id) ? '' : msg);
962
+ if (!texts.get(id)) texts.set(id, String(msg));
963
+ finishOne(id, { text: texts.get(id) || msg, model: obj.model || id });
964
+ }
965
+ } catch { /* partial JSON */ }
966
+ }
967
+ }
968
+ } finally {
969
+ stopWait();
970
+ try { await reader.cancel(); } catch { /* closed */ }
971
+ }
972
+
973
+ if (!finished.size && texts.size) {
974
+ for (const [id, text] of texts) finishOne(id, { text });
975
+ }
976
+ const arrivals = [...finished.values()];
977
+ if (!arrivals.length) arrivals.push({ model: 'gateway', text: '', error: 'empty body' });
978
+ return { arrivals };
979
+ }
980
+
981
+ /**
982
+ * Launch N models at once, judge the FIRST K that come back.
983
+ *
984
+ * "/race 2 3" — start three, and the moment two of them have returned a real
985
+ * answer, judge those two and ship the winner. The third is abandoned mid-flight.
986
+ *
987
+ * This is the useful shape, and it is neither of the obvious two:
988
+ * - first-past-the-post (K=1) optimises latency only, and on a hard question
989
+ * it rewards whichever model thought LEAST.
990
+ * - wait-for-all-then-judge (K=N) buys quality with the slowest entrant's
991
+ * latency, and one wedged provider stalls the whole turn.
992
+ * Taking the first K bounds the wait at the Kth-fastest while still giving the
993
+ * judge something to compare. The straggler is exactly the entrant you were
994
+ * least likely to want anyway.
995
+ *
996
+ * Reliability comes free with it: empty completions and provider 5xx are
997
+ * per-model and uncorrelated, which is why "the model returned nothing 4 times"
998
+ * was never fixable by a fourth try at the same model. An empty reply does NOT
999
+ * count toward K — otherwise the fastest model to FAIL would decide the race,
1000
+ * the exact bug this exists to fix.
1001
+ *
1002
+ * Live tokens: the fastest still-alive entrant is forwarded into onDelta as
1003
+ * they arrive (not swallowed). When a winner is picked, if it is that live
1004
+ * stream the bubble keeps going; if it is someone else the bubble is replaced
1005
+ * once (`onDelta(text, { replace: true })`), not left on mute dots.
1006
+ *
1007
+ * After the first X land, a cheap classify call scores each of those X
1008
+ * (correctness, completeness, actually did the asked thing — a RUN:/DONE:
1009
+ * directive is success, not a flaw). Highest score that clears the bar wins;
1010
+ * a tie is pairwise-broken. If nobody clears, the last of the X is shipped
1011
+ * anyway. If X never fills (every entrant empty/5xx/fetch-failed), ship a
1012
+ * race-level error — never a single model's `(name failed: fetch failed)` as
1013
+ * if it won. Never blank, never hang.
1014
+ *
1015
+ * Every entrant is paid for, including the abandoned one — this trades money
1016
+ * for latency and quality, which is why it is opt-in and capped.
1017
+ * grokui does not grant unused or failed racers back to the user.
1018
+ *
1019
+ * `hooks` is for tests: `{ stream, classify, pairwise, minScore }`.
1020
+ */
1021
+ export async function brainRace(messages, onDelta, contextId, models, need = 1, maxTokens, onStatus, hooks = {}) {
1022
+ const stream = hooks.stream || brainStream;
1023
+ const classify = hooks.classify || classifyRaceAnswer;
1024
+ const pairwise = hooks.pairwise || pairwiseTied;
1025
+ const minScore = hooks.minScore != null ? Number(hooks.minScore) : RACE_MIN_SCORE;
1026
+ let list = (models || []).filter(Boolean).slice(0, RACE_MAX);
1027
+ const budget = await raceBudget(hooks);
1028
+ let tier = hooks.tier || inferRaceTier(list, 'medium');
1029
+ let wantNeed = Math.max(1, Math.min(Number(need) || 1, list.length || 1));
1030
+ const dollarX = sessionDollarX(budget);
1031
+ const hud = recutRaceByHud({
1032
+ y: list.length, need: wantNeed, dollarX, tier,
1033
+ });
1034
+ let raceRecut = '';
1035
+ if (hud.recut) {
1036
+ raceRecut = 'savings';
1037
+ if (hud.tier !== tier && !hooks.stream) {
1038
+ try {
1039
+ const next = await tierModels(hud.tier, hud.y, true);
1040
+ if (next.length) { list = next; tier = hud.tier; }
1041
+ else list = list.slice(0, hud.y);
1042
+ } catch { list = list.slice(0, hud.y); }
1043
+ } else {
1044
+ list = list.slice(0, hud.y);
1045
+ tier = hud.tier;
1046
+ }
1047
+ wantNeed = Math.min(hud.need, list.length);
1048
+ onStatus?.(hud.y < 2
1049
+ ? 'race recut to 1 — savings'
1050
+ : `race recut to ${hud.y} — savings`);
1051
+ }
1052
+ hooks = { ...hooks, tier, raceRecut };
1053
+ const capped = capRaceByCredit(list.length, budget);
1054
+ if (capped.n < 1) {
1055
+ onStatus?.('race refused — no credit');
1056
+ return RACE_NO_CREDIT;
1057
+ }
1058
+ if (capped.n < list.length) {
1059
+ list = list.slice(0, capped.n);
1060
+ onStatus?.(capped.n < 2 ? 'race shrunk to 1 — credit' : `race shrunk to ${capped.n} — credit`);
1061
+ }
1062
+ if (list.length < 2) return stream(messages, onDelta, contextId, list[0], maxTokens, 0, 0, onStatus);
1063
+ const want = Math.max(1, Math.min(wantNeed, list.length));
1064
+
1065
+ // One Fly settle when the completions door honors `race:`. Custom stream
1066
+ // hooks (unit tests of the N-parallel judge) keep the old path. Old
1067
+ // sidecar / local mock that does not accept race: also stays N-parallel.
1068
+ const customStream = Boolean(hooks.stream);
1069
+ let gateway = hooks.gatewayRace;
1070
+ if (gateway == null && !customStream) {
1071
+ try { gateway = await probeGatewayRace(hooks.proxy || completionsProxy(), hooks.fetch || fetch); }
1072
+ catch { gateway = false; }
1073
+ }
1074
+ if (gateway && !customStream) {
1075
+ return brainGatewayRace(messages, onDelta, contextId, list, want, maxTokens, onStatus, hooks);
1076
+ }
1077
+
1078
+ const feed = createRaceFeed(onDelta, onStatus, want, hooks.onRace);
1079
+ feed.start(list, raceRecut ? { recut: raceRecut } : undefined);
1080
+
1081
+ const done = [];
1082
+ const arrivals = [];
1083
+ let finished = 0;
1084
+ let release;
1085
+ const enough = new Promise((r) => { release = r; });
1086
+ const raceAbort = new AbortController();
1087
+ if (hooks.signal) {
1088
+ if (hooks.signal.aborted) raceAbort.abort(hooks.signal.reason);
1089
+ else hooks.signal.addEventListener('abort', () => raceAbort.abort(), { once: true });
1090
+ }
1091
+
1092
+ const noteRace = (arr) => {
1093
+ try {
1094
+ hooks.onArrivals?.(arr);
1095
+ const line = JSON.stringify({
1096
+ at: new Date().toISOString(),
1097
+ fail: summarizeRaceFailures(arr),
1098
+ n: arr.length,
1099
+ kinds: arr.map((a) => raceFailKind(a)),
1100
+ });
1101
+ appendFileSync(`${homedir()}/.openzoo/grokui-race.log`, line + '\n');
1102
+ } catch { /* diagnostic only */ }
1103
+ };
1104
+
1105
+ const ship = (cand) => {
1106
+ const out = cand && String(cand.text || '').trim() ? cand : raceLastShip(arrivals);
1107
+ feed.settle(out);
1108
+ noteRace(arrivals);
1109
+ try { raceAbort.abort(); } catch { /* already */ }
1110
+ return out.text;
1111
+ };
1112
+
1113
+ // Do not pass onStatus into each entrant — their "waiting on model…" would
1114
+ // clobber the race line. Race owns the status until a winner ships.
1115
+ // fetch-failed / empty / 5xx are retried ONCE on the same slot — tetris was
1116
+ // shipping every-model-failed after a single ~22s fetch failed per racer.
1117
+ const runOne = async (m) => {
1118
+ let last = { model: m, text: '', error: 'empty body' };
1119
+ for (let attempt = 0; attempt < 2; attempt++) {
1120
+ if (raceAbort.signal.aborted && attempt > 0) break;
1121
+ try {
1122
+ const text = await stream(messages, (chunk, meta) => {
1123
+ if (meta?.think) return;
1124
+ feed.onToken(m, chunk);
1125
+ }, contextId, m, maxTokens, 0, 0, undefined, raceAbort.signal);
1126
+ last = { model: m, text: text == null ? '' : String(text) };
1127
+ if (isRaceCountable(last)) {
1128
+ arrivals.push(last);
1129
+ done.push(last);
1130
+ feed.onBack(m);
1131
+ return;
1132
+ }
1133
+ } catch (e) {
1134
+ last = { model: m, text: '', error: e?.message || 'error' };
1135
+ }
1136
+ if (!shouldRetryRaceArrival(last) || attempt === 1 || raceAbort.signal.aborted) break;
1137
+ }
1138
+ arrivals.push(last);
1139
+ feed.onFail(m, last);
1140
+ };
1141
+
1142
+ const attempts = list.map((m) => runOne(m).finally(() => {
1143
+ finished += 1;
1144
+ // Either we have what we asked for, or everyone is done and no more is
1145
+ // coming — without the second condition a race where two of three fail
1146
+ // would hang forever waiting for a K that can never arrive.
1147
+ if (done.length >= want || finished === list.length) release();
1148
+ }));
1149
+ // Losers keep running until ship() aborts them; swallow rejections so one
1150
+ // cannot take the process down after the winner has already been returned.
1151
+ for (const p of attempts) p.catch(() => {});
1152
+
1153
+ await enough;
1154
+ // Completion order, so this really is the first X back — not the first X
1155
+ // launched. A slow 3rd never enters this set. Empty/5xx/fetch-failed stay
1156
+ // in arrivals only so an all-fail race can ship one race-level error.
1157
+ const cands = done.slice(0, want);
1158
+ if (!cands.length) return ship(raceLastShip(arrivals));
1159
+ // One real answer — nothing to compare. Ship it; do not spend a classify
1160
+ // call to rubber-stamp the only candidate.
1161
+ if (cands.length === 1) return ship(cands[0]);
1162
+
1163
+ onStatus?.('judging…');
1164
+ feed.judge();
1165
+ const scored = await Promise.all(cands.map(async (c) => {
1166
+ let score = 0;
1167
+ try { score = Number(await classify(messages, c)) || 0; } catch { score = 0; }
1168
+ return { ...c, score };
1169
+ }));
1170
+
1171
+ let picked = pickRaceWinner(scored, minScore);
1172
+ if (picked.reason === 'tie' && picked.tied.length > 1) {
1173
+ let broken = null;
1174
+ try { broken = await pairwise(messages, picked.tied); } catch { /* last of the tie */ }
1175
+ const usable = broken && String(broken.text || '').trim();
1176
+ // Malformed verdict / all equally bad → last finished of the tie, not empty.
1177
+ picked = { winner: usable ? broken : picked.tied[picked.tied.length - 1], reason: 'tiebreak', tied: picked.tied };
1178
+ }
1179
+ return ship(picked.winner || scored[scored.length - 1] || raceLastShip(arrivals));
1180
+ }
1181
+
1182
+ function raceQuestion(messages) {
1183
+ const asked = [...messages].reverse().find((m) => m.role === 'user')?.content;
1184
+ return typeof asked === 'string' ? asked : '(see candidates)';
1185
+ }
1186
+
1187
+ /**
1188
+ * Cheap structured score of ONE finished answer vs the question.
1189
+ * This is grokui's own classify call — not OpenRouter's async log-tag
1190
+ * Classifiers beta, which does not pick winners.
1191
+ */
1192
+ async function classifyRaceAnswer(messages, cand) {
1193
+ const prompt = 'Score this answer to one question from 0 to 10.\n\n'
1194
+ + 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
1195
+ + 'ANSWER:\n' + stripThinkTags(String(cand?.text || '')).slice(0, 6000) + '\n\n'
1196
+ + 'Judge on: correctness first, then completeness, then whether it actually did what was asked '
1197
+ + '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
1198
+ + 'Ignore length and confidence of tone.\n'
1199
+ + 'Reply with exactly: SCORE <n>';
1200
+ const verdict = await brainStream(
1201
+ [{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 24,
1202
+ );
1203
+ return parseClassifyScore(verdict);
1204
+ }
1205
+
1206
+ /**
1207
+ * Pairwise break among same-score passers. Blind A/B/C so the model names
1208
+ * cannot leak the answer. Last of the tied set if the call dies.
1209
+ */
1210
+ async function pairwiseTied(messages, tied) {
1211
+ const letters = tied.map((_, i) => String.fromCharCode(65 + i));
1212
+ const prompt = 'You are judging answers to one question. Pick the single best one.\n\n'
1213
+ + 'QUESTION:\n' + String(raceQuestion(messages)).slice(0, 4000) + '\n\n'
1214
+ + tied.map((c, i) => 'ANSWER ' + letters[i] + ':\n' + stripThinkTags(String(c.text || '')).slice(0, 6000)).join('\n\n')
1215
+ + '\n\nJudge on: correctness first, then completeness, then whether it actually did what was asked '
1216
+ + '(a directive like RUN: or DONE: on one line is the correct format here, not a flaw). '
1217
+ + 'Ignore length and confidence of tone.\n'
1218
+ + 'Reply with ONE letter and nothing else: ' + letters.join(' or ') + '.';
1219
+ try {
1220
+ const verdict = await brainStream([{ role: 'user', content: prompt }], () => {}, undefined, JUDGE_MODEL, 8);
1221
+ const hit = String(verdict).toUpperCase().split('').find((ch) => {
1222
+ const n = ch.charCodeAt(0) - 65;
1223
+ return n >= 0 && n < tied.length;
1224
+ });
1225
+ if (hit) return tied[hit.charCodeAt(0) - 65];
1226
+ } catch { /* fall through */ }
1227
+ return tied[tied.length - 1];
1228
+ }
1229
+
1230
+ const RACE_MAX = Number(process.env.OZ_RACE_MAX || 4);
1231
+
1232
+ // brainBest is GONE — brainRace(models, need) subsumes it. "wait for all N
1233
+ // then judge" is exactly need === N, and keeping a second judged-race entry
1234
+ // point meant two call sites that could disagree about what a race is.
1235
+
1236
+ // Cheapest thing that can reliably output one letter. Overridable because the
1237
+ // catalog moves; if it is delisted the try/catch above falls back cleanly.
1238
+ const JUDGE_MODEL = process.env.OZ_JUDGE_MODEL || 'deepseek/deepseek-v4-flash';
1239
+
1240
+ const SYSTEM = `You are the brain of a Grok-Bot-style coding/ops agent. The polished chat UI
1241
+ the user sees is Grok Bot (Anysphere's app); its "sandbox" has been pointed at THIS box, and
1242
+ your reasoning is served by openzoo (pay-per-call access to ~435 models over x402 — no API key,
1243
+ no account, each call paid from a burner wallet). You are the substitute agent server.
1244
+
1245
+ WHERE YOUR COMMANDS RUN: each shell command you emit is pushed to a local-exec daemon running on
1246
+ the USER'S OWN Mac (a supervised "sand" sandbox rooted at their home dir). Every command is
1247
+ APPROVAL-GATED — the user sees and approves it before it runs. So: act on the user's real machine,
1248
+ be careful, never destructive, prefer read-before-write, and explain nothing to the shell.
1249
+
1250
+ PROTOCOL — reply with EXACTLY one line, no prose, no code fences:
1251
+ RUN: <a single shell command> to execute a step
1252
+ DONE: <a short natural-language answer> when the task is complete (this text is shown in the UI)
1253
+ SPAWN: <name> | <task> to delegate an independent subtask to a fresh subagent
1254
+ SEND: <name> | <message> to message a subagent you (or the user) already spawned
1255
+ PING: <name> to check whether a subagent is still working or has finished
1256
+ You are given each command's output before your next line. SPAWN does not block you — the subagent
1257
+ runs independently and you continue your own reasoning immediately; check on it later with PING or
1258
+ wait for it to SEND you its result. All subagents share the ONE real exec channel (the user's Mac),
1259
+ so their shell commands queue safely behind each other automatically — you never need to worry about
1260
+ that part. If the task needs no shell (a question, an explanation), answer it directly with a single
1261
+ DONE: line. Keep DONE summaries human and useful — they are the assistant's reply to the user, not a
1262
+ log.`;
1263
+
1264
+ // ---------------------------------------------------------------- subagents --
1265
+
1266
+ // name -> { mailbox: string[], done: boolean, result: string|null }. In-process
1267
+ // registry — every agent (root task + SPAWNed subagents) shares the ONE real
1268
+ // exec channel (queueDrive), but reason (call the brain) independently and
1269
+ // concurrently, so SPAWN genuinely doesn't block the spawning agent.
1270
+ const agents = new Map();
1271
+
1272
+ function ensureAgent(name) {
1273
+ if (!agents.has(name)) agents.set(name, { mailbox: [], done: false, result: null });
1274
+ return agents.get(name);
1275
+ }
1276
+
1277
+ /** The agent loop for one task, executed through the connected daemon. Each
1278
+ * RUN is pushed as an exec frame; the daemon's result frames (captured in
1279
+ * `pendingResults`) feed the next turn. */
1280
+ async function runTask(task, stream, ctx) {
1281
+ const messages = [{ role: 'system', content: SYSTEM }, { role: 'user', content: task }];
1282
+ let answer = '';
1283
+ for (let step = 0; step < MAX_STEPS; step++) {
1284
+ const line = (await brain(messages)).trim();
1285
+ record({ ev: 'brain', step, line });
1286
+ const done = /^DONE:\s*([\s\S]+)/.exec(line);
1287
+ if (done) { answer = done[1].trim(); record({ ev: 'task-done', step, answer }); return answer; }
1288
+ const run = /^RUN:\s*([\s\S]+)/.exec(line);
1289
+ // no RUN and no DONE — treat the whole line as a direct answer to the user
1290
+ if (!run) { answer = line.replace(/^DONE:\s*/i, ''); record({ ev: 'task-freeform', step, answer }); return answer; }
1291
+ const cmd = run[1].trim();
1292
+ const frame = execFrame(cmd);
1293
+ ctx.awaiting = frame.requestId;
1294
+ ctx.output = '';
1295
+ ctx.done = false;
1296
+ sseSend(stream, frame);
1297
+ record({ ev: 'exec-push', step, requestId: frame.requestId, cmd });
1298
+ // wait for the daemon's result frames for this requestId (or timeout)
1299
+ const out = await waitResult(ctx, 60_000);
1300
+ messages.push({ role: 'assistant', content: line });
1301
+ messages.push({ role: 'user', content: `output:\n${out || '(none)'}` });
1302
+ }
1303
+ return answer || '(reached step limit without finishing)';
1304
+ }
1305
+
1306
+ function waitResult(ctx, timeoutMs) {
1307
+ return new Promise((resolve) => {
1308
+ const started = Date.now();
1309
+ const iv = setInterval(() => {
1310
+ if (ctx.done || Date.now() - started > timeoutMs) {
1311
+ clearInterval(iv); ctx.done = false; resolve(ctx.output || '');
1312
+ }
1313
+ }, 250);
1314
+ });
1315
+ }
1316
+
1317
+ // ----------------------------------------------------------------- servers --
1318
+
1319
+ // per-connection context so a result frame can be matched to its exec
1320
+ const ctxByToken = new Map();
1321
+ // the single live daemon (Grok Bot's local-exec on the user's Mac). Set when it
1322
+ // opens its SSE; the /drive endpoint pushes exec frames down THIS stream.
1323
+ let activeDaemon = null;
1324
+ // one exec loop at a time against the shared daemon ctx — two concurrent
1325
+ // runTask calls would interleave result frames onto the same ctx.output.
1326
+ let driveQueue = Promise.resolve();
1327
+ function queueDrive(fn) {
1328
+ const next = driveQueue.then(fn, fn);
1329
+ driveQueue = next.catch(() => {});
1330
+ return next;
1331
+ }
1332
+
1333
+ for (const port of PORTS) {
1334
+ const server = http.createServer((req, res) => {
1335
+ // The daemon's receive-channel: hold the SSE open. The TASK does NOT come
1336
+ // from here or from any env var — it arrives via POST /drive, forwarded by
1337
+ // the backend from whatever the user typed into the Grok Bot UI.
1338
+ if (req.method === 'GET' && req.url && req.url.startsWith('/local-exec/requests')) {
1339
+ const token = (req.headers['x-anyrun-network-token'] || 'default');
1340
+ const ctx = { awaiting: null, output: '', done: false };
1341
+ ctxByToken.set(token, ctx);
1342
+ activeDaemon = { stream: res, ctx };
1343
+ record({ port, method: 'GET', path: req.url, note: 'SSE opened — daemon connected', bodyBytes: 0 });
1344
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
1345
+ res.write(':ok\n\n');
1346
+ // announce ourselves; then wait for the UI to drive us
1347
+ sseSend(res, { kind: 'welcome', providerId: 'openzoo' });
1348
+ req.on('close', () => {
1349
+ ctxByToken.delete(token);
1350
+ if (activeDaemon && activeDaemon.stream === res) activeDaemon = null;
1351
+ });
1352
+ return;
1353
+ }
1354
+
1355
+ const chunks = [];
1356
+ req.on('data', (d) => chunks.push(d));
1357
+ req.on('end', async () => {
1358
+ const body = Buffer.concat(chunks);
1359
+ let parsed, kinds;
1360
+ try { parsed = JSON.parse(body.toString('utf8')); kinds = parsed.frames?.map((f) => f.kind).join(','); } catch { /* not json */ }
1361
+ record({ port, method: req.method, path: req.url, bodyBytes: body.length, frames: kinds,
1362
+ bodyUtf8: body.slice(0, 1024).toString('utf8').replace(/[^\x20-\x7e]/g, '.') });
1363
+
1364
+ // THE TRIGGER: the backend forwards the user's Grok Bot prompt here. Drive
1365
+ // the agent loop against the connected daemon and return the answer text.
1366
+ if (req.method === 'POST' && req.url && req.url.startsWith('/drive')) {
1367
+ let task = '';
1368
+ try { task = (JSON.parse(body.toString('utf8')).task || '').toString(); }
1369
+ catch { task = body.toString('utf8'); }
1370
+ record({ ev: 'drive', task: task.slice(0, 160) });
1371
+ if (!activeDaemon) {
1372
+ res.writeHead(200, { 'content-type': 'application/json' });
1373
+ res.end(JSON.stringify({ ok: false, text: '(sandbox not connected yet — give the app a moment and retry)' }));
1374
+ return;
1375
+ }
1376
+ let text;
1377
+ try {
1378
+ text = await queueDrive(() => runTask(task, activeDaemon.stream, activeDaemon.ctx));
1379
+ } catch (e) { text = `agent error: ${e.message}`; record({ ev: 'drive-error', err: String(e) }); }
1380
+ res.writeHead(200, { 'content-type': 'application/json' });
1381
+ res.end(JSON.stringify({ ok: true, text }));
1382
+ return;
1383
+ }
1384
+
1385
+ // the daemon posts result frames here — accumulate output for the loop
1386
+ if (parsed?.frames && req.url?.includes('/local-exec/responses')) {
1387
+ const token = (req.headers['x-anyrun-network-token'] || 'default');
1388
+ const ctx = ctxByToken.get(token);
1389
+ for (const f of parsed.frames) {
1390
+ if (ctx && (f.kind === 'output' || f.kind === 'stdout')) ctx.output += (f.data || f.text || f.chunk || '');
1391
+ if (ctx && (f.kind === 'result' || f.kind === 'exit')) ctx.done = true;
1392
+ }
1393
+ }
1394
+
1395
+ // THE REAL TRIGGER. Grok Bot's own chat (StreamUnifiedChat) never reaches
1396
+ // us — inference happens server-side inside whatever pod EnsureSandBox
1397
+ // named, and since we hijacked that to a pod that doesn't exist on
1398
+ // Cursor's side, the UI's chat box just sits silent forever. But the app
1399
+ // ALSO opens this vnc.html URL — OUR box — inside its own sandbox panel.
1400
+ // Serve a real chat page here instead of a stub: the user types inside
1401
+ // Grok Bot's own window, it POSTs straight to /drive on this box.
1402
+ if (req.url && req.url.includes('/vnc')) {
1403
+ res.writeHead(200, { 'content-type': 'text/html' });
1404
+ res.end(VNC_CHAT_HTML);
1405
+ } else {
1406
+ res.writeHead(200, { 'content-type': 'application/json' });
1407
+ res.end('{"ok":true}');
1408
+ }
1409
+ });
1410
+ });
1411
+ server.on('upgrade', (req, socket) => {
1412
+ record({ port, method: 'UPGRADE', path: req.url, bodyBytes: 0 });
1413
+ socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n');
1414
+ });
1415
+ // NEVER FATAL. These are the Grok-Bot-shaped agent ports, a nicety — but an
1416
+ // unhandled 'error' event takes the whole process down, and the process that
1417
+ // dies is the one also serving the CHAT on :4173. A stale instance holding
1418
+ // :1337 therefore black-screened the app: Electron loaded :4173 and got
1419
+ // ERR_CONNECTION_REFUSED, with the real cause (EADDRINUSE on a port nobody
1420
+ // cares about) buried in a log the user never sees.
1421
+ server.on('error', (e) => console.log(`[agent] :${port} unavailable (${e.code}) — continuing without it`));
1422
+ server.listen(port, '0.0.0.0', () => console.log(`[agent] on :${port}`));
1423
+ }
1424
+
1425
+ console.log(`[agent] brain=${MODEL} via ${PROXY} task=${process.env.OZ_TASK ? JSON.stringify(process.env.OZ_TASK).slice(0, 60) : '(none — waiting)'}`);