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.
- package/bin/claude-zoo.js +28 -3
- package/lib/aoe.js +8 -2
- package/lib/cursorbackend.js +47 -22
- package/lib/cursorbackend.js.bak-hiddenbots +4072 -0
- package/lib/grokbotAccount.js +4 -2
- package/lib/grokbotAccount.js.bak-hiddenbots +386 -0
- package/lib/grokbotweb.js +10 -1
- package/lib/grokcli.js +89 -7
- package/lib/launch.js +18 -24
- package/lib/modelroute/README.md +1 -0
- package/lib/modelroute/catalog.json +1 -0
- package/lib/modelroute/outcomes.json +1566 -0
- package/lib/modelroute/router.json +1 -0
- package/lib/modelroute.js +737 -0
- package/lib/ozSpendChip.js +187 -29
- package/lib/podagent.mjs +1425 -0
- package/lib/proxy.js +127 -48
- package/lib/runguard.js +31 -0
- package/lib/setup.js +12 -13
- package/lib/spill.js +2031 -0
- package/lib/stripeOnramp.js +50 -0
- package/lib/subscription.js +207 -0
- package/lib/worktree.mjs +424 -0
- package/package.json +1 -1
package/lib/grokbotAccount.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
288
|
+
const bin = GROK_BOT_BIN;
|
|
212
289
|
if (!existsSync(bin)) {
|
|
213
|
-
console.error(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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.
|