openzoo 0.50.95 → 0.50.97

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.
@@ -250,6 +250,7 @@ export function shapeAgent(raw = {}) {
250
250
  updatedAt: Number(a.updatedAt) || Date.now(),
251
251
  hasUnread: !!a.hasUnread,
252
252
  unreadCount: Number(a.unreadCount) || 0,
253
+ hidden: a.hidden === true || a.hiddenFromSidebar === true, // GROKROOM-HIDDEN-BOTS
253
254
  notificationsEnabled: a.notificationsEnabled !== false,
254
255
  notifyOnUpdatesEnabled: a.notifyOnUpdatesEnabled !== false,
255
256
  isGroup,
@@ -267,8 +268,9 @@ export function shapeAgent(raw = {}) {
267
268
  * Full sidebar roster for SSE `agents` / listAgents. Sorts by activity and
268
269
  * stamps unread -- does not slice. The old 80-cap hid agents past the tray.
269
270
  */
270
- export function rosterForEvent(list, activity) {
271
- const arr = Array.isArray(list) ? [...list] : [];
271
+ export function rosterForEvent(list, activity, opts) {
272
+ // GROKROOM-HIDDEN-BOTS: client-facing rosters never include hidden bots; persistence passes { includeHidden: true }
273
+ const arr = (Array.isArray(list) ? [...list] : []).filter((a) => (opts && opts.includeHidden) || !a?.hidden);
272
274
  arr.sort((x, y) => activityTs(y, activity) - activityTs(x, activity));
273
275
  return arr.map((agent) => {
274
276
  const rec = activityRec(agent, activity) || {};
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Grok Bot tray/roster.
3
+ *
4
+ * Electron hijack: a second Cursor login on the same Mac must not inherit the
5
+ * previous account's 1340 / tray (rosterForAccount still isolates that).
6
+ *
7
+ * Cafe/web: every visitor is a cookie identity on ONE house. They share the
8
+ * operator tray at ~/.openzoo/grokbot-agents.json plus any account agents.json
9
+ * under ~/.openzoo/grokbot/<id>/ -- not per-browser localStorage, not empty
10
+ * just because no Cursor account has logged in.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ export function accountSlug(accountId) {
16
+ const s = String(accountId || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64);
17
+ return s || null;
18
+ }
19
+
20
+ export function accountDir(home, accountId) {
21
+ const slug = accountSlug(accountId);
22
+ if (!slug) return null;
23
+ return path.join(home, '.openzoo', 'grokbot', slug);
24
+ }
25
+
26
+ export function accountPodPath(home, accountId) {
27
+ const dir = accountDir(home, accountId);
28
+ return dir ? path.join(dir, 'pod.json') : null;
29
+ }
30
+
31
+ export function accountAgentsPath(home, accountId) {
32
+ const dir = accountDir(home, accountId);
33
+ return dir ? path.join(dir, 'agents.json') : null;
34
+ }
35
+
36
+ export function houseAgentsPath(home) {
37
+ return path.join(home, '.openzoo', 'grokbot-agents.json');
38
+ }
39
+
40
+ function readJsonFile(p) {
41
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
42
+ }
43
+
44
+ /** First-seen id wins. A later pile can only *upgrade* a UUID name/stub brief. */
45
+ export function mergeAgentRecords(piles) {
46
+ const seen = new Map();
47
+ const out = [];
48
+ for (const pile of piles) {
49
+ if (!Array.isArray(pile)) continue;
50
+ for (const a of pile) {
51
+ if (!a?.id) continue;
52
+ const idx = seen.get(a.id);
53
+ if (idx == null) {
54
+ seen.set(a.id, out.length);
55
+ out.push(a);
56
+ continue;
57
+ }
58
+ out[idx] = preferNamedAgent(out[idx], a);
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Shared house tray from disk. Cafe visitors have no Cursor account — they
66
+ * still get this list. A live account's file is preferred, never required.
67
+ */
68
+ export function readHouseRoster(home, liveAccountId) {
69
+ const piles = [];
70
+ const livePath = liveAccountId ? accountAgentsPath(home, liveAccountId) : null;
71
+ if (livePath) {
72
+ const scoped = readJsonFile(livePath);
73
+ if (Array.isArray(scoped)) piles.push(scoped);
74
+ }
75
+ const global = readJsonFile(houseAgentsPath(home));
76
+ if (Array.isArray(global)) piles.push(global);
77
+ try {
78
+ const dir = path.join(home, '.openzoo', 'grokbot');
79
+ for (const name of fs.readdirSync(dir)) {
80
+ const p = path.join(dir, name, 'agents.json');
81
+ if (livePath && p === livePath) continue;
82
+ const a = readJsonFile(p);
83
+ if (Array.isArray(a) && a.length) piles.push(a);
84
+ }
85
+ } catch { /* no grokbot dir yet */ }
86
+ return mergeAgentRecords(piles);
87
+ }
88
+
89
+ /**
90
+ * Isolate two Cursor logins. Cafe (no live account) gets the house fallback.
91
+ * `fallback` is the merged disk roster; never return [] just because oauth
92
+ * hasn't run — that emptied the public site's sidebar.
93
+ */
94
+ export function rosterForAccount({ liveAccountId, cachedAccountId, cached, fallback }) {
95
+ if (liveAccountId && cachedAccountId && liveAccountId !== cachedAccountId) return [];
96
+ if (liveAccountId && cachedAccountId && liveAccountId === cachedAccountId) {
97
+ return Array.isArray(cached) ? cached : [];
98
+ }
99
+ if (Array.isArray(fallback) && fallback.length) return fallback;
100
+ return Array.isArray(cached) ? cached : [];
101
+ }
102
+
103
+ function activityRec(agent, activity) {
104
+ if (!activity || !agent?.id) return null;
105
+ if (typeof activity.get === 'function') return activity.get(agent.id) || null;
106
+ return activity[agent.id] || null;
107
+ }
108
+
109
+ function activityTs(agent, activity) {
110
+ const rec = activityRec(agent, activity);
111
+ return (rec && rec.updatedAt) || agent.updatedAt || agent.createdAt || 0;
112
+ }
113
+
114
+ /**
115
+ * Grok Bot client persistence (Xkn) DROPS any row missing these booleans /
116
+ * nulls, then clears the whole persisted tray. That is how a group vanished
117
+ * after the first send: bumpAgent wrote a partial row, restore returned null.
118
+ */
119
+ const AGENT_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
120
+
121
+ /** bumpAgent used `{id, name:id}` and 1340 listAgents often echoes that — sidebar then paints UUIDs. */
122
+ export function looksLikeAgentId(s, id) {
123
+ const n = String(s || '').trim();
124
+ if (!n) return true;
125
+ if (id && n === String(id)) return true;
126
+ return AGENT_UUID_RE.test(n);
127
+ }
128
+
129
+ export function isStubBrief(brief, id) {
130
+ const b = String(brief || '').trim();
131
+ if (!b) return true;
132
+ if (id && b.startsWith(`You are ${id}. Your job is ${id}`)) return true;
133
+ return /^You are [0-9a-f-]{36}\. Your job is [0-9a-f-]{36}/i.test(b);
134
+ }
135
+
136
+ /** Pull a human name out of a standing brief: "You are 6 · Content Studio…" / "Job: Product Simplification". */
137
+ export function nameFromBrief(brief, id) {
138
+ const b = String(brief || '').replace(/^\[brief\]\s*/i, '').trim();
139
+ if (!b) return '';
140
+ const job = b.match(/\bJob:\s*([^\n.]+)/i);
141
+ if (job) {
142
+ const n = job[1].trim();
143
+ if (n && !looksLikeAgentId(n, id)) return n.slice(0, 80);
144
+ }
145
+ const numbered = b.match(/^You are\s+(\d+\s*[·.•.\-—–]+\s*[^\n.]+)/i);
146
+ if (numbered) {
147
+ const n = numbered[1].replace(/\s+for\s+.*$/i, '').trim();
148
+ if (n && !looksLikeAgentId(n, id)) return n.slice(0, 80);
149
+ }
150
+ const you = b.match(/^You are\s+(.+?)(?:\.\s|$)/i);
151
+ if (you) {
152
+ const n = you[1].replace(/\s+for\s+Stacc(?:'s)?(?:\s+LLC)?$/i, '').trim();
153
+ if (n && !looksLikeAgentId(n, id)) return n.slice(0, 80);
154
+ }
155
+ return '';
156
+ }
157
+
158
+ export function nameQuality(a) {
159
+ const n = String(a?.name || a?.title || '').trim();
160
+ if (!n || looksLikeAgentId(n, a?.id)) return 0;
161
+ if (/^(chat|group)$/i.test(n)) return 1;
162
+ if (/^new bot$/i.test(n)) return 2;
163
+ return 3;
164
+ }
165
+
166
+ /** Keep the record that still has a human name / real brief. */
167
+ export function preferNamedAgent(keep, incoming) {
168
+ if (!keep) return incoming;
169
+ if (!incoming) return keep;
170
+ const kq = nameQuality(keep);
171
+ const iq = nameQuality(incoming);
172
+ const keepBrief = String(keep.brief || keep.instructions || '').trim();
173
+ const inBrief = String(incoming.brief || incoming.instructions || '').trim();
174
+ const keepStub = isStubBrief(keepBrief, keep.id);
175
+ const inStub = isStubBrief(inBrief, incoming.id);
176
+ if (iq <= kq && !(keepStub && !inStub)) return keep;
177
+ const nameSrc = iq > kq ? incoming : keep;
178
+ const briefSrc = (!inStub && keepStub) ? incoming : keep;
179
+ return {
180
+ ...keep,
181
+ ...incoming,
182
+ name: nameSrc.name || nameSrc.title,
183
+ title: nameSrc.title || nameSrc.name,
184
+ brief: briefSrc.brief || briefSrc.instructions || keepBrief || inBrief,
185
+ };
186
+ }
187
+
188
+ export function displayName(raw = {}) {
189
+ const a = raw && typeof raw === 'object' ? raw : {};
190
+ const id = String(a.id || '');
191
+ const isGroup = a.isGroup === true
192
+ || (Array.isArray(a.memberIds) && a.memberIds.length > 0)
193
+ || (Array.isArray(a.memberAgentIds) && a.memberAgentIds.length > 0);
194
+ for (const cand of [a.name, a.title]) {
195
+ const n = String(cand || '').trim();
196
+ if (n && !looksLikeAgentId(n, id)) return n.slice(0, 80);
197
+ }
198
+ const fromBrief = nameFromBrief(a.brief || a.instructions || a.description, id);
199
+ if (fromBrief) return fromBrief;
200
+ return isGroup ? 'group' : 'chat';
201
+ }
202
+
203
+ /** Standing job from a sidebar name like "6 · Content Studio" or "Bot 1 — Marketing (X)". */
204
+ export function briefFromName(name) {
205
+ const n = String(name || '').trim();
206
+ if (!n || /^(new bot|chat|group)$/i.test(n) || looksLikeAgentId(n)) return '';
207
+ const role = n.replace(/^\d+\s*[·.•.\-—–]+\s*/, '').trim() || n;
208
+ if (role.length < 2) return '';
209
+ return `You are ${n}. Your job is ${role}. Do that job. Do not ask the human to re-brief you. Coordinate with list_agents and message_agent.`;
210
+ }
211
+
212
+ /** Standing job text. shapeAgent used to drop this, so every restart was amnesia. */
213
+ export function agentBrief(raw = {}) {
214
+ const a = raw && typeof raw === 'object' ? raw : {};
215
+ const id = String(a.id || '');
216
+ for (const k of ['brief', 'instructions', 'customInstructions', 'systemPrompt']) {
217
+ const v = a[k];
218
+ if (typeof v === 'string' && v.trim() && !isStubBrief(v, id)) {
219
+ const name = displayName(a);
220
+ const text = v.trim();
221
+ if (id && name && !looksLikeAgentId(name, id) && text.startsWith(`You are ${id}`)) {
222
+ return (`You are ${name}` + text.slice(`You are ${id}`.length)).slice(0, 8000);
223
+ }
224
+ return text.slice(0, 8000);
225
+ }
226
+ }
227
+ const d = String(a.description || '').trim();
228
+ if (d && !isStubBrief(d, id)) return d.slice(0, 8000);
229
+ return briefFromName(displayName(a)).slice(0, 8000);
230
+ }
231
+
232
+ export function shapeAgent(raw = {}) {
233
+ const a = raw && typeof raw === 'object' ? raw : {};
234
+ const id = String(a.id || '');
235
+ const memberIds = Array.isArray(a.memberIds)
236
+ ? a.memberIds.map((x) => String(x)).filter(Boolean)
237
+ : (Array.isArray(a.memberAgentIds) ? a.memberAgentIds.map((x) => String(x)).filter(Boolean) : []);
238
+ const isGroup = a.isGroup === true || memberIds.length > 0;
239
+ const name = displayName({ ...a, isGroup, memberIds });
240
+ const brief = agentBrief({ ...a, name, title: a.title || name });
241
+ return {
242
+ id,
243
+ name,
244
+ brief,
245
+ description: String((a.description && !looksLikeAgentId(a.description, id) && a.description) || brief || ''),
246
+ title: String((!looksLikeAgentId(a.title, id) && a.title) || name),
247
+ origin: String(a.origin || 'user'),
248
+ path: String(a.path || (id ? `/local/${id}` : '/local')),
249
+ createdAt: Number(a.createdAt) || Date.now(),
250
+ updatedAt: Number(a.updatedAt) || Date.now(),
251
+ hasUnread: !!a.hasUnread,
252
+ unreadCount: Number(a.unreadCount) || 0,
253
+ notificationsEnabled: a.notificationsEnabled !== false,
254
+ notifyOnUpdatesEnabled: a.notifyOnUpdatesEnabled !== false,
255
+ isGroup,
256
+ memberIds: isGroup ? memberIds : [],
257
+ lastMessageId: a.lastMessageId == null ? null : String(a.lastMessageId),
258
+ lastEntry: a.lastEntry && typeof a.lastEntry === 'object' ? a.lastEntry : null,
259
+ awaitingUserResponse: a.awaitingUserResponse && typeof a.awaitingUserResponse === 'object'
260
+ ? a.awaitingUserResponse : null,
261
+ avatarShape: a.avatarShape ?? null,
262
+ avatarColor: a.avatarColor ?? null,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Full sidebar roster for SSE `agents` / listAgents. Sorts by activity and
268
+ * stamps unread -- does not slice. The old 80-cap hid agents past the tray.
269
+ */
270
+ export function rosterForEvent(list, activity) {
271
+ const arr = Array.isArray(list) ? [...list] : [];
272
+ arr.sort((x, y) => activityTs(y, activity) - activityTs(x, activity));
273
+ return arr.map((agent) => {
274
+ const rec = activityRec(agent, activity) || {};
275
+ return shapeAgent({
276
+ ...agent,
277
+ updatedAt: rec.updatedAt || agent.updatedAt || agent.createdAt || 0,
278
+ hasUnread: !!rec.hasUnread,
279
+ unreadCount: rec.unreadCount || 0,
280
+ });
281
+ });
282
+ }
283
+
284
+ export const WAKEUP_MIN_SEC = 60;
285
+ export const WAKEUP_MAX_SEC = 6 * 3600;
286
+ export const WAKEUP_DEFAULT_SEC = 5 * 60;
287
+ 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.';
288
+
289
+ export function wakeupsPath(home) {
290
+ return path.join(home, '.openzoo', 'grokbot-wakeups.json');
291
+ }
292
+
293
+ /** "5m" / "1h" / "90" / "30s". Floor 60s so a never-stop cron cannot storm. */
294
+ export function parseWakeupEvery(raw) {
295
+ const s = String(raw ?? '').trim().toLowerCase();
296
+ if (!s) return WAKEUP_DEFAULT_SEC;
297
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?)?$/);
298
+ if (!m) return WAKEUP_DEFAULT_SEC;
299
+ const n = Number(m[1]);
300
+ if (!Number.isFinite(n) || n <= 0) return WAKEUP_DEFAULT_SEC;
301
+ const u = m[2] || 's';
302
+ let sec = n;
303
+ if (/^m/.test(u)) sec = n * 60;
304
+ else if (/^h/.test(u)) sec = n * 3600;
305
+ return Math.min(WAKEUP_MAX_SEC, Math.max(WAKEUP_MIN_SEC, Math.round(sec)));
306
+ }
307
+
308
+ export function wantsWakeupCron(prompt) {
309
+ const s = String(prompt || '');
310
+ if (/\bcron\b.{0,48}\bwakeups?\b/i.test(s)) return true;
311
+ if (/\bwakeups?\b.{0,48}\bcron\b/i.test(s)) return true;
312
+ if (/\bnever stop\b/i.test(s)) return true;
313
+ if (/\bschedule\b.{0,24}\bwakeups?\b/i.test(s)) return true;
314
+ return false;
315
+ }
316
+
317
+ export function shapeWakeup(agentId, rec = {}, now = Date.now()) {
318
+ const everySec = parseWakeupEvery(rec.everySec ?? rec.every ?? rec.interval);
319
+ const prompt = String(rec.prompt || DEFAULT_WAKEUP_PROMPT).trim().slice(0, 2000)
320
+ || DEFAULT_WAKEUP_PROMPT;
321
+ const lastAt = Number(rec.lastAt) || 0;
322
+ let nextAt = Number(rec.nextAt) || 0;
323
+ if (!Number.isFinite(nextAt) || nextAt <= 0) nextAt = now + everySec * 1000;
324
+ return {
325
+ agentId: String(agentId || rec.agentId || ''),
326
+ everySec,
327
+ prompt,
328
+ lastAt,
329
+ nextAt,
330
+ };
331
+ }
332
+
333
+ export function readWakeups(home) {
334
+ const raw = readJsonFile(wakeupsPath(home));
335
+ const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
336
+ const out = {};
337
+ for (const [id, rec] of Object.entries(src)) {
338
+ const w = shapeWakeup(id, rec);
339
+ if (w.agentId) out[w.agentId] = w;
340
+ }
341
+ return out;
342
+ }
343
+
344
+ export function deletedAgentsPath(home) {
345
+ return path.join(home, '.openzoo', 'grokbot-deleted.json');
346
+ }
347
+
348
+ export function readDeletedIds(home) {
349
+ const raw = readJsonFile(deletedAgentsPath(home));
350
+ const ids = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.ids) ? raw.ids : []);
351
+ return new Set(ids.map(String).filter(Boolean));
352
+ }
353
+
354
+ export function addDeletedIds(home, ids) {
355
+ const s = readDeletedIds(home);
356
+ for (const id of ids) if (id) s.add(String(id));
357
+ const dir = path.dirname(deletedAgentsPath(home));
358
+ fs.mkdirSync(dir, { recursive: true });
359
+ fs.writeFileSync(deletedAgentsPath(home), JSON.stringify([...s], null, 2));
360
+ return s;
361
+ }
362
+
363
+ export function filterDeleted(agents, home) {
364
+ const del = readDeletedIds(home);
365
+ if (!del.size || !Array.isArray(agents)) return agents || [];
366
+ return agents.filter((a) => a && !del.has(String(a.id)));
367
+ }
368
+
369
+ export function writeWakeups(home, map) {
370
+ const dir = path.dirname(wakeupsPath(home));
371
+ fs.mkdirSync(dir, { recursive: true });
372
+ fs.writeFileSync(wakeupsPath(home), JSON.stringify(map || {}, null, 2));
373
+ }
374
+
375
+ export function callerKeyFromAuth(authorization) {
376
+ const a = String(authorization || '').trim();
377
+ if (!a) return '';
378
+ // Not a secret store — a session-scoped equality key so a second oauth
379
+ // does not inherit the previous account's 1340 / tray cache.
380
+ let h = 2166136261;
381
+ for (let i = 0; i < a.length; i++) {
382
+ h ^= a.charCodeAt(i);
383
+ h = Math.imul(h, 16777619);
384
+ }
385
+ return (h >>> 0).toString(16);
386
+ }
package/lib/grokbotweb.js CHANGED
@@ -18,7 +18,7 @@ import { exec } from 'node:child_process';
18
18
  import { config } from './config.js';
19
19
  import { readHouseRoster } from './grokbotAccount.js';
20
20
  import { ingestUpload, lookupUpload } from './grokbotUploads.js';
21
- import { spendChipSource } from './ozSpendChip.js';
21
+ import { spendChipSource, sessionSpendState } from './ozSpendChip.js';
22
22
 
23
23
  const HERE = path.dirname(fileURLToPath(import.meta.url));
24
24
  const SHIM_PATH = path.join(HERE, 'grokbotweb-shim.js');
@@ -763,6 +763,15 @@ export async function startGrokBotWeb(opts = {}) {
763
763
 
764
764
  const server = http.createServer((req, res) => {
765
765
  const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
766
+ if (urlPath === '/oz-spend') {
767
+ writeHead(res, 200, {
768
+ 'content-type': 'application/json',
769
+ 'cache-control': 'no-store',
770
+ 'access-control-allow-origin': '*',
771
+ }, req);
772
+ res.end(JSON.stringify({ ok: true, ...sessionSpendState() }));
773
+ return;
774
+ }
766
775
  if (urlPath === '/oz-health') {
767
776
  writeHead(res, 200, { 'content-type': 'application/json' }, req);
768
777
  res.end(JSON.stringify({
package/lib/grokcli.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // "payment required".
17
17
 
18
18
  import { spawn, execSync } from 'node:child_process';
19
- import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
19
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, chmodSync } from 'node:fs';
20
20
  import { homedir } from 'node:os';
21
21
  import { dirname, join } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
@@ -31,7 +31,68 @@ import {
31
31
 
32
32
  const GROK_HOME = process.env.GROK_HOME || join(homedir(), '.grok');
33
33
  const CONFIG_PATH = join(GROK_HOME, 'config.toml');
34
- const APP = '/Applications/Grok Bot.app';
34
+ const APP = process.env.GROK_APP || '/Applications/Grok Bot.app';
35
+
36
+ /* LINUX/WIN DISCOVERY — the multiarch release ships Grok_Bot_<v>_<plat>-<arch>.AppImage
37
+ * / .exe. GROKBOT_BIN env always wins. */
38
+ function linuxAppImage() {
39
+ const home = homedir();
40
+ const roots = [
41
+ join(home, 'Applications'),
42
+ join(home, '.local', 'bin'),
43
+ join(home, 'Downloads'),
44
+ '/opt',
45
+ '/usr/local/bin',
46
+ '/usr/bin',
47
+ ];
48
+ for (const dir of roots) {
49
+ try {
50
+ const hit = readdirSync(dir)
51
+ .filter((f) => /^grok[ _-]?bot.*\.appimage$/i.test(f))
52
+ .sort();
53
+ if (hit.length) return join(dir, hit[hit.length - 1]);
54
+ } catch { /* unreadable dir */ }
55
+ }
56
+ return join(home, '.local', 'bin', 'Grok_Bot.AppImage');
57
+ }
58
+
59
+ function windowsExe() {
60
+ const home = homedir();
61
+ const dirs = [
62
+ join(home, 'AppData', 'Local', 'Programs', 'Grok Bot'),
63
+ join(home, 'AppData', 'Local', 'Programs', 'grok-bot'),
64
+ 'C:\\Program Files\\Grok Bot',
65
+ ];
66
+ for (const dir of dirs) {
67
+ for (const name of ['Grok Bot.exe', 'Grok_Bot.exe', 'grokbot.exe']) {
68
+ const p = join(dir, name);
69
+ if (existsSync(p)) return p;
70
+ }
71
+ }
72
+ return join(home, 'AppData', 'Local', 'Programs', 'Grok Bot', 'Grok Bot.exe');
73
+ }
74
+
75
+ /** Cross-platform Grok Bot binary. GROKBOT_BIN env always wins. */
76
+ export function grokBotBinary() {
77
+ if (process.env.GROKBOT_BIN) return process.env.GROKBOT_BIN;
78
+ if (process.platform === 'darwin') return `${APP}/Contents/MacOS/Grok Bot`;
79
+ if (process.platform === 'linux') return linuxAppImage();
80
+ if (process.platform === 'win32') return windowsExe();
81
+ return `${APP}/Contents/MacOS/Grok Bot`;
82
+ }
83
+
84
+ /** Cross-platform quit: osascript on mac, SIGTERM pids elsewhere. */
85
+ function quitGrokBot() {
86
+ try {
87
+ if (process.platform === 'darwin') {
88
+ quitGrokBot();
89
+ } else {
90
+ for (const pid of grokBotPids()) {
91
+ try { process.kill(Number(pid), 'SIGTERM'); } catch { /* gone */ }
92
+ }
93
+ }
94
+ } catch { /* ok */ }
95
+ }
35
96
 
36
97
  /** Grok ids as the zoo serves them. Verified against GET /v1/models at write
37
98
  * time so a renamed/retired model never lands in the picker as a dead row. */
@@ -105,7 +166,7 @@ ${body}
105
166
  `;
106
167
  }
107
168
 
108
- const GROK_BOT_BIN = `${APP}/Contents/MacOS/Grok Bot`;
169
+ const GROK_BOT_BIN = grokBotBinary();
109
170
 
110
171
  /** pids whose command is the Grok Bot main binary (not grep itself). */
111
172
  export function grokBotPids(run = execSync) {
@@ -120,6 +181,14 @@ export function grokBotPids(run = execSync) {
120
181
  function pidHasNeedle(pid, needle, run = execSync) {
121
182
  const n = Number(pid);
122
183
  if (!Number.isInteger(n) || n <= 0) return false;
184
+ if (process.platform === 'linux') {
185
+ try {
186
+ // /proc/<pid>/environ is NUL-separated; flatten and test.
187
+ const env = readFileSync(`/proc/${n}/environ`, 'utf8');
188
+ if (env.replace(/\0/g, '\n').includes(needle)) return true;
189
+ } catch { /* gone */ }
190
+ }
191
+ if (process.platform === 'win32') return false;
123
192
  for (const cmd of [`ps -Eww -p ${n} -o command=`, `ps eww -p ${n}`]) {
124
193
  try {
125
194
  if (run(cmd, { encoding: 'utf8', timeout: 2000 }).includes(needle)) return true;
@@ -130,6 +199,14 @@ function pidHasNeedle(pid, needle, run = execSync) {
130
199
 
131
200
  function somethingTalksToPort(port, run = execSync) {
132
201
  try {
202
+ if (process.platform === 'linux') {
203
+ const out = run('ss -tnp', { encoding: 'utf8', timeout: 2000 });
204
+ return out.includes(':' + Number(port)) && /grok|electron|bot/i.test(out);
205
+ }
206
+ if (process.platform === 'win32') {
207
+ const out = run('netstat -ano', { encoding: 'utf8', timeout: 5000 });
208
+ return new RegExp(':' + Number(port) + '[^0-9].*ESTABLISHED', 'i').test(out);
209
+ }
133
210
  const out = run(`lsof -nP -iTCP:${Number(port)} -sTCP:ESTABLISHED`, {
134
211
  encoding: 'utf8', timeout: 2000,
135
212
  });
@@ -208,9 +285,11 @@ export async function runBot(argv = []) {
208
285
  }
209
286
  const port = 8443;
210
287
  const url = `https://127.0.0.1:${port}`;
211
- const bin = `${APP}/Contents/MacOS/Grok Bot`;
288
+ const bin = GROK_BOT_BIN;
212
289
  if (!existsSync(bin)) {
213
- console.error('openzoo: Grok Bot.app not found at', APP);
290
+ console.error(`openzoo: Grok Bot binary not found — looked for ${bin}`);
291
+ console.error(' set GROKBOT_BIN=/path/to/Grok_Bot_0.36.0_linux-x64.AppImage');
292
+ console.error(' or drop the AppImage in ~/Applications, ~/.local/bin, /opt, ~/Downloads');
214
293
  process.exit(1);
215
294
  }
216
295
 
@@ -294,13 +373,16 @@ export async function runBot(argv = []) {
294
373
  });
295
374
  console.error(`openzoo: grokbot ${plan.reason}`);
296
375
  if (plan.quit) {
297
- try { execSync('osascript -e \'tell application "Grok Bot" to quit\'', { stdio: 'ignore' }); } catch { /* ok */ }
376
+ try { quitGrokBot(); } catch { /* ok */ }
298
377
  await new Promise((r) => setTimeout(r, 1500));
299
378
  }
300
379
 
301
380
  const launchGrokBot = () => {
302
381
  console.error('openzoo: launching Grok Bot');
303
382
  console.error(` CURSOR_API_BASE_URL=${url}`);
383
+ if (process.platform === 'linux' && /\.appimage$/i.test(bin)) {
384
+ try { chmodSync(bin, 0o755); } catch { /* read-only */ }
385
+ }
304
386
  spawn(bin, grokBotChromiumArgs(), {
305
387
  stdio: 'ignore',
306
388
  detached: true,
@@ -481,7 +563,7 @@ export async function setupGrokBot(argv = []) {
481
563
  // api2.cursor.sh. MEASURED: the backend logged "listening" and then zero
482
564
  // requests, while the app carried on answering normally.
483
565
  try {
484
- execSync('osascript -e \'tell application "Grok Bot" to quit\'', { stdio: 'ignore' });
566
+ quitGrokBot();
485
567
  } catch { /* not running is fine */ }
486
568
  // give it a moment to actually exit before relaunching
487
569
  await new Promise((r) => setTimeout(r, 2500));
package/lib/launch.js CHANGED
@@ -213,18 +213,21 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
213
213
  * DEFAULT is the desktop app; `--terminal` (or `-t`) runs the Claude Code CLI.
214
214
  * Both get ANTHROPIC_BASE_URL so inference pays x402.
215
215
  */
216
+
217
+
216
218
  export async function launchClaude(argv) {
217
- // let, not const: startProxy can heal onto a different port and every URL
218
- // below must follow the port we actually bound.
219
+ // NEVER hop. :8402 is the product. startProxy steals a stale listener.
219
220
  let base = `http://localhost:${config.port}/v1`;
220
- // AUTO-START THE PROXY. One command should just work — if nothing is listening,
221
- // boot the proxy in THIS process (it stays alive because claude runs in the
222
- // foreground below), rather than making the user run `npx openzoo` first.
221
+ // AUTO-START THE PROXY. One command should just work — if nothing is listening
222
+ // (or a leftover npx cache is), boot THIS version on :8402 in this process.
223
223
  let up = false;
224
- // /info, not /models — see the poll below. "Is a proxy already listening" must
225
- // not be answered by an endpoint that needs the gateway, or a user whose
226
- // upstream is flaky gets told to start a proxy that is already running.
227
- try { up = (await fetch(`${base}/info`, { signal: AbortSignal.timeout(3000) })).ok; } catch { up = false; }
224
+ const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
225
+ const mine = packageVersion();
226
+ if (await oursOn(config.port)) {
227
+ up = true;
228
+ } else {
229
+ process.stderr.write(`openzoo: claiming :${config.port} for v${mine}\n`);
230
+ }
228
231
  if (!up) {
229
232
  // NEVER GO SILENT DURING STARTUP. silent:true routes the proxy's own lines
230
233
  // to ~/.openzoo/proxy.log so payment receipts cannot corrupt Claude Code's
@@ -241,7 +244,6 @@ export async function launchClaude(argv) {
241
244
  tick.unref?.();
242
245
  const done = (msg) => { clearInterval(tick); process.stderr.write(`\r\x1b[2Kopenzoo: ${msg}\n`); };
243
246
  try {
244
- const { startProxy } = await import('./proxy.js');
245
247
  await startProxy({ silent: true, autoTunnel: true });
246
248
  } catch (err) {
247
249
  // An exception here used to surface as an eternal spinner. Say what broke.
@@ -250,9 +252,6 @@ export async function launchClaude(argv) {
250
252
  console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
251
253
  process.exit(1);
252
254
  }
253
- // The proxy may have healed onto a different port (8402 busy). config.port
254
- // is the one it ACTUALLY bound, so re-derive every URL from it — the old
255
- // code kept polling the port it wished for and timed out on a live proxy.
256
255
  base = `http://localhost:${config.port}/v1`;
257
256
  // PROBE /v1/info, NOT /v1/models. `models` is PROXIED UPSTREAM, so on a
258
257
  // network with a bad path to the gateway the local proxy is listening and
@@ -611,17 +610,12 @@ export async function launchClaude(argv) {
611
610
  }
612
611
 
613
612
  export async function launchHarness(cmd, args) {
614
- const base = `http://localhost:${config.port}/v1`;
615
- // Fail early with a clear message rather than letting the harness spew
616
- // connection errors — the #1 support question would otherwise be "why won't
617
- // claude connect" when the answer is "the proxy isn't up".
618
- try {
619
- const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(4000) });
620
- if (!r.ok) throw new Error(String(r.status));
621
- } catch {
622
- console.error(`openzoo: no proxy reachable at ${base}`);
623
- console.error('start it first in another terminal: npx openzoo');
624
- process.exit(1);
613
+ let base = `http://localhost:${config.port}/v1`;
614
+ const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
615
+ if (!(await oursOn(config.port))) {
616
+ process.stderr.write(`openzoo: claiming :${config.port} for v${packageVersion()}\n`);
617
+ await startProxy({ silent: true, autoTunnel: true });
618
+ base = `http://localhost:${config.port}/v1`;
625
619
  }
626
620
 
627
621
  const env = claudeZooEnv(process.env, { base });
@@ -0,0 +1 @@
1
+ Measured route table. catalog.json + router.json + outcomes.json. Regenerated from leCore bench_ledger_hard.jsonl. Do not edit by hand.