openzoo 0.50.48 → 0.50.49

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,130 @@ 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
+ map[id].nextAt = now + 5000 + i * 2000;
1303
+ });
1304
+ if (ids.length) persistWakeups(map);
1305
+ for (const id of ids) armWakeup(map[id]);
1306
+ if (ids.length) log(`cursor-backend: wakeups restored ${ids.length}`);
1307
+ return ids.length;
1308
+ }
1309
+
1188
1310
  export function formatZooProgress({ step, maxSteps, names, command } = {}) {
1189
1311
  const tools = Array.isArray(names) ? names.filter(Boolean).join(', ') : String(names || 'tools');
1190
1312
  const cmd = command ? ` ${JSON.stringify(String(command).slice(0, 80))}` : '';
@@ -1195,7 +1317,7 @@ export function formatZooProgress({ step, maxSteps, names, command } = {}) {
1195
1317
  * bubble is still silence. Keep it short; the model history skips ephemeral. */
1196
1318
  export function formatZooToolLine({ name, args = {}, result } = {}) {
1197
1319
  const detail = args.command || args.path || args.name || args.window
1198
- || args.query || args.url || args.key || args.app
1320
+ || args.query || args.url || args.key || args.app || args.every
1199
1321
  || (args.x != null && args.y != null ? `${args.x},${args.y}` : '')
1200
1322
  || (args.text != null ? String(args.text).slice(0, 80) : '');
1201
1323
  const head = detail
@@ -2003,6 +2125,34 @@ const LOCAL_TOOLS = [
2003
2125
  },
2004
2126
  },
2005
2127
  },
2128
+ {
2129
+ type: 'function',
2130
+ function: {
2131
+ name: 'schedule_wakeup',
2132
+ description: 'Host timer that keeps this bot working with no human message. "never stop" / cron. Default every 5m. Min 60s. Does not spawn bots.',
2133
+ parameters: {
2134
+ type: 'object',
2135
+ properties: {
2136
+ every: { type: 'string', description: '5m, 1h, 90s. Default 5m. Floor 60s.' },
2137
+ prompt: { type: 'string', description: 'What to do on each tick.' },
2138
+ agent: { type: 'string', description: 'Name or id. Default: this bot.' },
2139
+ },
2140
+ },
2141
+ },
2142
+ },
2143
+ {
2144
+ type: 'function',
2145
+ function: {
2146
+ name: 'cancel_wakeup',
2147
+ description: 'Stop the host wakeup timer for this bot (or agent).',
2148
+ parameters: {
2149
+ type: 'object',
2150
+ properties: {
2151
+ agent: { type: 'string' },
2152
+ },
2153
+ },
2154
+ },
2155
+ },
2006
2156
  ];
2007
2157
 
2008
2158
  export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
@@ -2151,8 +2301,12 @@ async function runLocalTool(name, args, log, ctx = {}) {
2151
2301
  const agent = mintLocalAgent({ name, brief });
2152
2302
  pushCreatedAgent(agent, { select: args.select !== false });
2153
2303
  seedBriefOnCanvas(agent);
2304
+ const n = (cachedAgentList() || []).length;
2154
2305
  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 || '' });
2306
+ const warning = n >= 8
2307
+ ? `Mac already has ${n} bots. Prefer schedule_wakeup over spawning. Only mint a NEW named role.`
2308
+ : undefined;
2309
+ return JSON.stringify({ ok: true, id: agent.id, name: agent.name, brief: agent.brief || '', warning });
2156
2310
  }
2157
2311
  if (name === 'set_brief') {
2158
2312
  const who = findAgent(args.agent || args.id || args.name) || findAgent(ctx.agentId);
@@ -2162,10 +2316,12 @@ async function runLocalTool(name, args, log, ctx = {}) {
2162
2316
  }
2163
2317
  if (name === 'list_agents') {
2164
2318
  const list = cachedAgentList() || [];
2319
+ const wakes = readWakeups(HOME);
2165
2320
  return JSON.stringify(list.map((a) => ({
2166
2321
  id: a.id,
2167
2322
  name: a.name,
2168
2323
  brief: String(a.brief || '').slice(0, 120),
2324
+ wakeup: wakes[a.id] ? { everySec: wakes[a.id].everySec, nextAt: wakes[a.id].nextAt } : null,
2169
2325
  })));
2170
2326
  }
2171
2327
  if (name === 'message_agent') {
@@ -2177,6 +2333,17 @@ async function runLocalTool(name, args, log, ctx = {}) {
2177
2333
  log,
2178
2334
  });
2179
2335
  }
2336
+ if (name === 'schedule_wakeup') {
2337
+ const who = findAgent(args.agent || args.id || args.name) || findAgent(ctx.agentId);
2338
+ if (!who) return 'ERROR no such agent';
2339
+ const got = scheduleAgentWakeup(who.id, { every: args.every, prompt: args.prompt });
2340
+ return JSON.stringify(got);
2341
+ }
2342
+ if (name === 'cancel_wakeup') {
2343
+ const who = findAgent(args.agent || args.id || args.name) || findAgent(ctx.agentId);
2344
+ if (!who) return 'ERROR no such agent';
2345
+ return JSON.stringify(cancelAgentWakeup(who.id));
2346
+ }
2180
2347
  return `unknown tool ${name}`;
2181
2348
  } catch (e) {
2182
2349
  return `ERROR ${e.message}`;
@@ -2273,8 +2440,9 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2273
2440
  return brief ? `${who} Standing brief (persisted): ${brief}` : who;
2274
2441
  })(),
2275
2442
  `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.',
2443
+ '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
2444
  '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.',
2445
+ '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
2446
  '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
2447
  '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
2448
  '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 +2973,10 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2805
2973
  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
2974
  lastSendEchoId = String(nonce);
2807
2975
  jsonSend(res, { accepted: true });
2976
+ if (!visitor && wantsWakeupCron(prompt)) {
2977
+ const cron = scheduleAgentWakeup(agentId, { every: '5m' });
2978
+ log(`cursor-backend: never-stop cron agent=${agentId} every=${cron.everySec}s`);
2979
+ }
2808
2980
  const turn = zooTurns.begin(agentId, nonce);
2809
2981
  const userLine = fanoutLine(agentId, 'user', uiText, {
2810
2982
  clientNonce: nonce,
@@ -3013,6 +3185,7 @@ function respond(req, res, method, models) {
3013
3185
  * unprivileged). Returns { server, port }.
3014
3186
  */
3015
3187
  export function startCursorBackend({ port = 8443, models, log = () => {} } = {}) {
3188
+ restoreAgentWakeups(log);
3016
3189
  const { cert, key } = ensureCert(log);
3017
3190
  const certPem = fs.readFileSync(cert);
3018
3191
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.48",
3
+ "version": "0.50.49",
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",