openzoo 0.50.48 → 0.50.50

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.
@@ -40,6 +40,8 @@ import { encodeAvailableModels, encodeForMethod, encodeEnsureSandBox, encodeGetG
40
40
  import {
41
41
  accountPodPath, accountAgentsPath, rosterForAccount, rosterForEvent,
42
42
  readHouseRoster, houseAgentsPath, shapeAgent, agentBrief, briefFromName,
43
+ readWakeups, writeWakeups, shapeWakeup, parseWakeupEvery, wantsWakeupCron,
44
+ DEFAULT_WAKEUP_PROMPT,
43
45
  } from './grokbotAccount.js';
44
46
  import { formatSpendFooter, mergeTurnProof } from './spendProof.js';
45
47
  import { prefixVisitorRichText } from './grokbotweb.js';
@@ -1181,10 +1183,132 @@ export function createZooTurnQueue() {
1181
1183
  inflight.delete(id);
1182
1184
  return 1;
1183
1185
  },
1186
+ busy(agentId) {
1187
+ return inflight.has(String(agentId || ''));
1188
+ },
1184
1189
  };
1185
1190
  }
1186
1191
  const zooTurns = createZooTurnQueue();
1187
1192
 
1193
+ const wakeupTimers = new Map();
1194
+ let wakeupLog = () => {};
1195
+ let wakeupsRestored = false;
1196
+
1197
+ function persistWakeups(map) {
1198
+ try { writeWakeups(HOME, map); } catch (e) {
1199
+ wakeupLog(`cursor-backend: wakeups save failed: ${e.message}`);
1200
+ }
1201
+ }
1202
+
1203
+ export function listAgentWakeups() {
1204
+ return readWakeups(HOME);
1205
+ }
1206
+
1207
+ export function cancelAgentWakeup(agentId) {
1208
+ const id = String(agentId || '');
1209
+ const t = wakeupTimers.get(id);
1210
+ if (t) {
1211
+ clearTimeout(t);
1212
+ wakeupTimers.delete(id);
1213
+ }
1214
+ const map = readWakeups(HOME);
1215
+ if (!map[id]) return { ok: true, cancelled: false };
1216
+ delete map[id];
1217
+ persistWakeups(map);
1218
+ return { ok: true, cancelled: true, id };
1219
+ }
1220
+
1221
+ function armWakeup(rec) {
1222
+ const id = rec.agentId;
1223
+ const prev = wakeupTimers.get(id);
1224
+ if (prev) clearTimeout(prev);
1225
+ const delay = Math.max(1000, Number(rec.nextAt) - Date.now());
1226
+ const t = setTimeout(() => {
1227
+ fireAgentWakeup(id).catch((e) => wakeupLog(`cursor-backend: wakeup fire ${id}: ${e.message}`));
1228
+ }, delay);
1229
+ wakeupTimers.set(id, t);
1230
+ }
1231
+
1232
+ export function scheduleAgentWakeup(agentId, opts = {}) {
1233
+ const id = String(agentId || '').trim();
1234
+ if (!id) return { ok: false, error: 'no agent' };
1235
+ const now = Date.now();
1236
+ const every = opts.every ?? opts.everySec;
1237
+ const rec = shapeWakeup(id, {
1238
+ every,
1239
+ prompt: opts.prompt,
1240
+ lastAt: 0,
1241
+ nextAt: now + parseWakeupEvery(every) * 1000,
1242
+ }, now);
1243
+ const map = readWakeups(HOME);
1244
+ map[id] = rec;
1245
+ persistWakeups(map);
1246
+ armWakeup(rec);
1247
+ wakeupLog(`cursor-backend: wakeup every ${rec.everySec}s agent=${id}`);
1248
+ return { ok: true, ...rec };
1249
+ }
1250
+
1251
+ async function fireAgentWakeup(agentId) {
1252
+ const id = String(agentId || '');
1253
+ const map = readWakeups(HOME);
1254
+ const rec = map[id];
1255
+ if (!rec) {
1256
+ wakeupTimers.delete(id);
1257
+ return;
1258
+ }
1259
+ rec.lastAt = Date.now();
1260
+ rec.nextAt = rec.lastAt + rec.everySec * 1000;
1261
+ persistWakeups(map);
1262
+ armWakeup(rec);
1263
+ if (zooTurns.busy(id)) {
1264
+ wakeupLog(`cursor-backend: wakeup skip busy agent=${id}`);
1265
+ return;
1266
+ }
1267
+ const nonce = `oz-wakeup-${id}-${rec.lastAt}`;
1268
+ const prompt = rec.prompt || DEFAULT_WAKEUP_PROMPT;
1269
+ const turn = zooTurns.begin(id, nonce);
1270
+ const userLine = fanoutLine(id, 'user', `[wakeup]\n${prompt}`, { clientNonce: nonce, requestId: nonce });
1271
+ ssePush('transcript', { ...gatewayEntry(userLine), agentId: id });
1272
+ bumpAgent(id, { preview: '[wakeup]', notify: false });
1273
+ wakeupLog(`cursor-backend: wakeup start agent=${id}`);
1274
+ try {
1275
+ const z = await zooComplete(prompt, wakeupLog, id, {}, {
1276
+ signal: turn.signal,
1277
+ onProgress: (note) => {
1278
+ if (!zooTurns.isCurrent(id, nonce)) return;
1279
+ paintChatUpdate(id, note, { clientNonce: nonce, requestId: nonce });
1280
+ },
1281
+ });
1282
+ if (!zooTurns.isCurrent(id, nonce)) return;
1283
+ const line = fanoutLine(id, 'assistant', z.text, { clientNonce: nonce, requestId: nonce });
1284
+ ssePush('transcript', { ...gatewayEntry(line), agentId: id });
1285
+ bumpAgent(id, { preview: z.text, notify: true });
1286
+ wakeupLog(`cursor-backend: wakeup done agent=${id} seq=${line.seq}`);
1287
+ } catch (e) {
1288
+ if (!isSupersededError(e)) wakeupLog(`cursor-backend: wakeup failed agent=${id}: ${e.message}`);
1289
+ } finally {
1290
+ zooTurns.end(id, nonce);
1291
+ }
1292
+ }
1293
+
1294
+ export function restoreAgentWakeups(log = () => {}) {
1295
+ wakeupLog = log;
1296
+ if (wakeupsRestored) return 0;
1297
+ wakeupsRestored = true;
1298
+ const map = readWakeups(HOME);
1299
+ const ids = Object.keys(map);
1300
+ const now = Date.now();
1301
+ ids.forEach((id, i) => {
1302
+ // Do not fire during Grok Bot boot. 10 parallel zoo turns + CDP on the
1303
+ // spinner page left the window on a white disc. First tick is +90s.
1304
+ map[id].nextAt = now + 90_000 + i * 4000;
1305
+ });
1306
+ if (ids.length) persistWakeups(map);
1307
+ for (const id of ids) armWakeup(map[id]);
1308
+ if (ids.length) log(`cursor-backend: wakeups restored ${ids.length}`);
1309
+ return ids.length;
1310
+ }
1311
+
1188
1312
  export function formatZooProgress({ step, maxSteps, names, command } = {}) {
1189
1313
  const tools = Array.isArray(names) ? names.filter(Boolean).join(', ') : String(names || 'tools');
1190
1314
  const cmd = command ? ` ${JSON.stringify(String(command).slice(0, 80))}` : '';
@@ -1195,7 +1319,7 @@ export function formatZooProgress({ step, maxSteps, names, command } = {}) {
1195
1319
  * bubble is still silence. Keep it short; the model history skips ephemeral. */
1196
1320
  export function formatZooToolLine({ name, args = {}, result } = {}) {
1197
1321
  const detail = args.command || args.path || args.name || args.window
1198
- || args.query || args.url || args.key || args.app
1322
+ || args.query || args.url || args.key || args.app || args.every
1199
1323
  || (args.x != null && args.y != null ? `${args.x},${args.y}` : '')
1200
1324
  || (args.text != null ? String(args.text).slice(0, 80) : '');
1201
1325
  const head = detail
@@ -2003,6 +2127,34 @@ const LOCAL_TOOLS = [
2003
2127
  },
2004
2128
  },
2005
2129
  },
2130
+ {
2131
+ type: 'function',
2132
+ function: {
2133
+ name: 'schedule_wakeup',
2134
+ description: 'Host timer that keeps this bot working with no human message. "never stop" / cron. Default every 5m. Min 60s. Does not spawn bots.',
2135
+ parameters: {
2136
+ type: 'object',
2137
+ properties: {
2138
+ every: { type: 'string', description: '5m, 1h, 90s. Default 5m. Floor 60s.' },
2139
+ prompt: { type: 'string', description: 'What to do on each tick.' },
2140
+ agent: { type: 'string', description: 'Name or id. Default: this bot.' },
2141
+ },
2142
+ },
2143
+ },
2144
+ },
2145
+ {
2146
+ type: 'function',
2147
+ function: {
2148
+ name: 'cancel_wakeup',
2149
+ description: 'Stop the host wakeup timer for this bot (or agent).',
2150
+ parameters: {
2151
+ type: 'object',
2152
+ properties: {
2153
+ agent: { type: 'string' },
2154
+ },
2155
+ },
2156
+ },
2157
+ },
2006
2158
  ];
2007
2159
 
2008
2160
  export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
@@ -2151,8 +2303,12 @@ async function runLocalTool(name, args, log, ctx = {}) {
2151
2303
  const agent = mintLocalAgent({ name, brief });
2152
2304
  pushCreatedAgent(agent, { select: args.select !== false });
2153
2305
  seedBriefOnCanvas(agent);
2306
+ const n = (cachedAgentList() || []).length;
2154
2307
  log(`cursor-backend: create_agent tool id=${agent.id} name=${JSON.stringify(agent.name)} brief=${brief ? brief.length : 0}c`);
2155
- return JSON.stringify({ ok: true, id: agent.id, name: agent.name, brief: agent.brief || '' });
2308
+ const warning = n >= 8
2309
+ ? `Mac already has ${n} bots. Prefer schedule_wakeup over spawning. Only mint a NEW named role.`
2310
+ : undefined;
2311
+ return JSON.stringify({ ok: true, id: agent.id, name: agent.name, brief: agent.brief || '', warning });
2156
2312
  }
2157
2313
  if (name === 'set_brief') {
2158
2314
  const who = findAgent(args.agent || args.id || args.name) || findAgent(ctx.agentId);
@@ -2162,10 +2318,12 @@ async function runLocalTool(name, args, log, ctx = {}) {
2162
2318
  }
2163
2319
  if (name === 'list_agents') {
2164
2320
  const list = cachedAgentList() || [];
2321
+ const wakes = readWakeups(HOME);
2165
2322
  return JSON.stringify(list.map((a) => ({
2166
2323
  id: a.id,
2167
2324
  name: a.name,
2168
2325
  brief: String(a.brief || '').slice(0, 120),
2326
+ wakeup: wakes[a.id] ? { everySec: wakes[a.id].everySec, nextAt: wakes[a.id].nextAt } : null,
2169
2327
  })));
2170
2328
  }
2171
2329
  if (name === 'message_agent') {
@@ -2177,6 +2335,17 @@ async function runLocalTool(name, args, log, ctx = {}) {
2177
2335
  log,
2178
2336
  });
2179
2337
  }
2338
+ if (name === 'schedule_wakeup') {
2339
+ const who = findAgent(args.agent || args.id || args.name) || findAgent(ctx.agentId);
2340
+ if (!who) return 'ERROR no such agent';
2341
+ const got = scheduleAgentWakeup(who.id, { every: args.every, prompt: args.prompt });
2342
+ return JSON.stringify(got);
2343
+ }
2344
+ if (name === 'cancel_wakeup') {
2345
+ const who = findAgent(args.agent || args.id || args.name) || findAgent(ctx.agentId);
2346
+ if (!who) return 'ERROR no such agent';
2347
+ return JSON.stringify(cancelAgentWakeup(who.id));
2348
+ }
2180
2349
  return `unknown tool ${name}`;
2181
2350
  } catch (e) {
2182
2351
  return `ERROR ${e.message}`;
@@ -2273,8 +2442,9 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2273
2442
  return brief ? `${who} Standing brief (persisted): ${brief}` : who;
2274
2443
  })(),
2275
2444
  `You HAVE local tools on the user's computer via ${via}.`,
2276
- '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.',
2445
+ '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.',
2277
2446
  'create_agent mints a sidebar bot. ALWAYS pass brief so they keep the job across restart. set_brief updates it. list_agents + message_agent talk to other bots (one hop). Do not tell the human to copy-paste between canvases.',
2447
+ 'NEVER STOP / cron / keep working between human messages: call schedule_wakeup every="5m". That is a host timer. There is no crontab. Do not spawn more bots for persistence. Do not re-read SITREP-NOW.md or STANDING-ORDERS.md every turn — do the next file or click. exec sysctl/uptime is not the job. If the tray already has named workers, answer "no" to spawning more.',
2278
2448
  'You CAN click the Mac and fill/submit browser forms. That is required when the user asks. Do not write a markdown briefing instead of clicking. Do not tell the user to click.',
2279
2449
  'Form loop: focus_app Brave Browser → screenshot → click the field (query or x,y) → type_text → screenshot to confirm → click Submit / key enter. screenshot.screen is click coordinate space. Prefer click query="Submit".',
2280
2450
  '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.',
@@ -2805,6 +2975,10 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2805
2975
  log(`cursor-backend: >> sendPrompt agent=${agentId} keys=${Object.keys(parsed).join(',')} attachments=${attN}${visitor ? ` visitor=${visitor.shortname}` : ''} prompt=${JSON.stringify((prompt || '').slice(0, 80))}`);
2806
2976
  lastSendEchoId = String(nonce);
2807
2977
  jsonSend(res, { accepted: true });
2978
+ if (!visitor && wantsWakeupCron(prompt)) {
2979
+ const cron = scheduleAgentWakeup(agentId, { every: '5m' });
2980
+ log(`cursor-backend: never-stop cron agent=${agentId} every=${cron.everySec}s`);
2981
+ }
2808
2982
  const turn = zooTurns.begin(agentId, nonce);
2809
2983
  const userLine = fanoutLine(agentId, 'user', uiText, {
2810
2984
  clientNonce: nonce,
@@ -3013,6 +3187,7 @@ function respond(req, res, method, models) {
3013
3187
  * unprivileged). Returns { server, port }.
3014
3188
  */
3015
3189
  export function startCursorBackend({ port = 8443, models, log = () => {} } = {}) {
3190
+ restoreAgentWakeups(log);
3016
3191
  const { cert, key } = ensureCert(log);
3017
3192
  const certPem = fs.readFileSync(cert);
3018
3193
  const keyPem = fs.readFileSync(key);
@@ -184,6 +184,72 @@ export function rosterForEvent(list, activity) {
184
184
  });
185
185
  }
186
186
 
187
+ export const WAKEUP_MIN_SEC = 60;
188
+ export const WAKEUP_MAX_SEC = 6 * 3600;
189
+ export const WAKEUP_DEFAULT_SEC = 5 * 60;
190
+ export const DEFAULT_WAKEUP_PROMPT = 'Wakeup. Continue your brief. Do the next real action (write a file, click, or message a worker). Do not only re-read sitrep. Do not spawn more bots. Do not exec sysctl/uptime.';
191
+
192
+ export function wakeupsPath(home) {
193
+ return path.join(home, '.openzoo', 'grokbot-wakeups.json');
194
+ }
195
+
196
+ /** "5m" / "1h" / "90" / "30s". Floor 60s so a never-stop cron cannot storm. */
197
+ export function parseWakeupEvery(raw) {
198
+ const s = String(raw ?? '').trim().toLowerCase();
199
+ if (!s) return WAKEUP_DEFAULT_SEC;
200
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?)?$/);
201
+ if (!m) return WAKEUP_DEFAULT_SEC;
202
+ const n = Number(m[1]);
203
+ if (!Number.isFinite(n) || n <= 0) return WAKEUP_DEFAULT_SEC;
204
+ const u = m[2] || 's';
205
+ let sec = n;
206
+ if (/^m/.test(u)) sec = n * 60;
207
+ else if (/^h/.test(u)) sec = n * 3600;
208
+ return Math.min(WAKEUP_MAX_SEC, Math.max(WAKEUP_MIN_SEC, Math.round(sec)));
209
+ }
210
+
211
+ export function wantsWakeupCron(prompt) {
212
+ const s = String(prompt || '');
213
+ if (/\bcron\b.{0,48}\bwakeups?\b/i.test(s)) return true;
214
+ if (/\bwakeups?\b.{0,48}\bcron\b/i.test(s)) return true;
215
+ if (/\bnever stop\b/i.test(s)) return true;
216
+ if (/\bschedule\b.{0,24}\bwakeups?\b/i.test(s)) return true;
217
+ return false;
218
+ }
219
+
220
+ export function shapeWakeup(agentId, rec = {}, now = Date.now()) {
221
+ const everySec = parseWakeupEvery(rec.everySec ?? rec.every ?? rec.interval);
222
+ const prompt = String(rec.prompt || DEFAULT_WAKEUP_PROMPT).trim().slice(0, 2000)
223
+ || DEFAULT_WAKEUP_PROMPT;
224
+ const lastAt = Number(rec.lastAt) || 0;
225
+ let nextAt = Number(rec.nextAt) || 0;
226
+ if (!Number.isFinite(nextAt) || nextAt <= 0) nextAt = now + everySec * 1000;
227
+ return {
228
+ agentId: String(agentId || rec.agentId || ''),
229
+ everySec,
230
+ prompt,
231
+ lastAt,
232
+ nextAt,
233
+ };
234
+ }
235
+
236
+ export function readWakeups(home) {
237
+ const raw = readJsonFile(wakeupsPath(home));
238
+ const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
239
+ const out = {};
240
+ for (const [id, rec] of Object.entries(src)) {
241
+ const w = shapeWakeup(id, rec);
242
+ if (w.agentId) out[w.agentId] = w;
243
+ }
244
+ return out;
245
+ }
246
+
247
+ export function writeWakeups(home, map) {
248
+ const dir = path.dirname(wakeupsPath(home));
249
+ fs.mkdirSync(dir, { recursive: true });
250
+ fs.writeFileSync(wakeupsPath(home), JSON.stringify(map || {}, null, 2));
251
+ }
252
+
187
253
  export function callerKeyFromAuth(authorization) {
188
254
  const a = String(authorization || '').trim();
189
255
  if (!a) return '';
package/lib/grokcli.js CHANGED
@@ -110,7 +110,7 @@ const GROK_BOT_BIN = `${APP}/Contents/MacOS/Grok Bot`;
110
110
  /** pids whose command is the Grok Bot main binary (not grep itself). */
111
111
  export function grokBotPids(run = execSync) {
112
112
  try {
113
- return run(`pgrep -f ${JSON.stringify(GROK_BOT_BIN)}`, { encoding: 'utf8' })
113
+ return run(`pgrep -f ${JSON.stringify(GROK_BOT_BIN)}`, { encoding: 'utf8', shell: true })
114
114
  .trim().split('\n').map((s) => s.trim()).filter(Boolean);
115
115
  } catch {
116
116
  return [];
@@ -296,7 +296,7 @@ export async function runBot(argv = []) {
296
296
  await new Promise((r) => setTimeout(r, 1500));
297
297
  }
298
298
 
299
- if (plan.spawn) {
299
+ const launchGrokBot = () => {
300
300
  console.error('openzoo: launching Grok Bot');
301
301
  console.error(` CURSOR_API_BASE_URL=${url}`);
302
302
  spawn(bin, grokBotChromiumArgs(), {
@@ -315,14 +315,14 @@ export async function runBot(argv = []) {
315
315
  SAND_HOST_GATEWAY_NETWORK_TOKEN: 'openzoo',
316
316
  },
317
317
  }).unref();
318
- } else {
319
- console.error('openzoo: Grok Bot already hijacked — not spawning another copy');
320
- }
318
+ };
319
+ if (plan.spawn) launchGrokBot();
320
+ else console.error('openzoo: Grok Bot already hijacked — not spawning another copy');
321
321
 
322
322
  injectSpendChipInBackground({
323
323
  port: GROKBOT_CDP_PORT,
324
324
  log: (m) => console.error(m),
325
- delayMs: plan.spawn ? 500 : 200,
325
+ delayMs: plan.spawn ? 8000 : 2000,
326
326
  });
327
327
 
328
328
  console.error('openzoo: leave this running. ctrl-c stops the backend.');
@@ -7,9 +7,27 @@
7
7
  */
8
8
  import net from 'node:net';
9
9
  import crypto from 'node:crypto';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { spendChipLabel } from './spendProof.js';
10
14
 
11
15
  export const GROKBOT_CDP_PORT = Number(process.env.OZ_GROKBOT_CDP_PORT || 9444);
12
16
 
17
+ /** Session totals for a floating pill when the open canvas has no footer. */
18
+ export function sessionSpendLabel(home = os.homedir()) {
19
+ try {
20
+ const s = JSON.parse(fs.readFileSync(path.join(home, '.openzoo', 'session.json'), 'utf8'));
21
+ const spent = Number(s.spentUsd || s.spendUsd || 0);
22
+ const would = Number(s.directUsd || 0);
23
+ const saved = Number(s.savedUsd != null ? s.savedUsd : Math.max(0, would - spent));
24
+ if (!(spent > 0.00005)) return '';
25
+ return spendChipLabel({ spent, would, saved, pct: would > 0 ? (100 * saved) / would : 0 });
26
+ } catch {
27
+ return '';
28
+ }
29
+ }
30
+
13
31
  export function grokBotChromiumArgs(port = GROKBOT_CDP_PORT) {
14
32
  return [
15
33
  '--ignore-certificate-errors',
@@ -133,9 +151,12 @@ export function spendOnlyText(t) {
133
151
  }
134
152
 
135
153
  function ozEnsureSpendCss() {
136
- if (document.getElementById('oz-spend-css')) return;
137
- const s = document.createElement('style');
138
- s.id = 'oz-spend-css';
154
+ let s = document.getElementById('oz-spend-css');
155
+ if (!s) {
156
+ s = document.createElement('style');
157
+ s.id = 'oz-spend-css';
158
+ (document.head || document.documentElement).appendChild(s);
159
+ }
139
160
  s.textContent = [
140
161
  '.oz-spend{margin:.55rem 0 0;font-size:12px;color:inherit;opacity:.82;max-width:36em}',
141
162
  '.oz-spend>summary{cursor:help;list-style:none;display:inline-flex;align-items:center;gap:.35rem;',
@@ -144,8 +165,9 @@ function ozEnsureSpendCss() {
144
165
  '.oz-spend>summary::-webkit-details-marker{display:none}',
145
166
  '.oz-spend-body{white-space:pre-wrap;margin:.55rem 0 0;font-size:11px;line-height:1.45;opacity:.88;overflow-wrap:anywhere}',
146
167
  '[data-oz-spend-hide]{display:none!important}',
168
+ 'button[aria-label="Start voice input"],button[aria-label*="voice input" i],',
169
+ 'button[aria-label*="steminvoer" i],button[aria-label^="Microphone"]{display:none!important}',
147
170
  ].join('');
148
- (document.head || document.documentElement).appendChild(s);
149
171
  }
150
172
 
151
173
  function ozSpendHost(el) {
@@ -292,6 +314,52 @@ function ozCollapseSpend() {
292
314
  ozAttachSpendChip(host, split);
293
315
  }
294
316
  ozHideSpendLeftovers();
317
+ ozEnsureFloatSpend();
318
+ }
319
+
320
+ function ozEnsureFloatSpend() {
321
+ const label = String(window.__OZ_SESSION_SPEND__ || '').trim();
322
+ const msgPills = document.querySelectorAll('.oz-spend:not(#oz-spend-float)').length;
323
+ let el = document.getElementById('oz-spend-float');
324
+ if (!label || msgPills) {
325
+ if (el) el.remove();
326
+ return;
327
+ }
328
+ ozEnsureSpendCss();
329
+ if (!el) {
330
+ el = document.createElement('details');
331
+ el.id = 'oz-spend-float';
332
+ el.className = 'oz-spend';
333
+ const sum = document.createElement('summary');
334
+ const body = document.createElement('div');
335
+ body.className = 'oz-spend-body';
336
+ body.textContent = 'session spend (this openzoo bot)';
337
+ el.appendChild(sum);
338
+ el.appendChild(body);
339
+ document.body.appendChild(el);
340
+ }
341
+ ozPlaceFloat(el);
342
+ const sum = el.querySelector('summary');
343
+ if (sum) sum.textContent = 'ⓘ ' + label;
344
+ }
345
+
346
+ function ozPlaceFloat(el) {
347
+ if (!el) return;
348
+ const ta = document.querySelector('textarea, [contenteditable="true"]');
349
+ const box = ta && ta.getBoundingClientRect();
350
+ el.style.position = 'fixed';
351
+ el.style.right = 'auto';
352
+ el.style.zIndex = '2147483646';
353
+ el.style.opacity = '0.95';
354
+ if (box && box.width > 80 && box.top > 48) {
355
+ el.style.left = Math.round(box.left) + 'px';
356
+ el.style.top = Math.round(Math.max(8, box.top - 56)) + 'px';
357
+ el.style.bottom = 'auto';
358
+ } else {
359
+ el.style.left = '96px';
360
+ el.style.bottom = '72px';
361
+ el.style.top = 'auto';
362
+ }
295
363
  }
296
364
 
297
365
  function ozWatchSpend() {
@@ -305,14 +373,27 @@ function ozWatchSpend() {
305
373
  else document.addEventListener('DOMContentLoaded', start);
306
374
  } catch { /* */ }
307
375
  setInterval(run, 1500);
376
+ try { window.addEventListener('resize', run); } catch (e) {}
308
377
  }
309
378
 
310
379
  export function spendChipSource() {
311
380
  return [
312
381
  '(function ozSpendChip(){',
313
382
  "'use strict';",
314
- 'if (window.__OZ_SPEND_CHIP__ === 4) return;',
315
- 'window.__OZ_SPEND_CHIP__ = 4;',
383
+ 'if (window.__OZ_SPEND_CHIP__ === 10) {',
384
+ ' const el = document.getElementById("oz-spend-float");',
385
+ ' const ta = document.querySelector("textarea, [contenteditable=\\"true\\"]");',
386
+ ' const box = ta && ta.getBoundingClientRect();',
387
+ ' if (el && box && box.width > 80) {',
388
+ ' el.style.position = "fixed";',
389
+ ' el.style.left = Math.round(box.left) + "px";',
390
+ ' el.style.top = Math.round(Math.max(8, box.top - 56)) + "px";',
391
+ ' el.style.right = "auto";',
392
+ ' el.style.bottom = "auto";',
393
+ ' }',
394
+ ' return;',
395
+ '}',
396
+ 'window.__OZ_SPEND_CHIP__ = 10;',
316
397
  chipUsd.toString(),
317
398
  labelFromSpendBody.toString(),
318
399
  spendLinesOnly.toString(),
@@ -327,6 +408,8 @@ export function spendChipSource() {
327
408
  ozPreviousMessageCard.toString(),
328
409
  ozAttachSpendChip.toString(),
329
410
  ozCollapseSpend.toString(),
411
+ ozEnsureFloatSpend.toString(),
412
+ ozPlaceFloat.toString(),
330
413
  ozWatchSpend.toString(),
331
414
  'ozWatchSpend();',
332
415
  '})();',
@@ -511,12 +594,24 @@ export async function listCdpPages(port, fetchImpl = fetch) {
511
594
  return [];
512
595
  }
513
596
 
514
- async function injectTarget(target, source, connect) {
597
+ async function injectTarget(target, source, connect, { waitForUi = false } = {}) {
515
598
  const session = await connect(target.webSocketDebuggerUrl);
516
599
  try {
517
600
  await session.send('Page.enable').catch(() => {});
518
601
  await session.send('Runtime.enable').catch(() => {});
519
602
  await session.send('Page.addScriptToEvaluateOnNewDocument', { source }).catch(() => {});
603
+ if (waitForUi) {
604
+ for (let i = 0; i < 32; i += 1) {
605
+ try {
606
+ const r = await session.send('Runtime.evaluate', {
607
+ expression: '!!document.querySelector(\'[class*="sand-"], textarea, [contenteditable="true"]\')',
608
+ returnByValue: true,
609
+ });
610
+ if (r?.result?.value) break;
611
+ } catch { /* still booting */ }
612
+ await new Promise((ok) => setTimeout(ok, 250));
613
+ }
614
+ }
520
615
  await session.send('Runtime.evaluate', { expression: source, awaitPromise: false });
521
616
  } finally {
522
617
  session.close();
@@ -531,6 +626,7 @@ export async function injectSpendChip({
531
626
  connect = cdpSession,
532
627
  tries = 40,
533
628
  delayMs = 400,
629
+ waitForUi = false,
534
630
  } = {}) {
535
631
  let targets = [];
536
632
  for (let i = 0; i < Math.max(1, tries); i += 1) {
@@ -542,10 +638,12 @@ export async function injectSpendChip({
542
638
  log(`openzoo: spend chip CDP :${port} has no pages yet`);
543
639
  return { ok: false, injected: 0 };
544
640
  }
641
+ const label = sessionSpendLabel();
642
+ const src = `window.__OZ_SESSION_SPEND__=${JSON.stringify(label)};\n${source}`;
545
643
  let injected = 0;
546
644
  for (const t of targets) {
547
645
  try {
548
- await injectTarget(t, source, connect);
646
+ await injectTarget(t, src, connect, { waitForUi });
549
647
  injected += 1;
550
648
  } catch (e) {
551
649
  log(`openzoo: spend chip inject ${e.message}`);
@@ -555,11 +653,23 @@ export async function injectSpendChip({
555
653
  return { ok: injected > 0, injected };
556
654
  }
557
655
 
558
- /** Fire-and-forget wrapper used by `openzoo bot`. */
656
+ /** One CDP attach, then drop the debugger. A loop here pauses the renderer
657
+ * so the composer accepts a keystroke and then dies. Reloads pick the chip
658
+ * up via Page.addScriptToEvaluateOnNewDocument from that single inject. */
559
659
  export function injectSpendChipInBackground(opts = {}) {
560
660
  const log = opts.log || ((m) => console.error(m));
561
- injectSpendChip({ ...opts, log }).catch((e) => {
562
- log(`openzoo: spend chip inject failed ${e.message}`);
563
- });
661
+ const wait = Math.max(0, Number(opts.delayMs) || 400);
662
+ setTimeout(() => {
663
+ injectSpendChip({
664
+ tries: 24,
665
+ delayMs: 400,
666
+ waitForUi: true,
667
+ ...opts,
668
+ log,
669
+ connect: (ws) => cdpSession(ws, { timeoutMs: 2500 }),
670
+ }).catch((e) => {
671
+ log(`openzoo: spend chip inject failed ${e.message}`);
672
+ });
673
+ }, wait);
564
674
  }
565
675
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.48",
3
+ "version": "0.50.50",
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",