openzoo 0.50.64 → 0.50.66

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.
@@ -55,6 +55,7 @@ import {
55
55
  } from './grokbotDesktop.js';
56
56
  import { startHostMcps, hostMcpTools, hostMcpHas, callHostMcp, hostMcpServers, chromeStatus, reattachChrome } from './mcpbridge.js';
57
57
  import * as ship from './ship.js';
58
+ import * as xclaims from './xclaims.js';
58
59
 
59
60
  const TLS_DIR = path.join(os.homedir(), '.openzoo', 'cursor-tls');
60
61
  const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
@@ -2287,6 +2288,50 @@ const LOCAL_TOOLS = [
2287
2288
  },
2288
2289
  },
2289
2290
  },
2291
+ {
2292
+ type: 'function',
2293
+ function: {
2294
+ name: 'x_claim',
2295
+ description: 'X reply lock: claim a tweet (status URL or id) before drafting a reply. ok:false means another bot has it or it was already answered — pick a different tweet. Lease 20m.',
2296
+ parameters: { type: 'object', properties: { tweet: { type: 'string' } }, required: ['tweet'] },
2297
+ },
2298
+ },
2299
+ {
2300
+ type: 'function',
2301
+ function: {
2302
+ name: 'x_done',
2303
+ description: 'X reply lock: mark a claimed tweet as replied, permanently, with the URL of our reply. Also appends to openzoobot-posted.json.',
2304
+ parameters: { type: 'object', properties: { tweet: { type: 'string' }, url: { type: 'string' }, lane: { type: 'string' } }, required: ['tweet', 'url'] },
2305
+ },
2306
+ },
2307
+ {
2308
+ type: 'function',
2309
+ function: {
2310
+ name: 'x_release',
2311
+ description: 'X reply lock: give a claimed tweet back (you decided not to reply).',
2312
+ parameters: { type: 'object', properties: { tweet: { type: 'string' } }, required: ['tweet'] },
2313
+ },
2314
+ },
2315
+ {
2316
+ type: 'function',
2317
+ function: {
2318
+ name: 'x_claims',
2319
+ description: 'X reply lock: list tweet ids already answered plus live claims by other bots, so you can skip them before reading.',
2320
+ parameters: { type: 'object', properties: {} },
2321
+ },
2322
+ },
2323
+ {
2324
+ type: 'function',
2325
+ function: {
2326
+ name: 'x_compose',
2327
+ description: 'Type reply text into the X composer on the current page of the given browser, LINE BY LINE with real Enter keys so newlines survive (fill flattens them). Click/focus the reply box first (or open the tweet). Then click the Reply button yourself. browser: chrome or brave.',
2328
+ parameters: {
2329
+ type: 'object',
2330
+ properties: { browser: { type: 'string', enum: ['chrome', 'brave'] }, text: { type: 'string' } },
2331
+ required: ['browser', 'text'],
2332
+ },
2333
+ },
2334
+ },
2290
2335
  ];
2291
2336
 
2292
2337
  export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
@@ -2486,6 +2531,26 @@ async function runLocalTool(name, args, log, ctx = {}) {
2486
2531
  if (name.startsWith('ship_')) {
2487
2532
  return await runShipTool(name, args, log, ctx);
2488
2533
  }
2534
+ if (name.startsWith('x_')) {
2535
+ const me = findAgent(ctx.agentId) || { id: String(ctx.agentId || ''), name: '' };
2536
+ if (name === 'x_claim') return JSON.stringify(xclaims.claimTweet({ tweet: args.tweet || args.url || args.id, by: me.id, name: me.name }));
2537
+ if (name === 'x_done') return JSON.stringify(xclaims.markDone({ tweet: args.tweet || args.id, by: me.id, name: me.name, url: args.url, lane: args.lane }));
2538
+ if (name === 'x_release') return JSON.stringify(xclaims.releaseTweet({ tweet: args.tweet || args.id, by: me.id }));
2539
+ if (name === 'x_claims') return JSON.stringify(xclaims.listClaims());
2540
+ if (name === 'x_compose') {
2541
+ const server = String(args.browser || 'chrome').toLowerCase() === 'brave' ? 'brave-devtools' : 'chrome-devtools';
2542
+ const plan = xclaims.composePlan(args.text);
2543
+ let last = '';
2544
+ for (const op of plan.ops) {
2545
+ const tname = `${server}__${op.tool}`;
2546
+ if (!hostMcpHas(tname)) return JSON.stringify({ ok: false, error: `${tname} not attached` });
2547
+ last = String(await callHostMcp(tname, op.args));
2548
+ if (op.tool === 'evaluate_script' && /"ok":\s*false/.test(last)) return JSON.stringify({ ok: false, error: last.slice(0, 300) });
2549
+ }
2550
+ log(`cursor-backend: x_compose ${server} lines=${plan.lines}`);
2551
+ return JSON.stringify({ ok: true, browser: server, linesTyped: plan.lines, composer: last.slice(0, 700), next: 'verify the composer text above shows separate lines, then click the Reply button' });
2552
+ }
2553
+ }
2489
2554
  if (hostMcpHas(name)) {
2490
2555
  return await callHostMcp(name, args);
2491
2556
  }
@@ -2645,7 +2710,9 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2645
2710
  return brief ? `${who} Standing brief (persisted): ${brief}` : who;
2646
2711
  })(),
2647
2712
  `You HAVE local tools on the user's computer via ${via}.`,
2648
- 'Tools: read_file, write_file, exec, list_dir, screenshot, click, type_text, key, ui_tree, focus_app, open_url, create_agent, set_brief, list_agents, message_agent, schedule_wakeup, cancel_wakeup, ship_crew, ship_forge, ship_launch_worker, ship_status, ship_review, ship_open_pr.',
2713
+ 'Tools: read_file, write_file, exec, list_dir, screenshot, click, type_text, key, ui_tree, focus_app, open_url, create_agent, set_brief, list_agents, message_agent, schedule_wakeup, cancel_wakeup, ship_crew, ship_forge, ship_launch_worker, ship_status, ship_review, ship_open_pr, x_claim, x_done, x_release, x_claims, x_compose.',
2714
+ 'Posting on X: NEVER fill the reply box (newlines get flattened). Open the tweet, click the reply box, then x_compose {browser, text} which types line by line with Enter, then click Reply.',
2715
+ 'X reply bots: x_claims first (skip those ids), x_claim the tweet BEFORE drafting (ok:false -> pick another), x_done with our reply URL after posting, x_release if you back out. Never reply to a tweet you did not claim.',
2649
2716
  'If no sidebar bot is named Firstmate and the human brings code or repo work, ask ONE question: which repo path to set up Grok Ship for. Then call ship_crew with that cwd. Do not start coding outside the factory.',
2650
2717
  'Grok Ship (software factory): ship_crew sets up Firstmate + a crewmate per repo. A crewmate ships with ship_launch_worker -> ship_status until pushed -> ship_review (fresh, diff-only) -> ship_open_pr only when gate.clean. Never merge; the human does.',
2651
2718
  (() => {
@@ -2656,10 +2723,11 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2656
2723
  return 'Host MCP servers (Claude/Grok chrome-devtools, brave, …) are connecting. When chrome-devtools__* tools appear, use them for web pages.';
2657
2724
  }
2658
2725
  const chrome = names.filter((n) => /chrome|devtools|browser/i.test(n));
2726
+ const brave = names.some((n) => n.startsWith('brave-devtools__'));
2659
2727
  return [
2660
2728
  `Host MCP tools from the user's local Claude/Grok config are attached (${servers.join(', ') || 'mcp'}): ${names.slice(0, 36).join(', ')}${names.length > 36 ? '…' : ''}.`,
2661
2729
  chrome.length
2662
- ? `For any web page or HTML form, use chrome-devtools tools (navigate_page / take_snapshot / fill / click). Do NOT use osascript, Quartz, Python Foundation, or AppleScript to read Brave. screenshot/click/type_text are for native Mac UI only. On heavy pages (x.com, gmail, big SPAs) take_snapshot can hang: prefer evaluate_script that returns only the text you need, or take_screenshot; a tool that times out says so — do not repeat it. Do not re-run schedule_wakeup or list_agents every turn; once is enough. Browser mode: ${chromeStatus().mode}.${chromeStatus().hint ? ` This Chrome is NOT the human's browser: a separate profile with no logins. If the human asks for their logged-in Chrome / Google / X / "the same chrome", do NOT open pages in this one and do NOT spawn anything — reply with exactly this and stop: ${chromeStatus().hint}` : ' This is the human\'s real logged-in browser; do not log out or change its settings.'}`
2730
+ ? `For any web page or HTML form, use chrome-devtools tools (navigate_page / take_snapshot / fill / click). Do NOT use osascript, Quartz, Python Foundation, or AppleScript to read Brave. screenshot/click/type_text are for native Mac UI only. On heavy pages (x.com, gmail, big SPAs) take_snapshot can hang: prefer evaluate_script that returns only the text you need, or take_screenshot; a tool that times out says so — do not repeat it. Do not re-run schedule_wakeup or list_agents every turn; once is enough. Browser mode: ${chromeStatus().mode}.${brave ? ' A SECOND real browser is attached: brave-devtools__* tools drive the human\'s logged-in Brave the same way; use whichever your brief names.' : ''}${chromeStatus().hint ? ` This Chrome is NOT the human's browser: a separate profile with no logins. If the human asks for their logged-in Chrome / Google / X / "the same chrome", do NOT open pages in this one and do NOT spawn anything — reply with exactly this and stop: ${chromeStatus().hint}` : ' This is the human\'s real logged-in browser; do not log out or change its settings.'}`
2663
2731
  : 'Use matching MCP tools instead of inventing shell one-liners.',
2664
2732
  ].join(' ');
2665
2733
  })(),
package/lib/mcpbridge.js CHANGED
@@ -22,6 +22,7 @@ let started = null;
22
22
  let serversUp = [];
23
23
  let chromeCfg = null;
24
24
  let lastReattachCheck = 0;
25
+ let lastBraveCheck = 0;
25
26
 
26
27
  export function hostMcpTools() {
27
28
  return openaiTools;
@@ -208,6 +209,19 @@ export function loadHostMcpConfigs(home = os.homedir()) {
208
209
  url: '',
209
210
  });
210
211
  }
212
+ // BRAVE AS A SECOND BROWSER. When Brave has flipped brave://inspect
213
+ // remote debugging it writes DevToolsActivePort like Chrome does; a second
214
+ // chrome-devtools-mcp attaches to it by --browserUrl and its tools land as
215
+ // brave-devtools__*. Half the X bots drive Brave, half drive Chrome.
216
+ const bravePort = readActivePort(path.join(home, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'));
217
+ if (bravePort && !seen.has('brave-devtools')) {
218
+ out.push({
219
+ name: 'brave-devtools',
220
+ command: 'npx',
221
+ args: ['-y', 'chrome-devtools-mcp@latest', '--browserUrl', `http://127.0.0.1:${bravePort}`],
222
+ url: '',
223
+ });
224
+ }
211
225
  return out;
212
226
  }
213
227
 
@@ -273,12 +287,12 @@ function readActivePort(dir) {
273
287
  }
274
288
 
275
289
  /** Pure: decide the chrome-devtools-mcp argv from what is observable. */
276
- export function chromeArgsFor(baseArgs, { chromePort = 0, bravePort = 0, openPorts = [] } = {}) {
290
+ export function chromeArgsFor(baseArgs, { chromePort = 0, bravePort = 0, openPorts = [], hasOwnBraveServer = false } = {}) {
277
291
  const args = [...(baseArgs || ['-y', 'chrome-devtools-mcp@latest'])];
278
292
  const has = (flag) => args.some((a) => String(a) === flag || String(a).startsWith(`${flag}=`));
279
293
  if (has('--browserUrl') || has('--autoConnect') || has('--wsEndpoint')) return { args, mode: 'explicit', detail: '' };
280
294
  if (chromePort) { args.push('--autoConnect'); return { args, mode: 'real-chrome', detail: `Chrome DevToolsActivePort ${chromePort}` }; }
281
- if (bravePort) { args.push('--browserUrl', `http://127.0.0.1:${bravePort}`); return { args, mode: 'real-brave', detail: `Brave DevToolsActivePort ${bravePort}` }; }
295
+ if (bravePort && !hasOwnBraveServer) { args.push('--browserUrl', `http://127.0.0.1:${bravePort}`); return { args, mode: 'real-brave', detail: `Brave DevToolsActivePort ${bravePort}` }; }
282
296
  const port = openPorts.find((n) => n === 9222 || n === 9333);
283
297
  if (port) { args.push('--browserUrl', `http://127.0.0.1:${port}`); return { args, mode: `attached:${port}`, detail: `browser listening on ${port}` }; }
284
298
  return { args, mode: 'own-profile', detail: '~/.cache/chrome-devtools-mcp/chrome-profile' };
@@ -293,7 +307,7 @@ async function chromeArgs(baseArgs, home = os.homedir()) {
293
307
  if (bravePort && !(await portOpen(bravePort))) bravePort = 0;
294
308
  const openPorts = [];
295
309
  for (const port of [9222, 9333]) if (await portOpen(port)) openPorts.push(port);
296
- const got = chromeArgsFor(baseArgs, { chromePort, bravePort, openPorts });
310
+ const got = chromeArgsFor(baseArgs, { chromePort, bravePort, openPorts, hasOwnBraveServer: true });
297
311
  chromeMode = { mode: got.mode, detail: got.detail };
298
312
  return got.args;
299
313
  }
@@ -345,8 +359,33 @@ async function connectOne(cfg, log) {
345
359
  * chrome-devtools for one attached to their real browser, no restart.
346
360
  * Cheap (two stats) — called at the top of every zoo turn, throttled 5s.
347
361
  */
362
+ async function ensureBraveServer(log, home) {
363
+ if (!started || serversUp.includes('brave-devtools')) return false;
364
+ const bravePort = readActivePort(path.join(home, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'));
365
+ if (!bravePort || !(await portOpen(bravePort))) return false;
366
+ const cfg = { name: 'brave-devtools', command: 'npx', args: ['-y', 'chrome-devtools-mcp@latest', '--browserUrl', `http://127.0.0.1:${bravePort}`], url: '' };
367
+ log(`cursor-backend: mcp brave-devtools Brave appeared on ${bravePort} — attaching`);
368
+ try {
369
+ const got = await connectOne(cfg, log);
370
+ serversUp.push(cfg.name);
371
+ for (const tool of got.tools) {
372
+ registry.set(toolOpenaiName(cfg.name, tool.name), {
373
+ client: got.client, server: cfg.name, tool: tool.name, description: tool.description || tool.name, schema: tool.inputSchema,
374
+ });
375
+ }
376
+ rebuildOpenai();
377
+ log(`cursor-backend: mcp brave-devtools attached tools=${got.tools.length}`);
378
+ return true;
379
+ } catch (e) {
380
+ log(`cursor-backend: mcp brave-devtools attach FAIL ${e.message}`);
381
+ return false;
382
+ }
383
+ }
384
+
348
385
  export async function reattachChrome({ log = () => {}, home = os.homedir() } = {}) {
349
386
  if (!chromeCfg || !started) return false;
387
+ const now0 = Date.now();
388
+ if (now0 - lastBraveCheck > 5000) { lastBraveCheck = now0; await ensureBraveServer(log, home); }
350
389
  if (/^(real-chrome|real-brave|explicit)$/.test(chromeMode.mode)) return false;
351
390
  const now = Date.now();
352
391
  if (now - lastReattachCheck < 5000) return false;
@@ -395,9 +434,22 @@ function rebuildOpenai() {
395
434
  * hangs must come back as an error string the model can route around.
396
435
  */
397
436
  export const MCP_CALL_TIMEOUT_MS = Math.max(10_000, Number(process.env.OZ_MCP_CALL_TIMEOUT_MS || 75_000));
437
+ /** One browser, many bots: chrome-devtools calls run one at a time so two
438
+ * turns cannot interleave select_page / click on the same Chrome. */
439
+ const browserChains = new Map(); // server name -> promise chain (one per browser)
398
440
  export async function callHostMcp(name, args, { timeoutMs = MCP_CALL_TIMEOUT_MS } = {}) {
399
441
  const rec = registry.get(String(name || ''));
400
442
  if (!rec) throw new Error(`no mcp tool ${name}`);
443
+ if (/chrome|devtools|browser/i.test(rec.server)) {
444
+ const run = () => callHostMcpNow(name, rec, args, timeoutMs);
445
+ const prev = browserChains.get(rec.server) || Promise.resolve();
446
+ const p = prev.then(run, run);
447
+ browserChains.set(rec.server, p.catch(() => {}));
448
+ return p;
449
+ }
450
+ return callHostMcpNow(name, rec, args, timeoutMs);
451
+ }
452
+ async function callHostMcpNow(name, rec, args, timeoutMs) {
401
453
  let timer;
402
454
  const ceiling = new Promise((_, rej) => {
403
455
  timer = setTimeout(() => rej(new Error(`MCP tool ${name} timed out after ${Math.round(timeoutMs / 1000)}s. The page may be too heavy for that action. Use a narrower one: evaluate_script to pull the text you need, take_screenshot, or navigate_page to a lighter URL. Do not retry the same call.`)), timeoutMs);
package/lib/xclaims.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Shared tweet-id locks for the X reply bots.
3
+ *
4
+ * Thirteen bots search thirteen lanes, and lanes overlap. Before a bot spends
5
+ * a turn drafting a reply it CLAIMS the tweet id here; the claim is atomic
6
+ * on the host (one process, synchronous file write), leased for CLAIM_TTL_MS
7
+ * so a bot that dies mid-reply frees the tweet, and `done` makes it permanent.
8
+ *
9
+ * File: ~/.openzoo/xclaims.json { [tweetId]: { by, name, at, until, done, url } }
10
+ * `done` entries are also appended to the human's ledger
11
+ * (~/openzoo-shim/openzoobot-posted.json) so the old "never reply twice" file
12
+ * keeps working for anything that reads it.
13
+ */
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ export const CLAIM_TTL_MS = Number(process.env.OZ_XCLAIM_TTL_MS || 20 * 60_000);
19
+
20
+ export function claimsPath(home = os.homedir()) {
21
+ return path.join(home, '.openzoo', 'xclaims.json');
22
+ }
23
+
24
+ export function tweetIdFrom(input) {
25
+ const s = String(input || '').trim();
26
+ const m = s.match(/status(?:es)?\/(\d{8,25})/) || s.match(/^(\d{8,25})$/);
27
+ return m ? m[1] : '';
28
+ }
29
+
30
+ export function loadClaims(home = os.homedir()) {
31
+ try {
32
+ const j = JSON.parse(fs.readFileSync(claimsPath(home), 'utf8'));
33
+ return j && typeof j === 'object' && !Array.isArray(j) ? j : {};
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ export function saveClaims(home, claims) {
40
+ const p = claimsPath(home);
41
+ fs.mkdirSync(path.dirname(p), { recursive: true });
42
+ const tmp = `${p}.tmp`;
43
+ fs.writeFileSync(tmp, JSON.stringify(claims, null, 2));
44
+ fs.renameSync(tmp, p);
45
+ return claims;
46
+ }
47
+
48
+ /** Atomic on the host: returns {ok:true} once per live lease, else who holds it. */
49
+ export function claimTweet({ tweet, by, name = '', home = os.homedir(), now = Date.now(), ttlMs = CLAIM_TTL_MS } = {}) {
50
+ const id = tweetIdFrom(tweet);
51
+ if (!id) return { ok: false, error: 'no tweet id (pass the status URL or numeric id)' };
52
+ const claims = loadClaims(home);
53
+ const cur = claims[id];
54
+ if (cur?.done) return { ok: false, id, reason: 'already replied', by: cur.name || cur.by, url: cur.url || '' };
55
+ if (cur && cur.by !== by && Number(cur.until) > now) {
56
+ return { ok: false, id, reason: 'claimed by another bot', by: cur.name || cur.by, until: cur.until };
57
+ }
58
+ claims[id] = { by: String(by || ''), name: String(name || ''), at: now, until: now + ttlMs, done: false, url: '' };
59
+ saveClaims(home, claims);
60
+ return { ok: true, id, until: claims[id].until, renewed: !!cur };
61
+ }
62
+
63
+ export function releaseTweet({ tweet, by, home = os.homedir() } = {}) {
64
+ const id = tweetIdFrom(tweet);
65
+ const claims = loadClaims(home);
66
+ const cur = claims[id];
67
+ if (!cur) return { ok: true, id, released: false };
68
+ if (cur.done) return { ok: false, id, reason: 'already replied' };
69
+ if (cur.by !== by) return { ok: false, id, reason: 'not your claim', by: cur.name || cur.by };
70
+ delete claims[id];
71
+ saveClaims(home, claims);
72
+ return { ok: true, id, released: true };
73
+ }
74
+
75
+ /** Permanent. Also appends to the human's ledger so old readers still see it. */
76
+ export function markDone({ tweet, by, name = '', url = '', lane = '', home = os.homedir(), ledgerPath, now = Date.now() } = {}) {
77
+ const id = tweetIdFrom(tweet);
78
+ if (!id) return { ok: false, error: 'no tweet id' };
79
+ const claims = loadClaims(home);
80
+ const cur = claims[id];
81
+ if (cur?.done && cur.by !== by) return { ok: false, id, reason: 'already replied by another bot', by: cur.name || cur.by };
82
+ claims[id] = { by: String(by || ''), name: String(name || cur?.name || ''), at: cur?.at || now, until: 0, done: true, url: String(url || ''), doneAt: now };
83
+ saveClaims(home, claims);
84
+ const ledger = ledgerPath || path.join(home, 'openzoo-shim', 'openzoobot-posted.json');
85
+ try {
86
+ let j = { posted: [] };
87
+ try { j = JSON.parse(fs.readFileSync(ledger, 'utf8')); } catch { /* fresh */ }
88
+ if (!Array.isArray(j.posted)) j.posted = [];
89
+ j.posted.push({ tweet: `https://x.com/i/status/${id}`, ours: String(url || ''), lane: String(lane || ''), by: String(name || by || ''), at: new Date(now).toISOString() });
90
+ fs.mkdirSync(path.dirname(ledger), { recursive: true });
91
+ fs.writeFileSync(ledger, JSON.stringify(j, null, 2));
92
+ } catch { /* the lock file is the source of truth; the ledger is a courtesy */ }
93
+ return { ok: true, id, url: String(url || '') };
94
+ }
95
+
96
+ /** Everything a bot needs to skip fast: done ids + live claims by others. */
97
+ export function listClaims({ home = os.homedir(), now = Date.now() } = {}) {
98
+ const claims = loadClaims(home);
99
+ const done = [];
100
+ const live = [];
101
+ for (const [id, c] of Object.entries(claims)) {
102
+ if (c.done) done.push(id);
103
+ else if (Number(c.until) > now) live.push({ id, by: c.name || c.by, until: c.until });
104
+ }
105
+ return { done, live, total: Object.keys(claims).length };
106
+ }
107
+
108
+ /**
109
+ * X's composer is a Draft-style contenteditable: one `fill` flattens every
110
+ * newline (measured: "openzoo.fun/core" + "x402 · PAID…" fused into
111
+ * "corex402"). Real keystrokes work: type a line, press Enter, type the next.
112
+ * Pure planner — the host executes these against chrome-devtools / brave-devtools.
113
+ */
114
+ export const X_COMPOSER_SELECTOR = '[data-testid="tweetTextarea_0"], [data-testid="tweetTextarea_0RichTextInputContainer"] [contenteditable="true"], div[role="textbox"][contenteditable="true"]';
115
+ export function composePlan(text) {
116
+ const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n');
117
+ const ops = [{ tool: 'evaluate_script', args: { function: `() => { const el = document.querySelector(${JSON.stringify(X_COMPOSER_SELECTOR)}); if (!el) return { ok: false, error: 'no composer on this page' }; el.focus(); return { ok: true }; }` } }];
118
+ lines.forEach((line, i) => {
119
+ if (i) ops.push({ tool: 'press_key', args: { key: 'Enter' } });
120
+ if (line) ops.push({ tool: 'type_text', args: { text: line } });
121
+ });
122
+ ops.push({ tool: 'evaluate_script', args: { function: `() => { const el = document.querySelector(${JSON.stringify(X_COMPOSER_SELECTOR)}); const t = el ? (el.innerText || el.textContent || '') : ''; return { ok: !!el, lines: t.split('\\n').filter(Boolean).length, text: t.slice(0, 600) }; }` } });
123
+ return { lines: lines.length, ops };
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.64",
3
+ "version": "0.50.66",
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",