openzoo 0.50.20 → 0.50.22

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.
@@ -32,7 +32,9 @@ import fs from 'node:fs';
32
32
  import os from 'node:os';
33
33
  import path from 'node:path';
34
34
  import zlib from 'node:zlib';
35
- import { execFileSync } from 'node:child_process';
35
+ import { execFileSync, execFile } from 'node:child_process';
36
+ import { promisify } from 'node:util';
37
+ import tls from 'node:tls';
36
38
  import { randomUUID } from 'node:crypto';
37
39
  import { encodeAvailableModels, encodeForMethod, encodeEnsureSandBox, encodeGetGrokBotSendStatus, decodeProtoFields, unwrapConnect } from './cursorapi.js';
38
40
 
@@ -287,7 +289,34 @@ const RESP_DROP = new Set([
287
289
  ]);
288
290
 
289
291
  const SNIFF_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-sniff.jsonl');
290
- let realPod = null; // { agent, vnc, token, p1340, p6081, region, accountId, podId }
292
+ const POD_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-pod.json');
293
+ function loadPod() {
294
+ try { return JSON.parse(fs.readFileSync(POD_FILE, 'utf8')); } catch { return null; }
295
+ }
296
+ function savePod(p) {
297
+ if (!p?.agent) return;
298
+ try {
299
+ fs.mkdirSync(path.dirname(POD_FILE), { recursive: true });
300
+ fs.writeFileSync(POD_FILE, JSON.stringify(p));
301
+ } catch { /* */ }
302
+ }
303
+ let realPod = loadPod(); // { agent, vnc, token, p1340, ... } — persist so api2 timeout does not UUID-stub the sidebar
304
+ const AGENTS_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-agents.json');
305
+ function loadAgents() {
306
+ try {
307
+ const a = JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf8'));
308
+ return Array.isArray(a) ? a : null;
309
+ } catch { return null; }
310
+ }
311
+ function saveAgents(a) {
312
+ if (!Array.isArray(a) || !a.length) return;
313
+ try { fs.writeFileSync(AGENTS_FILE, JSON.stringify(a)); } catch { /* */ }
314
+ }
315
+ function cachedAgentList() {
316
+ const a = loadAgents();
317
+ if (a?.length) return a;
318
+ return null;
319
+ }
291
320
 
292
321
  function sniffOn() { return process.env.OPENZOO_SNIFF === '1'; }
293
322
  function sniffSelf() { return process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443'; }
@@ -444,6 +473,7 @@ function rememberPod(fields, log) {
444
473
  podId: String(fields[3] || ''),
445
474
  };
446
475
  sniffDump({ kind: 'pod', fields, realPod });
476
+ savePod(realPod);
447
477
  log(`cursor-backend: SNIFF real pod ${realPod.agent}`);
448
478
  return realPod;
449
479
  }
@@ -468,7 +498,7 @@ async function sniffEnsureSandBox(req, res, body, host, full, log) {
468
498
  const upstream = cursorUpstream(host);
469
499
  const headers = copyReqHeaders(req, upstream);
470
500
  const cap = await upstreamUnary({
471
- host: upstream, path: full, method: req.method, headers, body, timeoutMs: 30000,
501
+ host: upstream, path: full, method: req.method, headers, body, timeoutMs: 60000,
472
502
  });
473
503
  const raw = inflateBody(cap.buf, cap.respHeaders);
474
504
  const proto = unwrapConnect(raw);
@@ -562,6 +592,26 @@ async function proxyPodHttp(req, res, full, body, log) {
562
592
  body,
563
593
  timeoutMs: path0 === '/health' ? 8000 : 120000,
564
594
  });
595
+ if (path0 === '/api/listAgents' && cap.status === 200) {
596
+ try {
597
+ const parsed = JSON.parse(String(inflateBody(cap.buf, cap.respHeaders)));
598
+ if (Array.isArray(parsed)) {
599
+ const merged = mergeAgentLists(parsed);
600
+ saveAgents(merged);
601
+ jsonSend(res, merged);
602
+ log(`cursor-backend: listAgents 200 merged n=${merged.length}`);
603
+ return true;
604
+ }
605
+ } catch { /* */ }
606
+ }
607
+ if (cap.status === 401 && path0 === '/api/listAgents') {
608
+ const cached = mergeAgentLists([]);
609
+ if (cached.length) {
610
+ jsonSend(res, cached);
611
+ log(`cursor-backend: listAgents 401 — cached ${cached.length} named agents`);
612
+ return true;
613
+ }
614
+ }
565
615
  writeCaptured(res, cap.status, cap.respHeaders, cap.buf);
566
616
  const rec = {
567
617
  kind: 'pod-http',
@@ -640,8 +690,14 @@ function handleLocalExecFrames(frames, log) {
640
690
  } else if (f.kind === 'file-error' || f.kind === 'messages-error') {
641
691
  w.reject(new Error(f.error || 'local-exec error'));
642
692
  localExecWaiters.delete(f.requestId);
643
- } else if (f.kind === 'client' || f.kind === 'control') {
644
- w.resolve({ kind: f.kind, message: f.message });
693
+ } else if (f.kind === 'client' || f.kind === 'control' || f.kind === 'result' || f.kind === 'exec-result') {
694
+ w.resolve({
695
+ kind: f.kind,
696
+ message: f.message || f.text || f.stdout || JSON.stringify(f),
697
+ stdout: f.stdout,
698
+ stderr: f.stderr,
699
+ exitCode: f.exitCode,
700
+ });
645
701
  localExecWaiters.delete(f.requestId);
646
702
  }
647
703
  }
@@ -670,12 +726,14 @@ async function handleLocalExecHttp(req, res, path0, body, log) {
670
726
  ...CORS,
671
727
  });
672
728
  localExecSse.add(res);
673
- res.write(`data: ${JSON.stringify({ kind: 'welcome', providerId: 'openzoo' })}\n\n`);
729
+ // Daemon parser (asar CNt) ignores unknown welcome; a comment ping is enough
730
+ // to keep the stream alive until it POSTs hello to /responses.
731
+ res.write(': openzoo local-exec\n\n');
674
732
  const iv = setInterval(() => {
675
733
  try { res.write(': ping\n\n'); } catch { clearInterval(iv); localExecSse.delete(res); }
676
- }, 15000);
677
- req.on('close', () => { clearInterval(iv); localExecSse.delete(res); });
678
- log('cursor-backend: -> local-exec /requests sse');
734
+ }, 10000);
735
+ req.on('close', () => { clearInterval(iv); localExecSse.delete(res); log('cursor-backend: local-exec sse closed'); });
736
+ log(`cursor-backend: -> local-exec /requests sse n=${localExecSse.size}`);
679
737
  return true;
680
738
  }
681
739
  if (/\/responses$/.test(path0)) {
@@ -683,7 +741,7 @@ async function handleLocalExecHttp(req, res, path0, body, log) {
683
741
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
684
742
  handleLocalExecFrames(parsed.frames, log);
685
743
  jsonSend(res, { ok: true });
686
- log(`cursor-backend: -> local-exec /responses n=${(parsed.frames || []).length}`);
744
+ log(`cursor-backend: -> local-exec /responses n=${(parsed.frames || []).length} kinds=${(parsed.frames || []).map((f) => f?.kind).join(',')}`);
687
745
  return true;
688
746
  }
689
747
  jsonSend(res, { ok: true });
@@ -754,10 +812,45 @@ function appendLine(agentId, role, text, extra = {}) {
754
812
  return e;
755
813
  }
756
814
  function fanoutLine(primaryId, role, text, extra = {}) {
757
- const ids = new Set([primaryId, ...tailedAgents]);
758
- let last = null;
759
- for (const id of ids) last = appendLine(id, role, text, extra);
760
- return last;
815
+ // Only the addressed agent. Writing to every tailedAgents id mixed canvases
816
+ // so a new/empty chat showed someone else's thread.
817
+ return appendLine(primaryId, role, text, extra);
818
+ }
819
+ function mintLocalAgent(parsed = {}) {
820
+ const id = String(parsed.id || randomUUID());
821
+ const name = String(parsed.name || parsed.title || 'new chat');
822
+ const agent = {
823
+ id,
824
+ name,
825
+ description: String(parsed.description || ''),
826
+ title: String(parsed.title || name),
827
+ origin: parsed.origin || 'user',
828
+ createdAt: Date.now(),
829
+ updatedAt: Date.now(),
830
+ avatarShape: parsed.avatarShape || null,
831
+ avatarColor: parsed.avatarColor || null,
832
+ path: `/local/${id}`,
833
+ };
834
+ const list = cachedAgentList() || [];
835
+ if (!list.some((a) => a.id === id)) list.unshift(agent);
836
+ else {
837
+ const i = list.findIndex((a) => a.id === id);
838
+ list[i] = { ...list[i], ...agent };
839
+ }
840
+ saveAgents(list);
841
+ agentTranscript(id);
842
+ return agent;
843
+ }
844
+ function mergeAgentLists(remote) {
845
+ const local = cachedAgentList() || [];
846
+ const seen = new Set();
847
+ const out = [];
848
+ for (const a of [...local, ...(Array.isArray(remote) ? remote : [])]) {
849
+ if (!a?.id || seen.has(a.id)) continue;
850
+ seen.add(a.id);
851
+ out.push(a);
852
+ }
853
+ return out;
761
854
  }
762
855
  function gatewayEntry(e) {
763
856
  const { seq, ...rest } = e;
@@ -832,7 +925,19 @@ async function zooSpendOverlay(data) {
832
925
  return lines.join('\n');
833
926
  }
834
927
 
835
- const agentModels = new Map();
928
+ const MODELS_PATH = path.join(os.homedir(), '.openzoo', 'grokbot-models.json');
929
+ function loadAgentModels() {
930
+ try {
931
+ return new Map(Object.entries(JSON.parse(fs.readFileSync(MODELS_PATH, 'utf8'))));
932
+ } catch { return new Map(); }
933
+ }
934
+ function saveAgentModels() {
935
+ try {
936
+ fs.mkdirSync(path.dirname(MODELS_PATH), { recursive: true });
937
+ fs.writeFileSync(MODELS_PATH, JSON.stringify(Object.fromEntries(agentModels)));
938
+ } catch { /* */ }
939
+ }
940
+ const agentModels = loadAgentModels();
836
941
  const MODEL_ALIASES = {
837
942
  fable: 'anthropic/claude-fable-5',
838
943
  'fable-5': 'anthropic/claude-fable-5',
@@ -852,65 +957,300 @@ function resolveModelId(raw) {
852
957
  if (s.includes('/')) return s;
853
958
  return null;
854
959
  }
855
-
856
- async function zooComplete(prompt, log, agentId) {
857
- log(`cursor-backend: zoo POST :8402 ${JSON.stringify((prompt || '').slice(0, 60))}`);
858
- const model = agentModels.get(agentId)
960
+ function currentModel(agentId) {
961
+ return agentModels.get(agentId)
859
962
  || process.env.OPENZOO_DEFAULT_MODEL
860
- || 'anthropic/claude-opus-5';
861
- const attachments = [];
862
- for (const raw of extractLocalPaths(prompt)) {
963
+ || 'x-ai/grok-4.6';
964
+ }
965
+
966
+ const execFileAsync = promisify(execFile);
967
+ const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|heic|svg)$/i;
968
+ const IMAGE_MAGIC = [
969
+ [Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'image/png'],
970
+ [Buffer.from([0xff, 0xd8, 0xff]), 'image/jpeg'],
971
+ [Buffer.from('GIF8'), 'image/gif'],
972
+ [Buffer.from('RIFF'), 'image/webp'],
973
+ ];
974
+ function mimeFromBytes(buf, p = '') {
975
+ if (IMAGE_EXT.test(p)) {
976
+ const ext = path.extname(p).toLowerCase();
977
+ return { 'png': 'image/png', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', '.heic': 'image/heic', '.svg': 'image/svg+xml' }[ext] || 'image/png';
978
+ }
979
+ for (const [magic, mime] of IMAGE_MAGIC) {
980
+ if (buf.length >= magic.length && buf.subarray(0, magic.length).equals(magic)) return mime;
981
+ }
982
+ return null;
983
+ }
984
+ function dataUrlsFromRichText(rt) {
985
+ const s = String(rt || '');
986
+ const out = [];
987
+ const re = /data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/]+=*/g;
988
+ let m;
989
+ while ((m = re.exec(s))) out.push(m[0]);
990
+ return out;
991
+ }
992
+ function attachmentList(parsed, prompt) {
993
+ const out = [];
994
+ const seen = new Set();
995
+ const add = (raw, name) => {
996
+ if (!raw || seen.has(raw)) return;
997
+ seen.add(raw);
998
+ out.push({ raw, name });
999
+ };
1000
+ const paths = parsed?.attachmentPaths;
1001
+ const names = parsed?.attachmentNames;
1002
+ if (Array.isArray(paths)) {
1003
+ for (let i = 0; i < paths.length; i++) add(String(paths[i]), names?.[i]);
1004
+ }
1005
+ for (const p of extractLocalPaths(prompt)) {
1006
+ if (/[*?]/.test(p)) continue;
1007
+ add(p);
1008
+ }
1009
+ const rt = String(parsed?.richText || '');
1010
+ const srcRe = /(?:src|path|filePath|url)"?\s*[:=]\s*"((?:file:\/\/|\/(?:var|tmp|private|Users)|~\/)[^"]+\.(?:png|jpe?g|gif|webp|bmp|heic))"/gi;
1011
+ let sm;
1012
+ while ((sm = srcRe.exec(rt))) {
1013
+ add(sm[1].replace(/^file:\/\//, ''));
1014
+ }
1015
+ return out;
1016
+ }
1017
+
1018
+ async function readLocalBytes(abs, log) {
1019
+ if (localExecSse.size > 0) {
1020
+ log(`cursor-backend: local-exec download ${abs}`);
1021
+ const got = await localExecAsk({ kind: 'download', path: abs });
1022
+ return got.bytes || Buffer.from(got.text || '', 'utf8');
1023
+ }
1024
+ return fs.readFileSync(abs);
1025
+ }
1026
+ async function writeLocalBytes(abs, bytes, log) {
1027
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes ?? ''), 'utf8');
1028
+ if (localExecSse.size > 0) {
1029
+ log(`cursor-backend: local-exec upload ${abs} ${buf.length}b`);
1030
+ await localExecAsk({
1031
+ kind: 'upload',
1032
+ path: abs,
1033
+ bytesBase64: buf.toString('base64'),
1034
+ });
1035
+ return `wrote ${abs} (${buf.length} bytes) via local-exec`;
1036
+ }
1037
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1038
+ fs.writeFileSync(abs, buf);
1039
+ return `wrote ${abs} (${buf.length} bytes)`;
1040
+ }
1041
+ async function execLocal(command, cwd, log) {
1042
+ const dir = cwd ? expandUserPath(cwd) : os.homedir();
1043
+ if (localExecSse.size > 0) {
1044
+ log(`cursor-backend: local-exec exec ${JSON.stringify(command).slice(0, 80)}`);
1045
+ const got = await localExecAsk({
1046
+ kind: 'exec',
1047
+ serverMessage: { command, cwd: dir, workingDirectory: dir },
1048
+ }, 60000);
1049
+ const out = got.stdout || got.message || '';
1050
+ const err = got.stderr ? `\nstderr:\n${got.stderr}` : '';
1051
+ return `${out}${err}`.trim() || `(exit ${got.exitCode ?? 0})`;
1052
+ }
1053
+ const { stdout, stderr } = await execFileAsync('/bin/zsh', ['-lc', command], {
1054
+ cwd: dir,
1055
+ timeout: 30000,
1056
+ maxBuffer: 2 * 1024 * 1024,
1057
+ env: process.env,
1058
+ });
1059
+ return `${stdout || ''}${stderr ? `\nstderr:\n${stderr}` : ''}`.trim() || '(no output)';
1060
+ }
1061
+
1062
+ const LOCAL_TOOLS = [
1063
+ {
1064
+ type: 'function',
1065
+ function: {
1066
+ name: 'read_file',
1067
+ description: 'Read a file on the user\'s Mac. Use this for any local path.',
1068
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
1069
+ },
1070
+ },
1071
+ {
1072
+ type: 'function',
1073
+ function: {
1074
+ name: 'write_file',
1075
+ description: 'Write a file on the user\'s Mac. Put HTML/games/code on disk — do not dump huge files in chat.',
1076
+ parameters: {
1077
+ type: 'object',
1078
+ properties: { path: { type: 'string' }, content: { type: 'string' } },
1079
+ required: ['path', 'content'],
1080
+ },
1081
+ },
1082
+ },
1083
+ {
1084
+ type: 'function',
1085
+ function: {
1086
+ name: 'exec',
1087
+ description: 'Run a shell command on the user\'s Mac (zsh -lc). Use for npm, curl, ls, git, installing MCP, etc.',
1088
+ parameters: {
1089
+ type: 'object',
1090
+ properties: { command: { type: 'string' }, cwd: { type: 'string' } },
1091
+ required: ['command'],
1092
+ },
1093
+ },
1094
+ },
1095
+ {
1096
+ type: 'function',
1097
+ function: {
1098
+ name: 'list_dir',
1099
+ description: 'List a directory on the user\'s Mac.',
1100
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
1101
+ },
1102
+ },
1103
+ ];
1104
+
1105
+ async function runLocalTool(name, args, log) {
1106
+ try {
1107
+ if (name === 'read_file') {
1108
+ const buf = await readLocalBytes(expandUserPath(args.path), log);
1109
+ if (buf.length > 180000) return `file ${args.path} is ${buf.length} bytes; first 180000:\n${buf.subarray(0, 180000).toString('utf8')}`;
1110
+ return buf.toString('utf8');
1111
+ }
1112
+ if (name === 'write_file') {
1113
+ return await writeLocalBytes(expandUserPath(args.path), args.content ?? '', log);
1114
+ }
1115
+ if (name === 'list_dir') {
1116
+ const abs = expandUserPath(args.path || os.homedir());
1117
+ if (localExecSse.size > 0) {
1118
+ const got = await localExecAsk({ kind: 'exec', serverMessage: { command: `ls -la ${JSON.stringify(abs)}`, cwd: os.homedir() } });
1119
+ return got.stdout || got.message || '';
1120
+ }
1121
+ return fs.readdirSync(abs, { withFileTypes: true })
1122
+ .map((e) => `${e.isDirectory() ? 'd' : '-'} ${e.name}`)
1123
+ .join('\n');
1124
+ }
1125
+ if (name === 'exec') {
1126
+ return await execLocal(String(args.command || ''), args.cwd, log);
1127
+ }
1128
+ return `unknown tool ${name}`;
1129
+ } catch (e) {
1130
+ return `ERROR ${e.message}`;
1131
+ }
1132
+ }
1133
+
1134
+ function zooTextFromMessage(msg, data) {
1135
+ let c = msg?.content;
1136
+ if (Array.isArray(c)) {
1137
+ c = c.map((p) => (typeof p === 'string' ? p : (p?.text || p?.content || ''))).join('');
1138
+ }
1139
+ if (typeof c === 'string' && c.trim()) return c;
1140
+ if (typeof data?.error?.message === 'string' && data.error.message) return data.error.message;
1141
+ return '';
1142
+ }
1143
+
1144
+ async function zooComplete(prompt, log, agentId, parsed = {}) {
1145
+ const model = currentModel(agentId);
1146
+ const helper = localExecSse.size > 0;
1147
+ log(`cursor-backend: zoo POST :8402 model=${model} helper=${helper ? localExecSse.size : 0} ${JSON.stringify((prompt || '').slice(0, 60))}`);
1148
+
1149
+ const images = [];
1150
+ const textFiles = [];
1151
+ for (const { raw, name } of attachmentList(parsed, prompt)) {
863
1152
  const abs = expandUserPath(raw);
864
1153
  try {
865
- if (localExecSse.size > 0) {
866
- log(`cursor-backend: local-exec download ${abs}`);
867
- const got = await localExecAsk({ kind: 'download', path: abs });
868
- attachments.push({ path: raw, abs, text: got.text || '' });
1154
+ const buf = await readLocalBytes(abs, log);
1155
+ const mime = mimeFromBytes(buf, name || raw);
1156
+ if (mime) {
1157
+ images.push({ path: raw, mime, dataUrl: `data:${mime};base64,${buf.toString('base64')}` });
1158
+ log(`cursor-backend: attached image ${raw} ${mime} ${buf.length}b`);
869
1159
  } else {
870
- const text = fs.readFileSync(abs, 'utf8');
871
- attachments.push({ path: raw, abs, text });
1160
+ textFiles.push({ path: raw, abs, text: buf.toString('utf8') });
872
1161
  }
873
1162
  } catch (e) {
874
- attachments.push({ path: raw, abs, error: e.message });
1163
+ textFiles.push({ path: raw, abs, error: e.message });
875
1164
  }
876
1165
  }
877
- const messages = [];
878
- if (attachments.length) {
879
- const bits = attachments.map((a) => (
1166
+ for (const url of dataUrlsFromRichText(parsed.richText)) {
1167
+ images.push({ path: '(richText)', mime: 'image', dataUrl: url });
1168
+ }
1169
+
1170
+ const via = helper ? 'Grok Bot Helper local-exec SSE' : 'this Mac (hijack process; Helper SSE not connected yet)';
1171
+ const messages = [
1172
+ {
1173
+ role: 'system',
1174
+ content: [
1175
+ `You are ${model} served through openzoo inside Grok Bot.`,
1176
+ `You HAVE local tools on the user's computer via ${via}.`,
1177
+ 'Tools: read_file, write_file, exec, list_dir. USE THEM. Write files to disk instead of pasting giant HTML into chat.',
1178
+ 'Pasted images arrive as attachments — you can see them when present. Do not claim you cannot see images if they are in this turn.',
1179
+ 'Never claim you lack filesystem access or local-exec. If a tool errors, report the error.',
1180
+ 'A spend footer is appended after your reply by the host — ignore it.',
1181
+ ].join(' '),
1182
+ },
1183
+ ];
1184
+ if (textFiles.length) {
1185
+ const bits = textFiles.map((a) => (
880
1186
  a.error
881
1187
  ? `FILE ${a.path} ERROR: ${a.error}`
882
1188
  : `FILE ${a.path} (${a.abs})\n${String(a.text).slice(0, 180000)}`
883
1189
  ));
884
- messages.push({
885
- role: 'system',
886
- content: 'Local files below were fetched via Grok Bot local-exec (the user\'s computer). You CAN review them. Do not claim you lack filesystem access.',
887
- });
888
1190
  messages.push({ role: 'user', content: bits.join('\n\n') });
889
1191
  }
890
- messages.push({ role: 'user', content: prompt || 'hello' });
891
- const payload = {
892
- model,
893
- messages,
894
- max_tokens: Number(process.env.OPENZOO_ASK_MAX_TOKENS || 2048),
895
- };
896
- const post = () => fetch('http://127.0.0.1:8402/v1/chat/completions', {
897
- method: 'POST',
898
- headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
899
- body: JSON.stringify(payload),
900
- signal: AbortSignal.timeout(120000),
1192
+
1193
+ const userContent = [];
1194
+ for (const img of images) {
1195
+ userContent.push({ type: 'image_url', image_url: { url: img.dataUrl } });
1196
+ }
1197
+ userContent.push({ type: 'text', text: prompt || (images.length ? '(see attached image)' : 'hello') });
1198
+ messages.push({
1199
+ role: 'user',
1200
+ content: userContent.length === 1 && userContent[0].type === 'text'
1201
+ ? userContent[0].text
1202
+ : userContent,
901
1203
  });
902
- let r = await post();
903
- // x402 dwell: proxy usually pays internally; a leftover 402 is retryable.
904
- if (r.status === 402) {
905
- log('cursor-backend: x402 402 dwell/retry');
906
- await new Promise((ok) => setTimeout(ok, 2500));
907
- r = await post();
1204
+
1205
+ const maxTok = Number(process.env.OPENZOO_ASK_MAX_TOKENS || 4096);
1206
+ let lastData = {};
1207
+ let text = '';
1208
+ for (let step = 0; step < 8; step++) {
1209
+ const payload = {
1210
+ model,
1211
+ messages,
1212
+ tools: LOCAL_TOOLS,
1213
+ tool_choice: 'auto',
1214
+ max_tokens: maxTok,
1215
+ };
1216
+ const post = () => fetch('http://127.0.0.1:8402/v1/chat/completions', {
1217
+ method: 'POST',
1218
+ headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
1219
+ body: JSON.stringify(payload),
1220
+ signal: AbortSignal.timeout(120000),
1221
+ });
1222
+ let r = await post();
1223
+ if (r.status === 402) {
1224
+ log('cursor-backend: x402 402 — dwell/retry');
1225
+ await new Promise((ok) => setTimeout(ok, 2500));
1226
+ r = await post();
1227
+ }
1228
+ const data = await r.json();
1229
+ lastData = data;
1230
+ const msg = data.choices?.[0]?.message || {};
1231
+ const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
1232
+ if (calls.length) {
1233
+ log(`cursor-backend: zoo tools step=${step} n=${calls.length} ${calls.map((c) => c.function?.name || c.name).join(',')}`);
1234
+ messages.push(msg);
1235
+ for (const c of calls) {
1236
+ let args = {};
1237
+ try { args = JSON.parse(c.function?.arguments || c.arguments || '{}'); } catch { args = {}; }
1238
+ const name = c.function?.name || c.name || '';
1239
+ const result = await runLocalTool(name, args, log);
1240
+ messages.push({
1241
+ role: 'tool',
1242
+ tool_call_id: c.id,
1243
+ content: String(result).slice(0, 120000),
1244
+ });
1245
+ }
1246
+ continue;
1247
+ }
1248
+ text = zooTextFromMessage(msg, data) || (step ? '(tool loop ended with empty content)' : '(empty zoo reply)');
1249
+ log(`cursor-backend: << zoo ${r.status} ${text.length}c model=${data.model || model} finish=${data.choices?.[0]?.finish_reason || '?'}`);
1250
+ break;
908
1251
  }
909
- const data = await r.json();
910
- let text = data.choices?.[0]?.message?.content || data.error?.message || '(empty zoo reply)';
911
- try { text += await zooSpendOverlay(data); } catch { /* overlay must never eat the reply */ }
912
- log(`cursor-backend: << zoo ${r.status} ${text.length}c`);
913
- return { text, data };
1252
+ try { text += await zooSpendOverlay(lastData); } catch { /* overlay must never eat the reply */ }
1253
+ return { text, data: lastData };
914
1254
  }
915
1255
 
916
1256
  async function handleHijackedPodHttp(req, res, full, body, log) {
@@ -918,7 +1258,8 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
918
1258
  const waiting = (full || '').split('?')[0];
919
1259
  const podPath = waiting === '/health' || waiting === '/healthz' || waiting === '/events'
920
1260
  || waiting.startsWith('/api/') || waiting.startsWith('/webauthn/')
921
- || waiting.startsWith('/cookie-origin-approval/') || waiting.startsWith('/local-exec/');
1261
+ || waiting.startsWith('/cookie-origin-approval/');
1262
+ if (waiting.startsWith('/local-exec/')) return handleLocalExecHttp(req, res, waiting, body, log);
922
1263
  if (realPod?.agent && podPath) return proxyPodHttp(req, res, full, body, log);
923
1264
  if (!podPath) return false;
924
1265
  if (waiting === '/health' || waiting === '/healthz') {
@@ -965,12 +1306,56 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
965
1306
  'getBotTemplateExportPolicy', 'getTeachRecordingStatus', 'isGlobalSearchEnabled',
966
1307
  'isEgressTunnelAvailable', 'listBoxMcpServers', 'getHostStatus',
967
1308
  'setWindowFocused', 'getAgentAutomations',
968
- 'createAgent', 'createAgentFromTemplate', 'createGroup', 'setGroupMembers',
969
- 'updateAgent', 'deleteAgents', 'duplicateAgent', 'kickstartAgent',
1309
+ 'createGroup', 'setGroupMembers',
970
1310
  'interruptAgentRun', 'requestDiskSaverAudit', 'broadcastToAgents',
971
1311
  'setAgentUnread', 'setAgentHiddenFromSidebar', 'setAgentNotificationsEnabled',
972
1312
  'setAgentNotifyOnUpdates', 'setAgentAvatarBytes', 'getAgentAvatar',
973
1313
  ]);
1314
+ // create/delete stay LOCAL — 1340 createAgent 401s on a stale cached token
1315
+ // and the UI then never grows a sidebar row or clears the canvas.
1316
+ if (name === 'createAgent' || name === 'createAgentFromTemplate' || name === 'duplicateAgent') {
1317
+ let parsed = {};
1318
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
1319
+ if (name === 'duplicateAgent' && parsed.id) {
1320
+ const src = (cachedAgentList() || []).find((a) => a.id === parsed.id) || {};
1321
+ parsed = { ...src, id: undefined, name: `${src.name || 'chat'} copy` };
1322
+ }
1323
+ const agent = mintLocalAgent(parsed);
1324
+ jsonSend(res, { agent, id: agent.id, ...agent });
1325
+ ssePush('agents', { action: 'created', agent });
1326
+ log(`cursor-backend: createAgent local id=${agent.id} name=${JSON.stringify(agent.name)}`);
1327
+ return true;
1328
+ }
1329
+ if (name === 'updateAgent') {
1330
+ let parsed = {};
1331
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
1332
+ const agent = mintLocalAgent(parsed);
1333
+ jsonSend(res, { agent, ...agent });
1334
+ log(`cursor-backend: updateAgent local id=${agent.id}`);
1335
+ return true;
1336
+ }
1337
+ if (name === 'deleteAgents') {
1338
+ let parsed = {};
1339
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
1340
+ const ids = new Set([].concat(parsed.ids || parsed.id || []).map(String));
1341
+ const next = (cachedAgentList() || []).filter((a) => !ids.has(a.id));
1342
+ saveAgents(next);
1343
+ for (const id of ids) transcripts.delete(id);
1344
+ jsonSend(res, { ok: true, deleted: [...ids] });
1345
+ log(`cursor-backend: deleteAgents n=${ids.size}`);
1346
+ return true;
1347
+ }
1348
+ if (name === 'listAgents') {
1349
+ if (!sniffOn() && realPod?.agent) {
1350
+ const proxied = await proxyPodHttp(req, res, full, body, log);
1351
+ if (proxied) return true;
1352
+ }
1353
+ const list = mergeAgentLists([]);
1354
+ jsonSend(res, list);
1355
+ log(`cursor-backend: listAgents local n=${list.length}`);
1356
+ return true;
1357
+ }
1358
+
974
1359
  if (!sniffOn() && realPod?.agent && roster.has(name)) {
975
1360
  return proxyPodHttp(req, res, full, body, log);
976
1361
  }
@@ -984,7 +1369,8 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
984
1369
  const prompt = promptFromSendBody(parsed);
985
1370
  const agentId = String(parsed.agentId || parsed.id || 'openzoo');
986
1371
  const nonce = parsed.clientNonce || `oz-${Date.now()}`;
987
- log(`cursor-backend: >> sendPrompt agent=${agentId} keys=${Object.keys(parsed).join(',')} prompt=${JSON.stringify((prompt || '').slice(0, 80))}`);
1372
+ const attN = Array.isArray(parsed.attachmentPaths) ? parsed.attachmentPaths.length : 0;
1373
+ log(`cursor-backend: >> sendPrompt agent=${agentId} keys=${Object.keys(parsed).join(',')} attachments=${attN} prompt=${JSON.stringify((prompt || '').slice(0, 80))}`);
988
1374
  lastSendEchoId = String(nonce);
989
1375
  const userLine = fanoutLine(agentId, 'user', prompt, {
990
1376
  clientNonce: nonce,
@@ -1000,7 +1386,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1000
1386
  if (modelCmd) {
1001
1387
  const want = modelCmd[1];
1002
1388
  if (!want) {
1003
- const cur = agentModels.get(agentId) || process.env.OPENZOO_DEFAULT_MODEL || 'anthropic/claude-opus-5';
1389
+ const cur = currentModel(agentId);
1004
1390
  text = `current model: ${cur}\nset with /model fable | opus | sonnet | grok | provider/id`;
1005
1391
  } else {
1006
1392
  const id = resolveModelId(want) || (want.includes('/') ? want : null);
@@ -1008,12 +1394,13 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1008
1394
  text = `unknown model "${want}". try /model fable | opus | sonnet | grok or a full id like anthropic/claude-fable-5`;
1009
1395
  } else {
1010
1396
  agentModels.set(agentId, id);
1397
+ saveAgentModels();
1011
1398
  text = `model set to ${id}`;
1012
1399
  }
1013
1400
  }
1014
1401
  try { text += await zooSpendOverlay({}); } catch { /* */ }
1015
1402
  } else {
1016
- const z = await zooComplete(prompt, log, agentId);
1403
+ const z = await zooComplete(prompt, log, agentId, parsed);
1017
1404
  text = z.text;
1018
1405
  }
1019
1406
  } catch (e) {
@@ -1029,14 +1416,9 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1029
1416
  if (name === 'getAgentTranscriptTail' || name === 'getAgentTranscriptWindow' || name === 'openAgentTail') {
1030
1417
  let parsed = {};
1031
1418
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
1032
- let id = String(parsed.id || parsed.agentId || 'openzoo');
1419
+ const id = String(parsed.id || parsed.agentId || 'openzoo');
1033
1420
  tailedAgents.add(id);
1034
- let t = agentTranscript(id);
1035
- if (!t.entries.length) {
1036
- for (const [other, ot] of transcripts) {
1037
- if (ot.entries.length) { id = other; t = ot; break; }
1038
- }
1039
- }
1421
+ const t = agentTranscript(id);
1040
1422
  const limit = Math.min(Number(parsed.limit) || 50, 200);
1041
1423
  const before = parsed.beforeSeq != null ? Number(parsed.beforeSeq) : Infinity;
1042
1424
  const sliced = t.entries.filter((e) => e.seq < before).slice(-limit);
@@ -1074,7 +1456,8 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1074
1456
  getHostSettings: { settings: {} },
1075
1457
  setHostSettings: { ok: true },
1076
1458
  setBoxSecrets: { ok: true },
1077
- listAgents: [...new Set([...transcripts.keys(), ...tailedAgents])].map((id) => ({ id, name: id, status: 'ready' })),
1459
+ listAgents: cachedAgentList()
1460
+ || [...new Set([...transcripts.keys(), ...tailedAgents])].map((id) => ({ id, name: id, status: 'ready' })),
1078
1461
  getAgentTranscriptTail: { tail: '', lines: [], dropped: false, ok: true },
1079
1462
  getTeachRecordingStatus: { recording: false },
1080
1463
  getTrays: { trays: [] },
@@ -1155,6 +1538,9 @@ function respond(req, res, method, models) {
1155
1538
  */
1156
1539
  export function startCursorBackend({ port = 8443, models, log = () => {} } = {}) {
1157
1540
  const { cert, key } = ensureCert(log);
1541
+ const certPem = fs.readFileSync(cert);
1542
+ const keyPem = fs.readFileSync(key);
1543
+ const secureContext = tls.createSecureContext({ cert: certPem, key: keyPem });
1158
1544
  let conns = 0;
1159
1545
  // SERVE BOTH h2 AND h1. An earlier build forced h1-only after concluding the
1160
1546
  // editor's h2 connections reset — but that log was the STALE 8443 backend the
@@ -1163,13 +1549,20 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
1163
1549
  // (ERR_SSL_NO_APPLICATION_PROTOCOL when we advertise just h1), so we must
1164
1550
  // negotiate both. allowHTTP1 keeps the h1 fetch() calls (stripe/updates)
1165
1551
  // working; ALPN h2 first satisfies the Connect gRPC client.
1552
+ // SNICallback MUST pass the SecureContext — cb(null) with no ctx is why the
1553
+ // Helper daemon's Node fetch never completed GET /local-exec/requests (TLS
1554
+ // ECONNRESET, no request log). Chromium --ignore-certificate-errors hid this
1555
+ // for the UI process.
1166
1556
  const server = http2.createSecureServer(
1167
1557
  {
1168
- cert: fs.readFileSync(cert),
1169
- key: fs.readFileSync(key),
1558
+ cert: certPem,
1559
+ key: keyPem,
1170
1560
  allowHTTP1: true,
1171
1561
  ALPNProtocols: ['h2', 'http/1.1'],
1172
- SNICallback: (servername, cb) => { log(`cursor-tls: <- ClientHello SNI=${servername}`); cb(null); },
1562
+ SNICallback: (servername, cb) => {
1563
+ log(`cursor-tls: <- ClientHello SNI=${servername || '?'}`);
1564
+ cb(null, secureContext);
1565
+ },
1173
1566
  },
1174
1567
  async (req, res) => {
1175
1568
  conns += 1;
@@ -1281,25 +1674,31 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
1281
1674
  // with OUR box so Grok Bot's UI wires to our sandbox; everything else
1282
1675
  // still passes through so the app loads normally.
1283
1676
  if (/GrokBotService\/(EnsureSandBox|WatchSandBoxMigration)/.test(full) && process.env.OZ_HIJACK_POD) {
1284
- if (/WatchSandBoxMigration/.test(full) && realPod) {
1285
- const payload = rewrittenBox();
1286
- res.writeHead(200, {
1287
- 'content-type': 'application/connect+proto',
1288
- 'grpc-status': '0',
1289
- ...CORS,
1290
- });
1291
- const end = Buffer.from('{}');
1292
- const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
1293
- res.end(Buffer.concat([envelope(payload), h, end]));
1294
- log('cursor-backend: -> WatchSandBoxMigration ready (hijack, real roster)');
1295
- return;
1296
- }
1297
1677
  try {
1298
1678
  process.env.OZ_SNIFF_SELF = process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443';
1299
1679
  await sniffEnsureSandBox(req, res, body, host, full, log);
1300
1680
  log('cursor-backend: -> HIJACKED EnsureSandBox -> our box (roster from real 1340)');
1301
1681
  return;
1302
1682
  } catch (e) {
1683
+ if (realPod?.agent) {
1684
+ log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — cached 1340 roster`);
1685
+ const payload = rewrittenBox();
1686
+ const reqCt = String(req.headers['content-type'] || '');
1687
+ if (/WatchSandBoxMigration/.test(full) || reqCt.includes('connect+proto')) {
1688
+ res.writeHead(200, {
1689
+ 'content-type': 'application/connect+proto',
1690
+ 'grpc-status': '0',
1691
+ ...CORS,
1692
+ });
1693
+ const end = Buffer.from('{}');
1694
+ const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
1695
+ res.end(Buffer.concat([envelope(payload), h, end]));
1696
+ } else {
1697
+ res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
1698
+ res.end(payload);
1699
+ }
1700
+ return;
1701
+ }
1303
1702
  log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — env box`);
1304
1703
  }
1305
1704
  let pod;
package/lib/grokcli.js CHANGED
@@ -177,8 +177,10 @@ export async function runBot(argv = []) {
177
177
  console.error(' EnsureSandBox HIJACKED here; StreamUnifiedChat -> :8402 (x402)');
178
178
  }
179
179
 
180
- try { execSync('osascript -e \'tell application "Grok Bot" to quit\'', { stdio: 'ignore' }); } catch { /* ok */ }
181
- await new Promise((r) => setTimeout(r, 1500));
180
+ if (!argv.includes('--no-quit') && process.env.OZ_NO_QUIT !== '1') {
181
+ try { execSync('osascript -e \'tell application "Grok Bot" to quit\'', { stdio: 'ignore' }); } catch { /* ok */ }
182
+ await new Promise((r) => setTimeout(r, 1500));
183
+ }
182
184
 
183
185
  console.error('openzoo: launching Grok Bot');
184
186
  console.error(` CURSOR_API_BASE_URL=${url}`);
@@ -190,6 +192,12 @@ export async function runBot(argv = []) {
190
192
  NODE_TLS_REJECT_UNAUTHORIZED: '0',
191
193
  CURSOR_API_BASE_URL: url,
192
194
  SAND_BACKEND_URL: url,
195
+ // Helper daemon (local-exec-daemon/main.cjs) uses Node fetch, not Chromium.
196
+ // Without these it dials the cached cursorvm 1337 URL / dies on our
197
+ // self-signed cert and GET /local-exec/requests never arrives.
198
+ SAND_HOST_GATEWAY_URL: url,
199
+ SAND_HOST_GATEWAY_TOKEN: 'openzoo',
200
+ SAND_HOST_GATEWAY_NETWORK_TOKEN: 'openzoo',
193
201
  },
194
202
  }).unref();
195
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.20",
3
+ "version": "0.50.22",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",