openzoo 0.50.96 → 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/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
@@ -164,7 +164,30 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
164
164
  // Correct here for the same reason as everything else in this block: the
165
165
  // proxy binds the prefix and forwards a bounded tail, so the request that
166
166
  // leaves this machine stays small no matter how long the conversation runs.
167
- if (baseEnv.OPENZOO_KEEP_COMPACT !== '1') {
167
+ //
168
+ // OPT-IN NOW, BECAUSE THE PRECONDITION IS NOT REAL.
169
+ //
170
+ // Everything above is correct ONLY IF something bounds the body that leaves
171
+ // this machine. Two comments here named that something — `spillTranscript`,
172
+ // and the knobs `OPENZOO_TAIL_MAX_CHARS` / `OPENZOO_KEEP_TAIL_MSGS`. None of
173
+ // the three exists: `grep -rn spillTranscript` matches nothing but these
174
+ // comments, in this package and in the gateway. Checked 2026-09-03.
175
+ //
176
+ // So the shipped behaviour was: compaction off, ceiling raised to 1M, and
177
+ // NOTHING trimming the transcript — Claude Code accumulates without limit and
178
+ // re-sends the whole thing every turn. The gateway's leCore cannot rescue it
179
+ // either: an agent body is few-and-huge, so `msgs.length <= KEEP_TAIL` leaves
180
+ // nothing "older than the live window", and what bulk there is sits in the
181
+ // system block and first user turn, both deliberately never spilled.
182
+ //
183
+ // MEASURED on a user's session: 20 calls, $13.86, `spilled 0/20`, 1.0159x vs
184
+ // direct — paying almost exactly retail to send an ever-growing transcript.
185
+ //
186
+ // Until a real bound ships, default to Claude Code's own behaviour, which is
187
+ // bounded and known-good. OPENZOO_UNBOUNDED_CONTEXT=1 restores the old
188
+ // settings for anyone who wants them back.
189
+ const unboundedContext = baseEnv.OPENZOO_UNBOUNDED_CONTEXT === '1';
190
+ if (unboundedContext && baseEnv.OPENZOO_KEEP_COMPACT !== '1') {
168
191
  env.DISABLE_COMPACT = baseEnv.DISABLE_COMPACT || '1';
169
192
  env.DISABLE_AUTO_COMPACT = baseEnv.DISABLE_AUTO_COMPACT || '1';
170
193
  }
@@ -177,9 +200,9 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
177
200
  // sent. Disabling auto-compact WITHOUT raising this made it worse: it used to
178
201
  // compact and carry on, and instead it just stopped.
179
202
  //
180
- // Safe only because spillTranscript is real: OPENZOO_TAIL_MAX_CHARS bounds what
181
- // leaves this machine however long the conversation gets.
182
- if (baseEnv.OPENZOO_KEEP_COMPACT !== '1' && !baseEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS) {
203
+ // WAS justified by `spillTranscript` / OPENZOO_TAIL_MAX_CHARS — neither of
204
+ // which exists. Now gated behind OPENZOO_UNBOUNDED_CONTEXT; see below.
205
+ if (unboundedContext && baseEnv.OPENZOO_KEEP_COMPACT !== '1' && !baseEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS) {
183
206
  env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = baseEnv.OPENZOO_CLAUDE_CONTEXT_TOKENS || '1000000';
184
207
  }
185
208
  return env;
@@ -360,7 +383,15 @@ export async function launchClaude(argv) {
360
383
  // side by side is the only way the markup is visible while it is happening.
361
384
  + 'const ac=j.actual||{};'
362
385
  + 'const real=(ac.calls>0)?(" \\u00b7 $"+Number(ac.upstreamUsd||0).toFixed(4)+" real"+(ac.markupX?(" ("+ac.markupX+"x)"):"")):"";'
363
- + 'process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo $"+(Number(j.spendUsd)||0).toFixed(4)+" "+(j.paidCalls||0)+" call"+((j.paidCalls||0)===1?"":"s")+save+real+spill+cr+" \\u00b7 x402")}'
386
+ // TWO NUMBERS, NEVER ONE. `spent` is cumulative across every session on
387
+ // this machine (restored from ~/.openzoo/session.json); `wallet` is what
388
+ // is left to pay with. Shown unlabelled, the first was read as the
389
+ // second — "$13.86" on screen while the wallet held $0.18 and every call
390
+ // 402'd underfunded. The words `spent` and `wallet` are the whole fix.
391
+ + 'const wu=Number(j.walletUsd);'
392
+ + 'const wcol=(!isFinite(wu)||wu<=0)?"\\x1b[31m":(wu<0.5?"\\x1b[33m":"\\x1b[90m");'
393
+ + 'const wal=isFinite(wu)?(" \\u00b7 "+wcol+"wallet $"+wu.toFixed(2)+"\\x1b[0m"):"";'
394
+ + 'process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo spent $"+(Number(j.spendUsd)||0).toFixed(4)+" all-time"+wal+" \\u00b7 "+(j.paidCalls||0)+" call"+((j.paidCalls||0)===1?"":"s")+save+real+spill+cr+" \\u00b7 x402")}'
364
395
  + 'catch{process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo \\u00b7 x402")}})\'\n');
365
396
  fs.chmodSync(scriptPath, 0o755);
366
397
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
@@ -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.