openzoo 0.50.39 → 0.50.40

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.
@@ -1129,12 +1129,99 @@ function appendLine(agentId, role, text, extra = {}) {
1129
1129
  timestampMs: ts,
1130
1130
  requestId,
1131
1131
  ...(extra.author && typeof extra.author === 'object' ? { author: extra.author } : {}),
1132
+ ...(extra.ephemeral ? { ephemeral: true } : {}),
1132
1133
  };
1133
1134
  }
1134
1135
  t.entries.push(e);
1135
1136
  saveTranscripts();
1136
1137
  return e;
1137
1138
  }
1139
+
1140
+ /** One zooComplete per agent. A new sendPrompt (or Stop) aborts the previous
1141
+ * loop so "try again" does not stack 32-step exec storms with an empty canvas. */
1142
+ export function createZooTurnQueue() {
1143
+ const inflight = new Map();
1144
+ return {
1145
+ begin(agentId, nonce) {
1146
+ const id = String(agentId || '');
1147
+ const prev = inflight.get(id);
1148
+ if (prev && prev.nonce !== nonce) {
1149
+ try { prev.abort.abort(new Error('superseded')); } catch { /* */ }
1150
+ }
1151
+ const abort = new AbortController();
1152
+ inflight.set(id, { nonce, abort });
1153
+ return abort;
1154
+ },
1155
+ isCurrent(agentId, nonce) {
1156
+ return inflight.get(String(agentId || ''))?.nonce === nonce;
1157
+ },
1158
+ end(agentId, nonce) {
1159
+ const id = String(agentId || '');
1160
+ const cur = inflight.get(id);
1161
+ if (cur && cur.nonce === nonce) inflight.delete(id);
1162
+ },
1163
+ abort(agentId) {
1164
+ const id = String(agentId || '');
1165
+ const prev = inflight.get(id);
1166
+ if (!prev) return 0;
1167
+ try { prev.abort.abort(new Error('interrupted')); } catch { /* */ }
1168
+ inflight.delete(id);
1169
+ return 1;
1170
+ },
1171
+ };
1172
+ }
1173
+ const zooTurns = createZooTurnQueue();
1174
+
1175
+ export function formatZooProgress({ step, maxSteps, names, command } = {}) {
1176
+ const tools = Array.isArray(names) ? names.filter(Boolean).join(', ') : String(names || 'tools');
1177
+ const cmd = command ? ` ${JSON.stringify(String(command).slice(0, 80))}` : '';
1178
+ return `Working on your Mac (step ${Number(step) + 1}/${maxSteps || '?'}): ${tools}${cmd}`;
1179
+ }
1180
+
1181
+ /** One canvas line per tool. Asar ingest is append-only — mutating a working
1182
+ * bubble is still silence. Keep it short; the model history skips ephemeral. */
1183
+ export function formatZooToolLine({ name, args = {}, result } = {}) {
1184
+ const detail = args.command || args.path || args.name || args.window || '';
1185
+ const head = detail
1186
+ ? `${name} ${JSON.stringify(String(detail).slice(0, 90))}`
1187
+ : String(name || 'tool');
1188
+ let tail = String(result || '').replace(/\s+/g, ' ').trim();
1189
+ if (tail.startsWith('{')) {
1190
+ try {
1191
+ const o = JSON.parse(String(result));
1192
+ if (o && typeof o === 'object') {
1193
+ tail = [o.path, o.id, o.name, o.bytes != null ? `${o.bytes}b` : '', o.ok === true ? 'ok' : '']
1194
+ .filter(Boolean).join(' ') || tail;
1195
+ }
1196
+ } catch { /* raw */ }
1197
+ }
1198
+ tail = tail.slice(0, 160);
1199
+ return `→ ${head}${tail ? `\n${tail}` : ''}`;
1200
+ }
1201
+
1202
+ export function combinedAbortSignal(a, b) {
1203
+ if (!a) return b;
1204
+ if (!b) return a;
1205
+ if (typeof AbortSignal.any === 'function') return AbortSignal.any([a, b]);
1206
+ const c = new AbortController();
1207
+ const on = () => { try { c.abort(); } catch { /* */ } };
1208
+ if (a.aborted || b.aborted) { on(); return c.signal; }
1209
+ a.addEventListener('abort', on, { once: true });
1210
+ b.addEventListener('abort', on, { once: true });
1211
+ return c.signal;
1212
+ }
1213
+
1214
+ export function isSupersededError(e) {
1215
+ const m = String(e?.message || e || '');
1216
+ return /superseded|interrupted/i.test(m);
1217
+ }
1218
+
1219
+ function paintChatUpdate(agentId, note, extra = {}) {
1220
+ const line = fanoutLine(agentId, 'assistant', note, { ...extra, ephemeral: true });
1221
+ ssePush('transcript', { ...gatewayEntry(line), agentId });
1222
+ bumpAgent(agentId, { preview: note, notify: false });
1223
+ return line;
1224
+ }
1138
1225
  function fanoutLine(primaryId, role, text, extra = {}) {
1139
1226
  // Only the addressed agent. Writing to every tailedAgents id mixed canvases
1140
1227
  // so a new/empty chat showed someone else's thread.
@@ -1254,7 +1341,7 @@ function mergeAgentLists(remote) {
1254
1341
  return sortAgentsByActivity(out);
1255
1342
  }
1256
1343
  function gatewayEntry(e) {
1257
- const { seq, pulledRemote, promptRaw, ...rest } = e;
1344
+ const { seq, pulledRemote, promptRaw, ephemeral, ...rest } = e;
1258
1345
  return rest;
1259
1346
  }
1260
1347
 
@@ -1286,6 +1373,7 @@ function historyMessages(agentId, currentPrompt) {
1286
1373
  if (!e || typeof e !== 'object') continue;
1287
1374
  let role = null;
1288
1375
  let text = '';
1376
+ if (e.ephemeral) continue;
1289
1377
  if (e.kind === 'send-message' || e.role === 'assistant' || e.kind === 'assistant') {
1290
1378
  text = entryPlainText(e.message ?? e.content ?? e.text);
1291
1379
  role = 'assistant';
@@ -1622,9 +1710,10 @@ async function execLocal(command, cwd, log) {
1622
1710
  return `${out}${err}`.trim() || `(exit ${got.exitCode ?? 0})`;
1623
1711
  }
1624
1712
  log(`cursor-backend: exec cwd=${dir} ${JSON.stringify(String(command).slice(0, 80))}`);
1713
+ const execMs = Math.min(180000, Math.max(15000, Number(process.env.OPENZOO_EXEC_TIMEOUT_MS || 90000)));
1625
1714
  const { stdout, stderr } = await execFileAsync('/bin/zsh', ['-lc', command], {
1626
1715
  cwd: dir,
1627
- timeout: 30000,
1716
+ timeout: execMs,
1628
1717
  maxBuffer: 2 * 1024 * 1024,
1629
1718
  env: process.env,
1630
1719
  });
@@ -1672,8 +1761,62 @@ const LOCAL_TOOLS = [
1672
1761
  parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
1673
1762
  },
1674
1763
  },
1764
+ {
1765
+ type: 'function',
1766
+ function: {
1767
+ name: 'screenshot',
1768
+ description: 'Capture the Mac screen (or the front window) and attach the image so you can SEE the UI. Use this before filling forms or clicking. Do not guess at a dashboard you have not screenshotted this turn.',
1769
+ parameters: {
1770
+ type: 'object',
1771
+ properties: {
1772
+ window: { type: 'string', description: 'Optional window title substring; default is the full display.' },
1773
+ },
1774
+ },
1775
+ },
1776
+ },
1777
+ {
1778
+ type: 'function',
1779
+ function: {
1780
+ name: 'create_agent',
1781
+ description: 'Mint another Grok Bot in the sidebar. Use this when asked to spawn bots — talking about spawning is not enough.',
1782
+ parameters: {
1783
+ type: 'object',
1784
+ properties: {
1785
+ name: { type: 'string', description: 'Sidebar label for the new bot.' },
1786
+ select: { type: 'boolean', description: 'If true (default), switch the UI to the new bot.' },
1787
+ },
1788
+ required: ['name'],
1789
+ },
1790
+ },
1791
+ },
1675
1792
  ];
1676
1793
 
1794
+ export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
1795
+
1796
+ async function captureScreenshot(log) {
1797
+ const dir = path.join(os.tmpdir(), 'openzoo-screens');
1798
+ fs.mkdirSync(dir, { recursive: true });
1799
+ const stamp = Date.now();
1800
+ const raw = path.join(dir, `oz-${stamp}.png`);
1801
+ const jpg = path.join(dir, `oz-${stamp}.jpg`);
1802
+ await execFileAsync('screencapture', ['-x', '-C', raw], { timeout: 15000 });
1803
+ if (!fs.existsSync(raw) || fs.statSync(raw).size < 80) {
1804
+ throw new Error('screencapture wrote nothing — Screen Recording permission may be off for Grok Bot / Terminal');
1805
+ }
1806
+ try {
1807
+ await execFileAsync('sips', [
1808
+ '-Z', '1400', '-s', 'format', 'jpeg', '-s', 'formatOptions', '70',
1809
+ raw, '--out', jpg,
1810
+ ], { timeout: 15000 });
1811
+ if (fs.existsSync(jpg) && fs.statSync(jpg).size > 80) {
1812
+ return { path: jpg, mime: 'image/jpeg', buf: fs.readFileSync(jpg) };
1813
+ }
1814
+ } catch (e) {
1815
+ log(`cursor-backend: sips screenshot resize failed: ${e.message}`);
1816
+ }
1817
+ return { path: raw, mime: 'image/png', buf: fs.readFileSync(raw) };
1818
+ }
1819
+
1677
1820
  async function runLocalTool(name, args, log) {
1678
1821
  try {
1679
1822
  if (name === 'read_file') {
@@ -1697,6 +1840,24 @@ async function runLocalTool(name, args, log) {
1697
1840
  if (name === 'exec') {
1698
1841
  return await execLocal(String(args.command || ''), args.cwd, log);
1699
1842
  }
1843
+ if (name === 'screenshot') {
1844
+ const shot = await captureScreenshot(log);
1845
+ return JSON.stringify({
1846
+ ok: true,
1847
+ path: shot.path,
1848
+ mime: shot.mime,
1849
+ bytes: shot.buf.length,
1850
+ dataUrl: `data:${shot.mime};base64,${shot.buf.toString('base64')}`,
1851
+ });
1852
+ }
1853
+ if (name === 'create_agent') {
1854
+ const agent = mintLocalAgent({
1855
+ name: String(args.name || args.title || 'New Bot').slice(0, 80),
1856
+ });
1857
+ pushCreatedAgent(agent, { select: args.select !== false });
1858
+ log(`cursor-backend: create_agent tool id=${agent.id} name=${JSON.stringify(agent.name)}`);
1859
+ return JSON.stringify({ ok: true, id: agent.id, name: agent.name });
1860
+ }
1700
1861
  return `unknown tool ${name}`;
1701
1862
  } catch (e) {
1702
1863
  return `ERROR ${e.message}`;
@@ -1718,7 +1879,7 @@ function zooTextFromMessage(msg, data) {
1718
1879
  return '';
1719
1880
  }
1720
1881
 
1721
- async function zooComplete(prompt, log, agentId, parsed = {}) {
1882
+ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
1722
1883
  const model = currentModel(agentId);
1723
1884
  const helper = localExecSse.size > 0;
1724
1885
  const visitor = visitorFromSend(parsed);
@@ -1726,8 +1887,12 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1726
1887
  const spoken = visitor && !/^You are /.test(String(prompt || ''))
1727
1888
  ? labeledVisitorPrompt(visitor, prompt)
1728
1889
  : prompt;
1890
+ const throwIfAborted = () => {
1891
+ if (opts.signal?.aborted) throw new Error('superseded');
1892
+ };
1729
1893
  await ensureTranscriptHydrated(agentId, log);
1730
1894
  log(`cursor-backend: zoo POST :8402 model=${model} helper=${helper ? localExecSse.size : 0} hist=${historyMessages(agentId, spoken).length}${chatOnly ? ` visitor=${visitor.shortname} chat-only` : ''} ${JSON.stringify((spoken || '').slice(0, 60))}`);
1895
+ if (typeof opts.onProgress === 'function') opts.onProgress('Working on your Mac…');
1731
1896
 
1732
1897
  const images = [];
1733
1898
  const textFiles = [];
@@ -1783,10 +1948,12 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1783
1948
  : [
1784
1949
  `You are ${model} served through openzoo inside Grok Bot.`,
1785
1950
  `You HAVE local tools on the user's computer via ${via}.`,
1786
- 'Tools: read_file, write_file, exec, list_dir. USE THEM. Write files to disk instead of pasting giant HTML into chat.',
1951
+ 'Tools: read_file, write_file, exec, list_dir, screenshot, create_agent. USE THEM. Write files to disk instead of pasting giant HTML into chat.',
1952
+ 'create_agent mints a new Grok Bot in the sidebar. When asked to spawn bots, call it — do not only describe spawning.',
1953
+ 'screenshot captures the display and attaches the image. For any on-screen form, dashboard, or click target, screenshot first this turn. Do not guess at UI you have not seen.',
1787
1954
  'Pasted images arrive as attachments — you can see them when present. Do not claim you cannot see images if they are in this turn.',
1788
1955
  'Never claim you lack filesystem access or local-exec. If a tool errors, report the error.',
1789
- 'After using tools you MUST still write a normal chat reply: what you did, file paths written, and what to open. Empty content is a bug.',
1956
+ 'After using tools you MUST still write a normal chat reply: what you did, file paths written, and what to open. Empty content is a bug. A new user message cancels this turn — leave a visible reply before that happens.',
1790
1957
  'Do not stop mid-task. Do not write "Stopped on research", "nothing to open yet", "no app files written this turn", or "say go again". Keep using tools until the files the user asked for exist on disk THIS turn. Summarize only after those writes succeed.',
1791
1958
  'A spend footer is appended after your reply by the host — ignore it.',
1792
1959
  'Prior turns of THIS Grok Bot chat are in the messages below. Do not claim the thread starts blank or that earlier questions did not arrive.',
@@ -1823,12 +1990,13 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1823
1990
  const usedTools = [];
1824
1991
  const KEEP_GOING = 'Do not stop. Do not wait for another message. Write the files to disk now with write_file / exec. Keep going until the requested paths exist. A summary is only allowed after the files are written.';
1825
1992
  const zooPost = async (payload) => {
1993
+ throwIfAborted();
1826
1994
  const ms = Number(process.env.OPENZOO_ASK_TIMEOUT_MS || 10 * 60_000);
1827
1995
  const post = () => fetch('http://127.0.0.1:8402/v1/chat/completions', {
1828
1996
  method: 'POST',
1829
1997
  headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
1830
1998
  body: JSON.stringify(payload),
1831
- signal: AbortSignal.timeout(ms),
1999
+ signal: combinedAbortSignal(opts.signal, AbortSignal.timeout(ms)),
1832
2000
  });
1833
2001
  let r;
1834
2002
  let lastErr;
@@ -1872,7 +2040,9 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1872
2040
  return { r, data };
1873
2041
  };
1874
2042
  let keepGoingNudge = 0;
2043
+ const pendingVision = [];
1875
2044
  for (let step = 0; step < maxSteps; step++) {
2045
+ throwIfAborted();
1876
2046
  const payload = { model, messages, max_tokens: maxTok };
1877
2047
  if (!chatOnly) {
1878
2048
  payload.tools = LOCAL_TOOLS;
@@ -1896,25 +2066,49 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1896
2066
  }
1897
2067
  continue;
1898
2068
  }
1899
- log(`cursor-backend: zoo tools step=${step} n=${calls.length} ${calls.map((c) => c.function?.name || c.name).join(',')}`);
2069
+ const names = calls.map((c) => c.function?.name || c.name);
2070
+ log(`cursor-backend: zoo tools step=${step} n=${calls.length} ${names.join(',')}`);
1900
2071
  messages.push(msg);
1901
2072
  for (const c of calls) {
2073
+ throwIfAborted();
1902
2074
  let args = {};
1903
2075
  try { args = JSON.parse(c.function?.arguments || c.arguments || '{}'); } catch { args = {}; }
1904
2076
  const name = c.function?.name || c.name || '';
1905
- const result = await runLocalTool(name, args, log);
2077
+ let result = await runLocalTool(name, args, log);
2078
+ if (name === 'screenshot') {
2079
+ try {
2080
+ const shot = JSON.parse(result);
2081
+ if (shot?.dataUrl) {
2082
+ pendingVision.push({ path: shot.path, dataUrl: shot.dataUrl });
2083
+ result = `screenshot ${shot.path} ${shot.bytes}b — image attached, look at it before acting`;
2084
+ }
2085
+ } catch { /* keep raw */ }
2086
+ }
1906
2087
  usedTools.push({
1907
2088
  name,
1908
2089
  path: args.path,
1909
2090
  command: args.command ? String(args.command).slice(0, 120) : undefined,
1910
2091
  note: String(result).slice(0, 200),
1911
2092
  });
2093
+ if (typeof opts.onProgress === 'function') {
2094
+ opts.onProgress(formatZooToolLine({ name, args, result }));
2095
+ }
1912
2096
  messages.push({
1913
2097
  role: 'tool',
1914
2098
  tool_call_id: c.id,
1915
2099
  content: String(result).slice(0, 120000),
1916
2100
  });
1917
2101
  }
2102
+ if (pendingVision.length) {
2103
+ messages.push({
2104
+ role: 'user',
2105
+ content: [
2106
+ ...pendingVision.map((img) => ({ type: 'image_url', image_url: { url: img.dataUrl } })),
2107
+ { type: 'text', text: pendingVision.map((img) => `screenshot: ${img.path}`).join('\n') },
2108
+ ],
2109
+ });
2110
+ pendingVision.length = 0;
2111
+ }
1918
2112
  continue;
1919
2113
  }
1920
2114
  text = zooTextFromMessage(msg, data);
@@ -2081,7 +2275,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2081
2275
  'getBotTemplateExportPolicy', 'getTeachRecordingStatus', 'isGlobalSearchEnabled',
2082
2276
  'isEgressTunnelAvailable', 'listBoxMcpServers',
2083
2277
  'setWindowFocused', 'getAgentAutomations',
2084
- 'interruptAgentRun', 'requestDiskSaverAudit',
2278
+ 'requestDiskSaverAudit',
2085
2279
  'setAgentUnread', 'setAgentHiddenFromSidebar', 'setAgentNotificationsEnabled',
2086
2280
  'setAgentNotifyOnUpdates', 'setAgentAvatarBytes', 'getAgentAvatar',
2087
2281
  ]);
@@ -2100,6 +2294,15 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2100
2294
  log(`cursor-backend: createAgent local id=${agent.id} name=${JSON.stringify(agent.name)}`);
2101
2295
  return true;
2102
2296
  }
2297
+ if (name === 'interruptAgentRun') {
2298
+ let parsed = {};
2299
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
2300
+ const id = String(parsed.id || parsed.agentId || focusedAgentId || '');
2301
+ const n = id ? zooTurns.abort(id) : 0;
2302
+ jsonSend(res, { ok: true, interrupted: n, id });
2303
+ log(`cursor-backend: interruptAgentRun id=${id || '?'} n=${n}`);
2304
+ return true;
2305
+ }
2103
2306
  if (name === 'kickstartAgent') {
2104
2307
  let parsed = {};
2105
2308
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
@@ -2262,6 +2465,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2262
2465
  log(`cursor-backend: >> sendPrompt agent=${agentId} keys=${Object.keys(parsed).join(',')} attachments=${attN}${visitor ? ` visitor=${visitor.shortname}` : ''} prompt=${JSON.stringify((prompt || '').slice(0, 80))}`);
2263
2466
  lastSendEchoId = String(nonce);
2264
2467
  jsonSend(res, { accepted: true });
2468
+ const turn = zooTurns.begin(agentId, nonce);
2265
2469
  const userLine = fanoutLine(agentId, 'user', uiText, {
2266
2470
  clientNonce: nonce,
2267
2471
  requestId: nonce,
@@ -2275,6 +2479,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2275
2479
  setImmediate(async () => {
2276
2480
  let text = '';
2277
2481
  let grouped = false;
2482
+ const stillCurrent = () => zooTurns.isCurrent(agentId, nonce) && !turn.signal.aborted;
2278
2483
  try {
2279
2484
  if (modelCmd) {
2280
2485
  const want = modelCmd[1];
@@ -2298,15 +2503,31 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2298
2503
  grouped = true;
2299
2504
  await runGroupQueue({ agentId, humanPrompt: prompt, parsed, nonce, log });
2300
2505
  } else {
2301
- const z = await zooComplete(prompt, log, agentId, parsed);
2506
+ const z = await zooComplete(prompt, log, agentId, parsed, {
2507
+ signal: turn.signal,
2508
+ onProgress: (note) => {
2509
+ if (!stillCurrent()) return;
2510
+ paintChatUpdate(agentId, note, { clientNonce: nonce, requestId: nonce });
2511
+ },
2512
+ });
2302
2513
  text = z.text;
2303
2514
  }
2304
2515
  }
2305
2516
  } catch (e) {
2517
+ if (!stillCurrent() || isSupersededError(e)) {
2518
+ log(`cursor-backend: sendPrompt superseded agent=${agentId} nonce=${nonce} ${e.message}`);
2519
+ zooTurns.end(agentId, nonce);
2520
+ return;
2521
+ }
2306
2522
  grouped = false;
2307
2523
  text = `openzoo error: ${e.message}`;
2308
2524
  log(`cursor-backend: sendPrompt zoo failed: ${e.message}`);
2309
2525
  }
2526
+ if (!stillCurrent()) {
2527
+ zooTurns.end(agentId, nonce);
2528
+ log(`cursor-backend: sendPrompt dropped stale agent=${agentId} nonce=${nonce}`);
2529
+ return;
2530
+ }
2310
2531
  if (!grouped) {
2311
2532
  const line = fanoutLine(agentId, 'assistant', text, { clientNonce: nonce, requestId: nonce });
2312
2533
  ssePush('transcript', { ...gatewayEntry(line), agentId });
@@ -2315,6 +2536,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2315
2536
  } else {
2316
2537
  log(`cursor-backend: sendPrompt group done agent=${agentId}`);
2317
2538
  }
2539
+ zooTurns.end(agentId, nonce);
2318
2540
  });
2319
2541
  return true;
2320
2542
  }
package/lib/xb ADDED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.39",
3
+ "version": "0.50.40",
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",