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
|
@@ -0,0 +1,4072 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Impersonate api2.cursor.sh so a PLAN-LESS editor still routes to the zoo.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM THIS SOLVES. The editor decides client-side whether a model may be
|
|
5
|
+
* used, from an answer its own backend gives. On an entitled account the custom
|
|
6
|
+
* OpenAI endpoint works (measured: real Solana settlements). On a free account the
|
|
7
|
+
* editor refuses before a request exists — "NOT ROUTING, 0 requests" — and nothing
|
|
8
|
+
* we write to its database reaches that decision. The only way to change the
|
|
9
|
+
* decision is to be the thing that answers it.
|
|
10
|
+
*
|
|
11
|
+
* WHY NO CERT INSTALL. Impersonating an HTTPS host normally needs a CA the editor
|
|
12
|
+
* trusts, and no real user installs one — the correct objection that killed the
|
|
13
|
+
* mkcert approach. But WE spawn the editor binary, so we pass Chromium's
|
|
14
|
+
* `--ignore-certificate-errors`, and a plain self-signed cert is accepted with
|
|
15
|
+
* zero prompts and zero trust-store changes. Verified from the bundle that the
|
|
16
|
+
* editor does NOT pin certs (no certificatePinning / pinnedPublicKey /
|
|
17
|
+
* checkServerIdentity), so the flag is honoured.
|
|
18
|
+
*
|
|
19
|
+
* WHAT WE ANSWER. Only `AvailableModels` needs a real body — the catalog, with
|
|
20
|
+
* every gate (supports_max_mode, etc.) set true so nothing is refused. Every other
|
|
21
|
+
* startup call (GetServerConfig, CheckUsage, GetTeams, GetUserPrivacyMode, …) gets
|
|
22
|
+
* an EMPTY protobuf message, which decodes as all-defaults and is valid for ANY
|
|
23
|
+
* message type — so we do not need each method's schema, only the one that matters.
|
|
24
|
+
*
|
|
25
|
+
* WIRE FORMAT. The editor uses Connect-RPC and gRPC-web. We detect the request's
|
|
26
|
+
* content-type and answer in kind: bare message for connect unary proto, enveloped
|
|
27
|
+
* + trailers for grpc-web. Both are handled below.
|
|
28
|
+
*/
|
|
29
|
+
import http2 from 'node:http2';
|
|
30
|
+
import https from 'node:https';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import zlib from 'node:zlib';
|
|
35
|
+
import { execFileSync, execFile } from 'node:child_process';
|
|
36
|
+
import { promisify } from 'node:util';
|
|
37
|
+
import tls from 'node:tls';
|
|
38
|
+
import { randomUUID } from 'node:crypto';
|
|
39
|
+
import { encodeAvailableModels, encodeForMethod, encodeEnsureSandBox, encodeGetGrokBotSendStatus, decodeProtoFields, unwrapConnect } from './cursorapi.js';
|
|
40
|
+
import {
|
|
41
|
+
accountPodPath, accountAgentsPath, rosterForAccount, rosterForEvent,
|
|
42
|
+
readHouseRoster, houseAgentsPath, shapeAgent, agentBrief, briefFromName,
|
|
43
|
+
preferNamedAgent, looksLikeAgentId,
|
|
44
|
+
readWakeups, writeWakeups, shapeWakeup, parseWakeupEvery, wantsWakeupCron,
|
|
45
|
+
DEFAULT_WAKEUP_PROMPT, addDeletedIds, filterDeleted,
|
|
46
|
+
} from './grokbotAccount.js';
|
|
47
|
+
import { spendChipLabel, formatSpendFooter, mergeTurnProof } from './spendProof.js';
|
|
48
|
+
import { spendLinesOnly, writeSpendHud } from './ozSpendChip.js';
|
|
49
|
+
import { zooModelIds, zooModelRow, mediaKindOf } from './models.js';
|
|
50
|
+
import { prefixVisitorRichText } from './grokbotweb.js';
|
|
51
|
+
import {
|
|
52
|
+
ingestUpload, lookupUpload, readUploadChunk, readUploadImage, readUploadText,
|
|
53
|
+
} from './grokbotUploads.js';
|
|
54
|
+
import {
|
|
55
|
+
desktopAction, displayBounds, imageSize, noteShotMeta, resolveAppName,
|
|
56
|
+
} from './grokbotDesktop.js';
|
|
57
|
+
import { startHostMcps, hostMcpTools, hostMcpHas, callHostMcp, hostMcpServers, chromeStatus, reattachChrome } from './mcpbridge.js';
|
|
58
|
+
import * as ship from './ship.js';
|
|
59
|
+
import * as xclaims from './xclaims.js';
|
|
60
|
+
|
|
61
|
+
const TLS_DIR = path.join(os.homedir(), '.openzoo', 'cursor-tls');
|
|
62
|
+
const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
|
|
63
|
+
// THE CERT MUST COVER ANTHROPIC TOO. This server impersonates api.anthropic.com
|
|
64
|
+
// for `claude --desktop`, but the SAN only ever listed the cursor hosts, so the
|
|
65
|
+
// client got a cert valid for a name it never asked for and killed the
|
|
66
|
+
// handshake before ALPN — logged as `HANDSHAKE FAILED ECONNRESET alpn=?`, with
|
|
67
|
+
// no request ever reaching us. Same cert, one more pair of names.
|
|
68
|
+
const ANTHROPIC_TLS_HOSTS = ['api.anthropic.com', 'api-staging.anthropic.com'];
|
|
69
|
+
const TLS_HOSTS = [...CURSOR_HOSTS, ...ANTHROPIC_TLS_HOSTS];
|
|
70
|
+
|
|
71
|
+
/** Generate (once) a self-signed cert covering every host we impersonate. openssl
|
|
72
|
+
* is on every mac/linux; this is the only external tool and it is not a trust op. */
|
|
73
|
+
export function ensureCert(log = () => {}) {
|
|
74
|
+
const cert = path.join(TLS_DIR, 'cert.pem');
|
|
75
|
+
const key = path.join(TLS_DIR, 'key.pem');
|
|
76
|
+
// STALE-CERT INVALIDATION. A plain existence check meant anyone who had ever
|
|
77
|
+
// run an older build kept their cursor-only cert forever and never got the
|
|
78
|
+
// anthropic names — the fix would ship and appear to do nothing. Record the
|
|
79
|
+
// SAN set the cert was minted for and re-mint whenever that set changes.
|
|
80
|
+
const stamp = path.join(TLS_DIR, 'san.txt');
|
|
81
|
+
const want = TLS_HOSTS.join(',');
|
|
82
|
+
try {
|
|
83
|
+
fs.accessSync(cert); fs.accessSync(key);
|
|
84
|
+
if (fs.readFileSync(stamp, 'utf8').trim() === want) return { cert, key };
|
|
85
|
+
log('cursor-tls: cert predates the current host list — re-minting');
|
|
86
|
+
} catch { /* make it */ }
|
|
87
|
+
fs.mkdirSync(TLS_DIR, { recursive: true });
|
|
88
|
+
const san = `subjectAltName=${TLS_HOSTS.map((h) => `DNS:${h}`).join(',')}`;
|
|
89
|
+
execFileSync('openssl', [
|
|
90
|
+
'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
|
|
91
|
+
'-keyout', key, '-out', cert, '-days', '3650',
|
|
92
|
+
'-subj', '/CN=api2.cursor.sh', '-addext', san,
|
|
93
|
+
], { stdio: 'ignore' });
|
|
94
|
+
fs.writeFileSync(stamp, want);
|
|
95
|
+
log(`cursor-tls: self-signed cert minted at ${TLS_DIR} covering ${TLS_HOSTS.length} hosts (no CA, no trust prompt)`);
|
|
96
|
+
return { cert, key };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read the whole request body. */
|
|
100
|
+
function readBody(req) {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
req.on('data', (c) => chunks.push(c));
|
|
104
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
105
|
+
req.on('error', () => resolve(Buffer.alloc(0)));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** grpc-web trailers frame: an enveloped block of "grpc-status:0\r\n". */
|
|
110
|
+
function grpcWebTrailer() {
|
|
111
|
+
const t = Buffer.from('grpc-status:0\r\ngrpc-message:\r\n', 'utf8');
|
|
112
|
+
const head = Buffer.alloc(5);
|
|
113
|
+
head.writeUInt8(0x80, 0); // trailer flag
|
|
114
|
+
head.writeUInt32BE(t.length, 1);
|
|
115
|
+
return Buffer.concat([head, t]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Envelope a message for grpc-web / connect-streaming (5-byte prefix). */
|
|
119
|
+
function envelope(payload) {
|
|
120
|
+
const head = Buffer.alloc(5);
|
|
121
|
+
head.writeUInt8(0, 0);
|
|
122
|
+
head.writeUInt32BE(payload.length, 1);
|
|
123
|
+
return Buffer.concat([head, payload]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** CORS headers — the editor's renderer is a browser; without these the
|
|
127
|
+
* preflight fails and the REAL request (e.g. full_stripe_profile) never fires. */
|
|
128
|
+
const CORS = {
|
|
129
|
+
'access-control-allow-origin': '*',
|
|
130
|
+
'access-control-allow-methods': 'GET,POST,OPTIONS,PUT,DELETE',
|
|
131
|
+
'access-control-allow-headers': '*',
|
|
132
|
+
'access-control-expose-headers': '*',
|
|
133
|
+
'access-control-allow-credentials': 'true',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* An ENTITLED stripe profile. `#4 OPTIONS /auth/full_stripe_profile` in the log
|
|
138
|
+
* is THE plan gate — this is what tells the editor whether it may use models.
|
|
139
|
+
* Empty (my first stub) reads as free, hence "upgrade". These fields report an
|
|
140
|
+
* active, unlimited membership so nothing is gated.
|
|
141
|
+
*/
|
|
142
|
+
function stripeProfile() {
|
|
143
|
+
// MUST MATCH the membershipType written into the applicationUser blob — Cursor
|
|
144
|
+
// refreshes the blob FROM this profile on launch, so a hardcoded 'pro' here
|
|
145
|
+
// overwrote our 'ultra' and the free-plan gate ("free plans can only use Auto")
|
|
146
|
+
// fired anyway. Same env var, one source of truth.
|
|
147
|
+
const m = process.env.OPENZOO_MEMBERSHIP || 'pro';
|
|
148
|
+
return JSON.stringify({
|
|
149
|
+
membershipType: m,
|
|
150
|
+
individualMembershipType: m,
|
|
151
|
+
teamMembershipType: null,
|
|
152
|
+
subscriptionStatus: 'active',
|
|
153
|
+
verifiedStudent: false,
|
|
154
|
+
trialEligible: false,
|
|
155
|
+
daysRemainingOnTrial: 0,
|
|
156
|
+
isOnStudentPlan: false,
|
|
157
|
+
hardLimit: null,
|
|
158
|
+
hardLimitPerUser: null,
|
|
159
|
+
usageBasedPricingEnabled: true,
|
|
160
|
+
monthlyUsageBasedLimit: 1000000,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Pull the user's prompt out of a StreamUnifiedChatRequest without the full
|
|
166
|
+
* schema: walk the protobuf, collect plausible UTF-8 string fields, take the
|
|
167
|
+
* longest natural-language one (the new user turn). The captured .bin refines it.
|
|
168
|
+
*/
|
|
169
|
+
function extractPromptText(buf) {
|
|
170
|
+
const strings = [];
|
|
171
|
+
const walk = (b, depth) => {
|
|
172
|
+
let i = 0;
|
|
173
|
+
while (i < b.length) {
|
|
174
|
+
let key = 0, shift = 0, byte;
|
|
175
|
+
do { if (i >= b.length) return; byte = b[i++]; key |= (byte & 0x7f) << shift; shift += 7; } while (byte & 0x80);
|
|
176
|
+
const wire = key & 7;
|
|
177
|
+
if (wire === 2) {
|
|
178
|
+
let len = 0; shift = 0;
|
|
179
|
+
do { if (i >= b.length) return; byte = b[i++]; len |= (byte & 0x7f) << shift; shift += 7; } while (byte & 0x80);
|
|
180
|
+
const sub = b.subarray(i, i + len); i += len;
|
|
181
|
+
const txt = sub.toString('utf8');
|
|
182
|
+
if (len > 1 && /[a-zA-Z]/.test(txt) && !/[\x00-\x08\x0e-\x1f]/.test(txt)) strings.push(txt);
|
|
183
|
+
else if (depth < 6) walk(sub, depth + 1);
|
|
184
|
+
} else if (wire === 0) { while (i < b.length && (b[i++] & 0x80)) { /* skip */ } }
|
|
185
|
+
else if (wire === 5) { i += 4; } else if (wire === 1) { i += 8; } else { return; }
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
try { walk(buf, 0); } catch { /* best effort */ }
|
|
189
|
+
const cand = strings.filter((x) => /\s/.test(x.trim()) || x.length > 12);
|
|
190
|
+
return ((cand.length ? cand : strings).sort((a, b) => b.length - a.length)[0] || '').trim();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** StreamUnifiedChatResponse{1: text}. */
|
|
194
|
+
function encodeChatResponse(text) {
|
|
195
|
+
const t = Buffer.from(text, 'utf8');
|
|
196
|
+
const head = [0x0a];
|
|
197
|
+
let n = t.length; do { let x = n & 0x7f; n = Math.floor(n / 128); if (n) x |= 0x80; head.push(x); } while (n);
|
|
198
|
+
return Buffer.concat([Buffer.from(head), t]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* THE TRIGGER. Cursor's chat inference is ChatService/StreamUnifiedChat to the
|
|
203
|
+
* agent host (now impersonated). Whatever the user typed into Grok Bot's UI is
|
|
204
|
+
* in this request — pull it out and hand it to OUR box (OZ_BOX_AGENT, set by
|
|
205
|
+
* grokbot.js to the box's :1337 podagent). The box's own openzoo brain drives
|
|
206
|
+
* the local-exec loop against the daemon running on this Mac (approval-gated),
|
|
207
|
+
* and its DONE: answer is what streams back into the UI. If no box is wired
|
|
208
|
+
* (OZ_BOX_AGENT unset — e.g. running the backend standalone), fall back to a
|
|
209
|
+
* plain one-shot zoo call so the app still gets a reply.
|
|
210
|
+
*/
|
|
211
|
+
async function handleStreamChat(req, res, body, log) {
|
|
212
|
+
const ct = String(req.headers['content-type'] || '');
|
|
213
|
+
const isGrpcWeb = ct.includes('grpc-web');
|
|
214
|
+
const prompt = extractPromptText(body) || 'hello';
|
|
215
|
+
log(`cursor-backend: >> StreamUnifiedChat prompt: ${JSON.stringify(prompt.slice(0, 80))}`);
|
|
216
|
+
let text = '';
|
|
217
|
+
const boxAgent = process.env.OZ_BOX_AGENT;
|
|
218
|
+
try {
|
|
219
|
+
if (boxAgent) {
|
|
220
|
+
const drive = await fetch(`${boxAgent}/drive`, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: { 'content-type': 'application/json' },
|
|
223
|
+
body: JSON.stringify({ task: prompt }),
|
|
224
|
+
// worst case: MAX_STEPS(10) x 60s exec waits + brain latency headroom
|
|
225
|
+
signal: AbortSignal.timeout(11 * 60_000),
|
|
226
|
+
});
|
|
227
|
+
const data = await drive.json();
|
|
228
|
+
text = data.text || '(box returned nothing)';
|
|
229
|
+
log(`cursor-backend: << box drove task -> ${text.length} chars`);
|
|
230
|
+
} else {
|
|
231
|
+
const zoo = await fetch('http://127.0.0.1:8402/v1/chat/completions', {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
model: process.env.OPENZOO_DEFAULT_MODEL || 'anthropic/claude-opus-5',
|
|
236
|
+
messages: [{ role: 'user', content: prompt }],
|
|
237
|
+
max_tokens: Number(process.env.OPENZOO_ASK_MAX_TOKENS || 2048),
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
const data = await zoo.json();
|
|
241
|
+
text = data.choices?.[0]?.message?.content || '(no content)';
|
|
242
|
+
log(`cursor-backend: << zoo replied ${text.length} chars (paid x402, no box wired)`);
|
|
243
|
+
}
|
|
244
|
+
} catch (e) { text = `openzoo error: ${e.message}`; log(`cursor-backend: drive call failed: ${e.message}`); }
|
|
245
|
+
|
|
246
|
+
res.writeHead(200, {
|
|
247
|
+
'content-type': isGrpcWeb ? 'application/grpc-web+proto' : 'application/connect+proto',
|
|
248
|
+
'grpc-status': '0', ...CORS,
|
|
249
|
+
});
|
|
250
|
+
res.write(envelope(encodeChatResponse(text)));
|
|
251
|
+
if (isGrpcWeb) { res.end(grpcWebTrailer()); }
|
|
252
|
+
else { const end = Buffer.from('{}'); const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1); res.end(Buffer.concat([h, end])); }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Headers that are illegal to copy onto an HTTP/2 response (RFC 7540 §8.1.2.2),
|
|
256
|
+
* plus the length/encoding fields that no longer describe our re-buffered body. */
|
|
257
|
+
const HOP_BY_HOP = new Set([
|
|
258
|
+
'connection', 'keep-alive', 'upgrade', 'proxy-connection',
|
|
259
|
+
'transfer-encoding', 'te', 'trailer', 'proxy-authenticate',
|
|
260
|
+
'proxy-authorization', 'content-encoding', 'content-length',
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* FORWARD A NON-INFERENCE REQUEST TO THE REAL ANTHROPIC API.
|
|
265
|
+
*
|
|
266
|
+
* We hijack api.anthropic.com at the DNS level, which catches far more than
|
|
267
|
+
* inference: the desktop app is OAuth/subscription-bound and boots by calling
|
|
268
|
+
* /api/organizations, /api/bootstrap, /api/account on the SAME host. Those are
|
|
269
|
+
* not ours to answer — the local proxy 404s them, the app cannot establish a
|
|
270
|
+
* session, and it hangs until macOS offers Force Quit.
|
|
271
|
+
*
|
|
272
|
+
* So for anything that is not inference we act as a plain reverse proxy to the
|
|
273
|
+
* genuine API, preserving the client's auth headers verbatim so the app's own
|
|
274
|
+
* OAuth session keeps working untouched.
|
|
275
|
+
*
|
|
276
|
+
* WE CANNOT RESOLVE THE NAME NORMALLY — /etc/hosts points it at us, so a plain
|
|
277
|
+
* fetch would loop straight back into this server. Resolve against public DNS
|
|
278
|
+
* over UDP (which ignores /etc/hosts entirely), cache it, and dial the address
|
|
279
|
+
* with the Host/SNI still set to the real hostname so TLS and routing succeed.
|
|
280
|
+
*/
|
|
281
|
+
// KEYED BY HOST. A single shared slot meant whichever of api/api-staging
|
|
282
|
+
// resolved first pinned its address for the other one too.
|
|
283
|
+
const realIpCache = new Map();
|
|
284
|
+
async function resolveRealAnthropic(host) {
|
|
285
|
+
const hit = realIpCache.get(host);
|
|
286
|
+
if (hit) return hit;
|
|
287
|
+
const { Resolver } = await import('node:dns/promises');
|
|
288
|
+
const r = new Resolver();
|
|
289
|
+
r.setServers(['1.1.1.1', '8.8.8.8']);
|
|
290
|
+
const [ip] = await r.resolve4(host);
|
|
291
|
+
realIpCache.set(host, ip);
|
|
292
|
+
return ip;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Headers we must NOT copy back from the upstream response.
|
|
297
|
+
*
|
|
298
|
+
* DELIBERATELY NARROWER THAN HOP_BY_HOP: `content-encoding` is absent here on
|
|
299
|
+
* purpose. Unlike fetch(), node:https hands us the body EXACTLY as it arrived —
|
|
300
|
+
* still gzip/brotli compressed. Stripping the encoding header while forwarding
|
|
301
|
+
* compressed bytes tells the app "this is plain JSON" over binary garbage, and
|
|
302
|
+
* it fails to parse the bootstrap payload. Forward body and label together.
|
|
303
|
+
* `content-length` still goes, because we set it from the buffer we actually
|
|
304
|
+
* have, and the h2 layer rejects the connection-specific ones outright.
|
|
305
|
+
*/
|
|
306
|
+
const RESP_DROP = new Set([
|
|
307
|
+
'connection', 'keep-alive', 'upgrade', 'proxy-connection',
|
|
308
|
+
'transfer-encoding', 'te', 'trailer', 'content-length',
|
|
309
|
+
]);
|
|
310
|
+
|
|
311
|
+
const SNIFF_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-sniff.jsonl');
|
|
312
|
+
const POD_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-pod.json');
|
|
313
|
+
const AGENTS_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-agents.json');
|
|
314
|
+
const HOME = os.homedir();
|
|
315
|
+
|
|
316
|
+
function readJsonFile(p) {
|
|
317
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
318
|
+
}
|
|
319
|
+
function writeJsonFile(p, v) {
|
|
320
|
+
try {
|
|
321
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
322
|
+
fs.writeFileSync(p, JSON.stringify(v));
|
|
323
|
+
} catch { /* */ }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let activeAccountId = null;
|
|
327
|
+
|
|
328
|
+
function migrateLegacyPod(accountId) {
|
|
329
|
+
if (!accountId) return;
|
|
330
|
+
const dest = accountPodPath(HOME, accountId);
|
|
331
|
+
const destA = accountAgentsPath(HOME, accountId);
|
|
332
|
+
if (!dest) return;
|
|
333
|
+
const legacy = readJsonFile(POD_FILE);
|
|
334
|
+
if (legacy?.agent && (!legacy.accountId || legacy.accountId === accountId) && !readJsonFile(dest)) {
|
|
335
|
+
writeJsonFile(dest, legacy);
|
|
336
|
+
}
|
|
337
|
+
if (legacy?.accountId === accountId && !readJsonFile(destA)) {
|
|
338
|
+
const agents = readJsonFile(AGENTS_FILE);
|
|
339
|
+
if (Array.isArray(agents) && agents.length) writeJsonFile(destA, agents);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function loadPod() {
|
|
344
|
+
const legacy = readJsonFile(POD_FILE);
|
|
345
|
+
const id = legacy?.accountId || activeAccountId;
|
|
346
|
+
if (id) {
|
|
347
|
+
migrateLegacyPod(id);
|
|
348
|
+
const scoped = readJsonFile(accountPodPath(HOME, id));
|
|
349
|
+
if (scoped?.agent) return scoped;
|
|
350
|
+
}
|
|
351
|
+
return legacy?.agent ? legacy : null;
|
|
352
|
+
}
|
|
353
|
+
function savePod(p) {
|
|
354
|
+
if (!p?.agent) return;
|
|
355
|
+
writeJsonFile(POD_FILE, p); // last-used pointer for same-user relaunch
|
|
356
|
+
if (p.accountId) writeJsonFile(accountPodPath(HOME, p.accountId), p);
|
|
357
|
+
}
|
|
358
|
+
let realPod = loadPod();
|
|
359
|
+
activeAccountId = realPod?.accountId || null;
|
|
360
|
+
if (activeAccountId) migrateLegacyPod(activeAccountId);
|
|
361
|
+
|
|
362
|
+
function agentsPath() {
|
|
363
|
+
if (!activeAccountId) return houseAgentsPath(HOME);
|
|
364
|
+
return accountAgentsPath(HOME, activeAccountId) || houseAgentsPath(HOME);
|
|
365
|
+
}
|
|
366
|
+
/** Cafe/web hijack: one house tray for every visitor. Electron sniff still
|
|
367
|
+
* talks to the live 1340 so a second Cursor login stays isolated. */
|
|
368
|
+
function useHouseRoster() {
|
|
369
|
+
return Boolean(process.env.OZ_HIJACK_POD) && !sniffOn();
|
|
370
|
+
}
|
|
371
|
+
function loadAgents() {
|
|
372
|
+
const house = filterDeleted(readHouseRoster(HOME, activeAccountId) || [], HOME);
|
|
373
|
+
const shaped = house.map(shapeAgent);
|
|
374
|
+
const dirty = shaped.some((a, i) => {
|
|
375
|
+
const prev = house[i] || {};
|
|
376
|
+
return (a.brief && a.brief !== String(prev.brief || prev.description || ''))
|
|
377
|
+
|| (a.name && a.name !== String(prev.name || prev.title || ''))
|
|
378
|
+
|| looksLikeAgentId(prev.name, prev.id);
|
|
379
|
+
});
|
|
380
|
+
if (dirty && shaped.length) {
|
|
381
|
+
writeJsonFile(houseAgentsPath(HOME), shaped);
|
|
382
|
+
if (activeAccountId) {
|
|
383
|
+
const p = accountAgentsPath(HOME, activeAccountId);
|
|
384
|
+
if (p) writeJsonFile(p, shaped);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const a of shaped) seedBriefOnCanvas(a);
|
|
388
|
+
return shaped;
|
|
389
|
+
}
|
|
390
|
+
function saveAgents(a) {
|
|
391
|
+
if (!Array.isArray(a)) return;
|
|
392
|
+
// Persist [] too — otherwise delete-all is a no-op and the 65 land back.
|
|
393
|
+
writeJsonFile(houseAgentsPath(HOME), a);
|
|
394
|
+
if (activeAccountId) {
|
|
395
|
+
const p = accountAgentsPath(HOME, activeAccountId);
|
|
396
|
+
if (p) writeJsonFile(p, a);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function cachedAgentList() {
|
|
400
|
+
const a = loadAgents();
|
|
401
|
+
return a.length ? a : null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function sniffOn() { return process.env.OPENZOO_SNIFF === '1'; }
|
|
405
|
+
function sniffSelf() { return process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443'; }
|
|
406
|
+
/** CURSOR_API_BASE_URL=https://127.0.0.1:8443 makes Host 127.0.0.1 — public DNS
|
|
407
|
+
* of that name is ENOTFOUND (measured sniff #3 /events, #4 listAgents). */
|
|
408
|
+
function cursorUpstream(host) {
|
|
409
|
+
const h = String(host || '').replace(/:\d+$/, '');
|
|
410
|
+
if (/cursor\.sh$/.test(h) || /anthropic\.com$/.test(h) || /cursorvm\.com$/.test(h)) return h;
|
|
411
|
+
return 'api2.cursor.sh';
|
|
412
|
+
}
|
|
413
|
+
function sniffDump(rec) {
|
|
414
|
+
if (!sniffOn()) return;
|
|
415
|
+
try {
|
|
416
|
+
fs.mkdirSync(path.dirname(SNIFF_FILE), { recursive: true, mode: 0o700 });
|
|
417
|
+
fs.appendFileSync(SNIFF_FILE, JSON.stringify({ at: new Date().toISOString(), ...rec }) + '\n');
|
|
418
|
+
} catch { /* dump must never break the proxy */ }
|
|
419
|
+
}
|
|
420
|
+
function jsonish(buf, limit = 12000) {
|
|
421
|
+
const s = Buffer.isBuffer(buf) ? buf.toString('utf8') : String(buf || '');
|
|
422
|
+
try { return JSON.parse(s); } catch { return s.slice(0, limit); }
|
|
423
|
+
}
|
|
424
|
+
function copyReqHeaders(req, host) {
|
|
425
|
+
const headers = {};
|
|
426
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
427
|
+
if (k.startsWith(':') || HOP_BY_HOP.has(k)) continue;
|
|
428
|
+
headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
|
|
429
|
+
}
|
|
430
|
+
if (host) headers.host = host;
|
|
431
|
+
delete headers['content-length'];
|
|
432
|
+
// Electron POSTs Expect: 100-continue. cursorvm 1340 answers 417 and the
|
|
433
|
+
// splash never leaves "Setting up Grok Bot's computer" (measured 2026-08-29).
|
|
434
|
+
delete headers.expect;
|
|
435
|
+
delete headers.Expect;
|
|
436
|
+
return headers;
|
|
437
|
+
}
|
|
438
|
+
function lookupPinned(ip) {
|
|
439
|
+
return (h, o, cb) => (o && o.all ? cb(null, [{ address: ip, family: 4 }]) : cb(null, ip, 4));
|
|
440
|
+
}
|
|
441
|
+
function inflateBody(buf, headers) {
|
|
442
|
+
const enc = String(headers?.['content-encoding'] || '').toLowerCase();
|
|
443
|
+
try {
|
|
444
|
+
if (enc.includes('gzip')) return zlib.gunzipSync(buf);
|
|
445
|
+
if (enc.includes('deflate')) return zlib.inflateSync(buf);
|
|
446
|
+
if (enc.includes('br')) return zlib.brotliDecompressSync(buf);
|
|
447
|
+
} catch { /* fall through */ }
|
|
448
|
+
if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) {
|
|
449
|
+
try { return zlib.gunzipSync(buf); } catch { /* */ }
|
|
450
|
+
}
|
|
451
|
+
return buf;
|
|
452
|
+
}
|
|
453
|
+
function writeCaptured(res, status, respHeaders, buf) {
|
|
454
|
+
const h = {};
|
|
455
|
+
for (const [k, v] of Object.entries(respHeaders || {})) {
|
|
456
|
+
if (RESP_DROP.has(k.toLowerCase())) continue;
|
|
457
|
+
h[k] = v;
|
|
458
|
+
}
|
|
459
|
+
res.writeHead(status, h);
|
|
460
|
+
res.end(buf);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function upstreamUnary({ host, path: pth, method, headers, body, timeoutMs = 20000 }) {
|
|
464
|
+
const ip = await resolveRealAnthropic(host);
|
|
465
|
+
return new Promise((resolve, reject) => {
|
|
466
|
+
const up = https.request({
|
|
467
|
+
hostname: host, servername: host, port: 443, path: pth,
|
|
468
|
+
method, headers, lookup: lookupPinned(ip),
|
|
469
|
+
}, (r) => {
|
|
470
|
+
const chunks = [];
|
|
471
|
+
r.on('data', (d) => chunks.push(d));
|
|
472
|
+
r.on('end', () => resolve({
|
|
473
|
+
status: r.statusCode, respHeaders: r.headers, buf: Buffer.concat(chunks),
|
|
474
|
+
}));
|
|
475
|
+
r.on('error', reject);
|
|
476
|
+
});
|
|
477
|
+
up.on('error', reject);
|
|
478
|
+
if (timeoutMs) {
|
|
479
|
+
up.setTimeout(timeoutMs, () => {
|
|
480
|
+
reject(new Error('upstream timeout'));
|
|
481
|
+
try { up.destroy(); } catch { /* */ }
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (method !== 'GET' && method !== 'HEAD' && body && body.length) up.write(body);
|
|
485
|
+
up.end();
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function passthroughToRealAnthropic(req, res, body, host, full, log) {
|
|
490
|
+
try {
|
|
491
|
+
host = cursorUpstream(host);
|
|
492
|
+
const streaming = /Watch|Stream|Subscribe/i.test(full);
|
|
493
|
+
if (streaming) {
|
|
494
|
+
await passthroughPipe(req, res, body, host, full, log);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const headers = copyReqHeaders(req, host);
|
|
498
|
+
const cap = await upstreamUnary({
|
|
499
|
+
host, path: full, method: req.method, headers, body, timeoutMs: 20000,
|
|
500
|
+
});
|
|
501
|
+
if (process.env.OPENZOO_DUMP === '1' && cap.buf.length) {
|
|
502
|
+
try {
|
|
503
|
+
const dir = '/tmp/openzoo-sniff';
|
|
504
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
505
|
+
const safe = full.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 80);
|
|
506
|
+
fs.writeFileSync(`${dir}/${Date.now()}_${safe}.bin`, cap.buf);
|
|
507
|
+
} catch { /* dumping must never break the proxy */ }
|
|
508
|
+
}
|
|
509
|
+
writeCaptured(res, cap.status, cap.respHeaders, cap.buf);
|
|
510
|
+
log(`cursor-backend: ${req.method} ${full} -> REAL ${host} (${cap.status}, ${cap.buf.length}b)`);
|
|
511
|
+
} catch (e) {
|
|
512
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
513
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `passthrough failed: ${e.message}` } }));
|
|
514
|
+
log(`cursor-backend: passthrough ${full} FAILED: ${e.message}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function passthroughPipe(req, res, body, host, full, log) {
|
|
519
|
+
host = cursorUpstream(host);
|
|
520
|
+
const ip = await resolveRealAnthropic(host);
|
|
521
|
+
const headers = copyReqHeaders(req, host);
|
|
522
|
+
await new Promise((resolve, reject) => {
|
|
523
|
+
const up = https.request({
|
|
524
|
+
hostname: host, servername: host, port: 443, path: full,
|
|
525
|
+
method: req.method, headers, lookup: lookupPinned(ip),
|
|
526
|
+
}, (r) => {
|
|
527
|
+
const h = {};
|
|
528
|
+
for (const [k, v] of Object.entries(r.headers || {})) {
|
|
529
|
+
if (RESP_DROP.has(k.toLowerCase())) continue;
|
|
530
|
+
h[k] = v;
|
|
531
|
+
}
|
|
532
|
+
res.writeHead(r.statusCode, h);
|
|
533
|
+
r.pipe(res);
|
|
534
|
+
r.on('end', resolve);
|
|
535
|
+
r.on('error', reject);
|
|
536
|
+
});
|
|
537
|
+
up.on('error', reject);
|
|
538
|
+
req.on('close', () => { try { up.destroy(); } catch { /* */ } });
|
|
539
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && body && body.length) up.write(body);
|
|
540
|
+
up.end();
|
|
541
|
+
});
|
|
542
|
+
log(`cursor-backend: -> PIPE ${full} -> REAL ${host}`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function rememberPod(fields, log) {
|
|
546
|
+
if (!fields || !fields[6]) return null;
|
|
547
|
+
const execDaemon = String(fields[6] || '');
|
|
548
|
+
const gateway = String(fields[10] || fields[6] || '');
|
|
549
|
+
const accountId = String(fields[2] || '');
|
|
550
|
+
if (activeAccountId && accountId && accountId !== activeAccountId) {
|
|
551
|
+
log(`cursor-backend: account ${activeAccountId} -> ${accountId} (drop previous tray)`);
|
|
552
|
+
cacheFallbackOk = true;
|
|
553
|
+
}
|
|
554
|
+
activeAccountId = accountId || activeAccountId;
|
|
555
|
+
if (activeAccountId) migrateLegacyPod(activeAccountId);
|
|
556
|
+
realPod = {
|
|
557
|
+
execDaemon,
|
|
558
|
+
agent: gateway, // /api/sendPrompt lives on gateway_url (1340), not exec_daemon (1337)
|
|
559
|
+
vnc: fields[7] ? String(fields[7]).split('/vnc.html')[0] : gateway,
|
|
560
|
+
vncPath: fields[7] ? String(fields[7]) : undefined,
|
|
561
|
+
token: String(fields[4] || ''),
|
|
562
|
+
accessToken: String(fields[11] || ''),
|
|
563
|
+
p1340: gateway,
|
|
564
|
+
p6081: fields[12] ? String(fields[12]) : undefined,
|
|
565
|
+
region: String(fields[1] || 'us1'),
|
|
566
|
+
accountId,
|
|
567
|
+
podId: String(fields[3] || ''),
|
|
568
|
+
};
|
|
569
|
+
sniffDump({ kind: 'pod', fields, realPod });
|
|
570
|
+
savePod(realPod);
|
|
571
|
+
podStale = false;
|
|
572
|
+
log(`cursor-backend: SNIFF real pod account=${accountId || '?'} ${realPod.agent}`);
|
|
573
|
+
return realPod;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function rewrittenBox({ confirmed = true } = {}) {
|
|
577
|
+
const self = sniffSelf();
|
|
578
|
+
const p = (confirmed && realPod) ? realPod : {};
|
|
579
|
+
return encodeEnsureSandBox({
|
|
580
|
+
region: p.region || 'us1',
|
|
581
|
+
accountId: p.accountId || 'openzoo',
|
|
582
|
+
podId: p.podId || 'openzoo-sniff',
|
|
583
|
+
token: p.token || 'openzoo',
|
|
584
|
+
accessToken: p.accessToken || p.token || 'openzoo',
|
|
585
|
+
agent: self,
|
|
586
|
+
vnc: self,
|
|
587
|
+
p1340: self,
|
|
588
|
+
p6081: self,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function replyEnsureBox(req, res, payload, full = '') {
|
|
593
|
+
const ct = String(req.headers['content-type'] || '');
|
|
594
|
+
if (/WatchSandBoxMigration/.test(full) || ct.includes('connect+proto')) {
|
|
595
|
+
res.writeHead(200, {
|
|
596
|
+
'content-type': 'application/connect+proto',
|
|
597
|
+
'grpc-status': '0',
|
|
598
|
+
...CORS,
|
|
599
|
+
});
|
|
600
|
+
const end = Buffer.from('{}');
|
|
601
|
+
const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
|
|
602
|
+
res.end(Buffer.concat([envelope(payload), h, end]));
|
|
603
|
+
} else if (ct.includes('grpc-web')) {
|
|
604
|
+
res.writeHead(200, {
|
|
605
|
+
'content-type': ct.includes('text') ? 'application/grpc-web-text+proto' : 'application/grpc-web+proto',
|
|
606
|
+
'grpc-status': '0', ...CORS,
|
|
607
|
+
});
|
|
608
|
+
res.end(Buffer.concat([envelope(payload), grpcWebTrailer()]));
|
|
609
|
+
} else {
|
|
610
|
+
res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
|
|
611
|
+
res.end(payload);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const discover = { promise: null, at: 0, ok: false };
|
|
616
|
+
let lastBoxReq = null;
|
|
617
|
+
let podStale = false;
|
|
618
|
+
let lastOauthAt = 0;
|
|
619
|
+
// Same-user relaunch may fall back to the last pod if api2 times out.
|
|
620
|
+
// A second login is detected by EnsureSandBox accountId, not by rotating Bearer.
|
|
621
|
+
let cacheFallbackOk = true;
|
|
622
|
+
|
|
623
|
+
function boxDiscoverHeaders(req) {
|
|
624
|
+
const headers = copyReqHeaders(req, 'api2.cursor.sh');
|
|
625
|
+
headers['content-type'] = 'application/proto';
|
|
626
|
+
headers.accept = 'application/proto';
|
|
627
|
+
delete headers['connect-protocol-version'];
|
|
628
|
+
delete headers['connect-timeout-ms'];
|
|
629
|
+
delete headers['grpc-timeout'];
|
|
630
|
+
delete headers.te;
|
|
631
|
+
return headers;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function fetchEnsureSandBox(req, body, log) {
|
|
635
|
+
const upstream = 'api2.cursor.sh';
|
|
636
|
+
const headers = boxDiscoverHeaders(req);
|
|
637
|
+
const cap = await upstreamUnary({
|
|
638
|
+
host: upstream,
|
|
639
|
+
path: '/aiserver.v1.GrokBotService/EnsureSandBox',
|
|
640
|
+
method: 'POST',
|
|
641
|
+
headers,
|
|
642
|
+
body: body && body.length ? body : Buffer.alloc(0),
|
|
643
|
+
timeoutMs: 60000,
|
|
644
|
+
});
|
|
645
|
+
const raw = inflateBody(cap.buf, cap.respHeaders);
|
|
646
|
+
const proto = unwrapConnect(raw);
|
|
647
|
+
const fields = decodeProtoFields(proto);
|
|
648
|
+
if (!fields?.[6]) {
|
|
649
|
+
throw new Error(`EnsureSandBox empty (${cap.status}, ${cap.buf.length}b)`);
|
|
650
|
+
}
|
|
651
|
+
rememberPod(fields, log);
|
|
652
|
+
try {
|
|
653
|
+
const remote = await podJson('/api/listAgents', {}, log);
|
|
654
|
+
if (Array.isArray(remote) && remote.length) {
|
|
655
|
+
const merged = rosterForEvent(mergeAgentLists(remote), agentActivity);
|
|
656
|
+
saveAgents(merged);
|
|
657
|
+
const active = focusedAgentId || merged[0]?.id;
|
|
658
|
+
ssePush('agents', { agents: merged, activeAgentId: active });
|
|
659
|
+
log(`cursor-backend: discovered roster n=${merged.length} account=${realPod.accountId}`);
|
|
660
|
+
}
|
|
661
|
+
} catch (e) {
|
|
662
|
+
log(`cursor-backend: discover roster ${e.message}`);
|
|
663
|
+
}
|
|
664
|
+
return realPod;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function ensureDiscover(req, body, log) {
|
|
668
|
+
lastBoxReq = { headers: { ...req.headers }, body: Buffer.from(body || []) };
|
|
669
|
+
if (discover.promise) return discover.promise;
|
|
670
|
+
const oauthFresh = lastOauthAt > discover.at;
|
|
671
|
+
if (discover.ok && realPod?.agent && !podStale && !oauthFresh) {
|
|
672
|
+
return Promise.resolve(realPod);
|
|
673
|
+
}
|
|
674
|
+
discover.at = Date.now();
|
|
675
|
+
const fakeReq = { headers: lastBoxReq.headers };
|
|
676
|
+
discover.promise = fetchEnsureSandBox(fakeReq, Buffer.alloc(0), log)
|
|
677
|
+
.then((p) => {
|
|
678
|
+
discover.ok = !!p?.agent;
|
|
679
|
+
discover.promise = null;
|
|
680
|
+
return p;
|
|
681
|
+
})
|
|
682
|
+
.catch((e) => {
|
|
683
|
+
discover.ok = false;
|
|
684
|
+
discover.promise = null;
|
|
685
|
+
log(`cursor-backend: EnsureSandBox discover failed (${e.message})`);
|
|
686
|
+
return null;
|
|
687
|
+
});
|
|
688
|
+
return discover.promise;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function waitForAccountPod(log, ms = 45000) {
|
|
692
|
+
if (realPod?.agent && !podStale && discover.ok) return realPod;
|
|
693
|
+
if (!discover.promise && lastBoxReq) {
|
|
694
|
+
ensureDiscover({ headers: lastBoxReq.headers }, lastBoxReq.body, log);
|
|
695
|
+
}
|
|
696
|
+
if (!discover.promise) return realPod && !podStale ? realPod : null;
|
|
697
|
+
try {
|
|
698
|
+
const got = await Promise.race([
|
|
699
|
+
discover.promise,
|
|
700
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), ms)),
|
|
701
|
+
]);
|
|
702
|
+
if (got === 'timeout') {
|
|
703
|
+
log('cursor-backend: listAgents waited on discover — still going');
|
|
704
|
+
return realPod && !podStale && cacheFallbackOk ? realPod : null;
|
|
705
|
+
}
|
|
706
|
+
return got && !podStale ? got : (realPod && !podStale && cacheFallbackOk ? realPod : null);
|
|
707
|
+
} catch {
|
|
708
|
+
return realPod && !podStale && cacheFallbackOk ? realPod : null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function sniffEnsureSandBox(req, res, body, host, full, log) {
|
|
713
|
+
const upstream = cursorUpstream(host);
|
|
714
|
+
const headers = copyReqHeaders(req, upstream);
|
|
715
|
+
const cap = await upstreamUnary({
|
|
716
|
+
host: upstream, path: full, method: req.method, headers, body, timeoutMs: 60000,
|
|
717
|
+
});
|
|
718
|
+
const raw = inflateBody(cap.buf, cap.respHeaders);
|
|
719
|
+
const proto = unwrapConnect(raw);
|
|
720
|
+
const fields = decodeProtoFields(proto);
|
|
721
|
+
rememberPod(fields, log);
|
|
722
|
+
sniffDump({
|
|
723
|
+
kind: 'EnsureSandBox',
|
|
724
|
+
status: cap.status,
|
|
725
|
+
bytes: cap.buf.length,
|
|
726
|
+
inflated: raw.length,
|
|
727
|
+
encoding: cap.respHeaders?.['content-encoding'] || '',
|
|
728
|
+
head: Buffer.from(raw.subarray(0, 24)).toString('hex'),
|
|
729
|
+
fields,
|
|
730
|
+
rewrittenTo: sniffSelf(),
|
|
731
|
+
});
|
|
732
|
+
const payload = realPod ? rewrittenBox() : proto;
|
|
733
|
+
const ct = String(req.headers['content-type'] || cap.respHeaders?.['content-type'] || '');
|
|
734
|
+
if (/WatchSandBoxMigration/.test(full) || ct.includes('connect+proto')) {
|
|
735
|
+
res.writeHead(200, {
|
|
736
|
+
'content-type': 'application/connect+proto',
|
|
737
|
+
'grpc-status': '0',
|
|
738
|
+
...CORS,
|
|
739
|
+
});
|
|
740
|
+
const end = Buffer.from('{}');
|
|
741
|
+
const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
|
|
742
|
+
res.end(Buffer.concat([envelope(payload), h, end]));
|
|
743
|
+
} else if (ct.includes('grpc-web')) {
|
|
744
|
+
res.writeHead(200, {
|
|
745
|
+
'content-type': ct.includes('text') ? 'application/grpc-web-text+proto' : 'application/grpc-web+proto',
|
|
746
|
+
'grpc-status': '0', ...CORS,
|
|
747
|
+
});
|
|
748
|
+
res.end(Buffer.concat([envelope(payload), grpcWebTrailer()]));
|
|
749
|
+
} else {
|
|
750
|
+
res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
|
|
751
|
+
res.end(payload);
|
|
752
|
+
}
|
|
753
|
+
log(`cursor-backend: SNIFF EnsureSandBox -> rewrite ${sniffSelf()} (${cap.status}, real ${cap.buf.length}b)`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function proxyPodHttp(req, res, full, body, log) {
|
|
757
|
+
if (podStale || !realPod?.agent) return false;
|
|
758
|
+
const agent = new URL(realPod.agent);
|
|
759
|
+
const headers = copyReqHeaders(req, agent.host);
|
|
760
|
+
// Incoming Authorization is Grok Bot oauth to api2. 1340 wants EnsureSandBox
|
|
761
|
+
// field 11. Leaving oauth in place 401s listAgents (measured: tails via
|
|
762
|
+
// podJson with field-11 worked, roster proxy with copied oauth 401ed).
|
|
763
|
+
delete headers.authorization;
|
|
764
|
+
delete headers.Authorization;
|
|
765
|
+
if (realPod.accessToken) headers.authorization = `Bearer ${realPod.accessToken}`;
|
|
766
|
+
if (realPod.token) headers['x-anyrun-network-token'] = realPod.token;
|
|
767
|
+
if (!headers['x-sand-slim-avatars']) headers['x-sand-slim-avatars'] = '1';
|
|
768
|
+
const path0 = (full || '').split('?')[0];
|
|
769
|
+
const interesting = /sendPrompt|Transcript|listAgents|promptAcceptance|openAgentTail|createAgent/i.test(path0);
|
|
770
|
+
if (path0 === '/events') {
|
|
771
|
+
const ip = await resolveRealAnthropic(agent.hostname);
|
|
772
|
+
await new Promise((resolve, reject) => {
|
|
773
|
+
const up = https.request({
|
|
774
|
+
hostname: agent.hostname, servername: agent.hostname, port: 443, path: full,
|
|
775
|
+
method: req.method, headers, lookup: lookupPinned(ip),
|
|
776
|
+
}, (r) => {
|
|
777
|
+
const h = { ...CORS };
|
|
778
|
+
for (const [k, v] of Object.entries(r.headers || {})) {
|
|
779
|
+
if (RESP_DROP.has(k.toLowerCase())) continue;
|
|
780
|
+
h[k] = v;
|
|
781
|
+
}
|
|
782
|
+
res.writeHead(r.statusCode, h);
|
|
783
|
+
const chunks = [];
|
|
784
|
+
r.on('data', (d) => {
|
|
785
|
+
res.write(d);
|
|
786
|
+
if (chunks.length < 40) chunks.push(d);
|
|
787
|
+
});
|
|
788
|
+
r.on('end', () => {
|
|
789
|
+
sniffDump({ kind: 'sse', path: full, sample: Buffer.concat(chunks).toString('utf8').slice(0, 8000) });
|
|
790
|
+
try { res.end(); } catch { /* */ }
|
|
791
|
+
resolve();
|
|
792
|
+
});
|
|
793
|
+
r.on('error', reject);
|
|
794
|
+
});
|
|
795
|
+
up.on('error', reject);
|
|
796
|
+
req.on('close', () => { try { up.destroy(); } catch { /* */ } });
|
|
797
|
+
up.end();
|
|
798
|
+
});
|
|
799
|
+
log('cursor-backend: SNIFF /events -> real pod (piped)');
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
const cap = await upstreamUnary({
|
|
804
|
+
host: agent.hostname,
|
|
805
|
+
path: full,
|
|
806
|
+
method: req.method,
|
|
807
|
+
headers,
|
|
808
|
+
body,
|
|
809
|
+
timeoutMs: path0 === '/health' ? 8000 : 120000,
|
|
810
|
+
});
|
|
811
|
+
if (cap.status === 417 || cap.status === 404) {
|
|
812
|
+
podStale = true;
|
|
813
|
+
log(`cursor-backend: 1340 ${cap.status} ${path0} — pod stale, local`);
|
|
814
|
+
if (path0 === '/api/listAgents') {
|
|
815
|
+
if (useHouseRoster()) {
|
|
816
|
+
jsonSend(res, rosterForEvent(loadAgents(), agentActivity));
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
const cached = rosterForAccount({
|
|
820
|
+
liveAccountId: activeAccountId,
|
|
821
|
+
cachedAccountId: activeAccountId,
|
|
822
|
+
cached: loadAgents(),
|
|
823
|
+
fallback: loadAgents(),
|
|
824
|
+
});
|
|
825
|
+
if (cached.length) {
|
|
826
|
+
jsonSend(res, rosterForEvent(mergeAgentLists(cached), agentActivity));
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
if (path0 === '/api/listAgents' && cap.status === 200) {
|
|
833
|
+
if (useHouseRoster()) {
|
|
834
|
+
jsonSend(res, rosterForEvent(loadAgents(), agentActivity));
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
try {
|
|
838
|
+
const parsed = JSON.parse(String(inflateBody(cap.buf, cap.respHeaders)));
|
|
839
|
+
if (Array.isArray(parsed)) {
|
|
840
|
+
const merged = rosterForEvent(mergeAgentLists(parsed), agentActivity);
|
|
841
|
+
saveAgents(merged);
|
|
842
|
+
jsonSend(res, merged);
|
|
843
|
+
log(`cursor-backend: listAgents 200 merged n=${merged.length} account=${activeAccountId || '?'}`);
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
} catch { /* */ }
|
|
847
|
+
}
|
|
848
|
+
if (cap.status !== 200 && path0 === '/api/listAgents') {
|
|
849
|
+
const cached = rosterForAccount({
|
|
850
|
+
liveAccountId: activeAccountId,
|
|
851
|
+
cachedAccountId: activeAccountId,
|
|
852
|
+
cached: loadAgents(),
|
|
853
|
+
fallback: loadAgents(),
|
|
854
|
+
});
|
|
855
|
+
if (cached.length || useHouseRoster()) {
|
|
856
|
+
jsonSend(res, rosterForEvent(mergeAgentLists(cached), agentActivity));
|
|
857
|
+
log(`cursor-backend: listAgents ${cap.status} — cached ${cached.length} named agents account=${activeAccountId}`);
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
writeCaptured(res, cap.status, cap.respHeaders, cap.buf);
|
|
862
|
+
const rec = {
|
|
863
|
+
kind: 'pod-http',
|
|
864
|
+
method: req.method,
|
|
865
|
+
path: path0,
|
|
866
|
+
status: cap.status,
|
|
867
|
+
reqBytes: body?.length || 0,
|
|
868
|
+
resBytes: cap.buf.length,
|
|
869
|
+
req: jsonish(body, 4000),
|
|
870
|
+
res: jsonish(inflateBody(cap.buf, cap.respHeaders), 16000),
|
|
871
|
+
};
|
|
872
|
+
sniffDump(rec);
|
|
873
|
+
if (interesting) {
|
|
874
|
+
log(`cursor-backend: SNIFF ${path0} ${cap.status} req=${JSON.stringify(rec.req).slice(0, 220)} res=${JSON.stringify(rec.res).slice(0, 500)}`);
|
|
875
|
+
} else {
|
|
876
|
+
log(`cursor-backend: SNIFF ${path0} -> real pod (${cap.status}, ${cap.buf.length}b)`);
|
|
877
|
+
}
|
|
878
|
+
return true;
|
|
879
|
+
} catch (e) {
|
|
880
|
+
log(`cursor-backend: SNIFF ${path0} FAILED ${e.message}`);
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** JSON for the in-pod agent HTTP API. Hijack points field-6 at THIS
|
|
886
|
+
* server, so GET /health and POST /api/* land here — empty protobuf on
|
|
887
|
+
* those is "unhealthy" and Grok Bot retries EnsureSandBox forever
|
|
888
|
+
* (measured: /health empty-ok then EnsureSandBox #1945+). */
|
|
889
|
+
function jsonSend(res, obj) {
|
|
890
|
+
res.writeHead(200, { 'content-type': 'application/json', ...CORS });
|
|
891
|
+
res.end(JSON.stringify(obj));
|
|
892
|
+
}
|
|
893
|
+
/** Box HTTP API envelope. Client parse (CVr) returns null unless both
|
|
894
|
+
* status==="ok" AND "value" in n — our unwrapped {ok:true}/{entries} was
|
|
895
|
+
* dropped, so Grok Bot never painted the zoo reply (measured: << zoo 200 91c
|
|
896
|
+
* then tail polls with Failed to send). */
|
|
897
|
+
function jsonApi(res, value) {
|
|
898
|
+
jsonSend(res, { status: 'ok', value });
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const sseClients = new Set();
|
|
902
|
+
/** Gateway SSE parser (asar dispatchEventBlock) only reads `data:` lines and
|
|
903
|
+
* requires `{channel, payload}`. `event:` names are ignored. */
|
|
904
|
+
function ssePush(channel, payload) {
|
|
905
|
+
const chunk = `data: ${JSON.stringify({ channel, payload })}\n\n`;
|
|
906
|
+
for (const c of sseClients) {
|
|
907
|
+
try { c.write(chunk); } catch { sseClients.delete(c); }
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
let focusedAgentId = null;
|
|
912
|
+
const agentActivity = new Map(); // id -> { updatedAt, unreadCount, hasUnread, preview }
|
|
913
|
+
function noteFocus(id) {
|
|
914
|
+
if (!id) return;
|
|
915
|
+
focusedAgentId = id;
|
|
916
|
+
const a = agentActivity.get(id) || {};
|
|
917
|
+
a.hasUnread = false;
|
|
918
|
+
a.unreadCount = 0;
|
|
919
|
+
agentActivity.set(id, a);
|
|
920
|
+
}
|
|
921
|
+
function topConversationId(list) {
|
|
922
|
+
const arr = Array.isArray(list) ? list : [];
|
|
923
|
+
return focusedAgentId && arr.some((a) => a.id === focusedAgentId)
|
|
924
|
+
? focusedAgentId
|
|
925
|
+
: (arr[0]?.id || null);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function stampActivity(agent) {
|
|
929
|
+
const a = agentActivity.get(agent.id) || {};
|
|
930
|
+
const updatedAt = a.updatedAt || agent.updatedAt || agent.createdAt || 0;
|
|
931
|
+
return {
|
|
932
|
+
...agent,
|
|
933
|
+
updatedAt,
|
|
934
|
+
hasUnread: !!a.hasUnread,
|
|
935
|
+
unreadCount: a.unreadCount || 0,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function sortAgentsByActivity(list) {
|
|
939
|
+
return [...list].sort((x, y) => {
|
|
940
|
+
const ax = agentActivity.get(x.id)?.updatedAt || x.updatedAt || x.createdAt || 0;
|
|
941
|
+
const ay = agentActivity.get(y.id)?.updatedAt || y.updatedAt || y.createdAt || 0;
|
|
942
|
+
return ay - ax;
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
function bumpAgent(id, { preview = '', notify = true } = {}) {
|
|
946
|
+
const now = Date.now();
|
|
947
|
+
const a = agentActivity.get(id) || { unreadCount: 0 };
|
|
948
|
+
a.updatedAt = now;
|
|
949
|
+
if (preview) a.preview = String(preview).slice(0, 120);
|
|
950
|
+
const other = notify && focusedAgentId && focusedAgentId !== id;
|
|
951
|
+
if (other) {
|
|
952
|
+
a.hasUnread = true;
|
|
953
|
+
a.unreadCount = (a.unreadCount || 0) + 1;
|
|
954
|
+
}
|
|
955
|
+
agentActivity.set(id, a);
|
|
956
|
+
const list = cachedAgentList() || [];
|
|
957
|
+
const idx = list.findIndex((x) => x.id === id);
|
|
958
|
+
const base = idx >= 0 ? list[idx] : { id, name: 'chat' };
|
|
959
|
+
const agent = shapeAgent(stampActivity({ ...base, updatedAt: now }));
|
|
960
|
+
if (idx >= 0) list[idx] = agent;
|
|
961
|
+
else list.unshift(agent);
|
|
962
|
+
saveAgents(sortAgentsByActivity(list.map(shapeAgent)));
|
|
963
|
+
pushCreatedAgent(agent);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** asar ingestAgentsEvent does Ae.agents.map — `{action:"created"}` has no
|
|
967
|
+
* agents[] so the picker/tray throws and looks empty. ingestAgentUpserted
|
|
968
|
+
* needs {agent, activeAgentId}. Same payload bumpAgent already sends. */
|
|
969
|
+
function pushCreatedAgent(agent, { select = true } = {}) {
|
|
970
|
+
if (!agent || !agent.id) return;
|
|
971
|
+
if (select) focusedAgentId = agent.id;
|
|
972
|
+
const active = focusedAgentId || agent.id;
|
|
973
|
+
const list = rosterForEvent(cachedAgentList() || [], agentActivity);
|
|
974
|
+
ssePush('agent-upserted', { agent, activeAgentId: active });
|
|
975
|
+
ssePush('agents', { agents: list, activeAgentId: active });
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Grok Bot Helper daemon: GET /local-exec/requests is SSE, POST /local-exec/responses
|
|
979
|
+
* is `{providerId, frames}`. We used to JSON-[] the GET which is "disconnected"
|
|
980
|
+
* (measured: Grok Bot "can't see files on your local computer"). */
|
|
981
|
+
const localExecSse = new Set();
|
|
982
|
+
const localExecWaiters = new Map();
|
|
983
|
+
let localExecHello = null;
|
|
984
|
+
function localExecPush(frame) {
|
|
985
|
+
const chunk = `data: ${JSON.stringify(frame)}\n\n`;
|
|
986
|
+
for (const c of localExecSse) {
|
|
987
|
+
try { c.write(chunk); } catch { localExecSse.delete(c); }
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
function handleLocalExecFrames(frames, log) {
|
|
991
|
+
for (const f of frames || []) {
|
|
992
|
+
if (!f || typeof f !== 'object') continue;
|
|
993
|
+
if (f.kind === 'hello') {
|
|
994
|
+
localExecHello = f;
|
|
995
|
+
log(`cursor-backend: local-exec hello computer=${f.computerId || f.label || '?'} root=${f.localRoot || '?'}`);
|
|
996
|
+
}
|
|
997
|
+
const w = f.requestId && localExecWaiters.get(f.requestId);
|
|
998
|
+
if (!w) continue;
|
|
999
|
+
if (f.kind === 'file') {
|
|
1000
|
+
const buf = Buffer.from(f.bytesBase64 || '', 'base64');
|
|
1001
|
+
w.resolve({ kind: 'file', bytes: buf, text: buf.toString('utf8') });
|
|
1002
|
+
localExecWaiters.delete(f.requestId);
|
|
1003
|
+
} else if (f.kind === 'file-error' || f.kind === 'messages-error') {
|
|
1004
|
+
w.reject(new Error(f.error || 'local-exec error'));
|
|
1005
|
+
localExecWaiters.delete(f.requestId);
|
|
1006
|
+
} else if (f.kind === 'client' || f.kind === 'control' || f.kind === 'result' || f.kind === 'exec-result') {
|
|
1007
|
+
w.resolve({
|
|
1008
|
+
kind: f.kind,
|
|
1009
|
+
message: f.message || f.text || f.stdout || JSON.stringify(f),
|
|
1010
|
+
stdout: f.stdout,
|
|
1011
|
+
stderr: f.stderr,
|
|
1012
|
+
exitCode: f.exitCode,
|
|
1013
|
+
});
|
|
1014
|
+
localExecWaiters.delete(f.requestId);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
function localExecAsk(frame, timeoutMs = 45000) {
|
|
1019
|
+
const requestId = frame.requestId || randomUUID();
|
|
1020
|
+
const job = { ...frame, requestId };
|
|
1021
|
+
return new Promise((resolve, reject) => {
|
|
1022
|
+
const t = setTimeout(() => {
|
|
1023
|
+
localExecWaiters.delete(requestId);
|
|
1024
|
+
reject(new Error('local-exec timeout — is Grok Bot Helper connected?'));
|
|
1025
|
+
}, timeoutMs);
|
|
1026
|
+
localExecWaiters.set(requestId, {
|
|
1027
|
+
resolve: (v) => { clearTimeout(t); resolve(v); },
|
|
1028
|
+
reject: (e) => { clearTimeout(t); reject(e); },
|
|
1029
|
+
});
|
|
1030
|
+
localExecPush(job);
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
async function handleLocalExecHttp(req, res, path0, body, log) {
|
|
1034
|
+
if (!/^\/local-exec\//.test(path0)) return false;
|
|
1035
|
+
if (req.method === 'GET' && /\/requests$/.test(path0)) {
|
|
1036
|
+
res.writeHead(200, {
|
|
1037
|
+
'content-type': 'text/event-stream',
|
|
1038
|
+
'cache-control': 'no-cache',
|
|
1039
|
+
...CORS,
|
|
1040
|
+
});
|
|
1041
|
+
localExecSse.add(res);
|
|
1042
|
+
// Daemon parser (asar CNt) ignores unknown welcome; a comment ping is enough
|
|
1043
|
+
// to keep the stream alive until it POSTs hello to /responses.
|
|
1044
|
+
res.write(': openzoo local-exec\n\n');
|
|
1045
|
+
const iv = setInterval(() => {
|
|
1046
|
+
try { res.write(': ping\n\n'); } catch { clearInterval(iv); localExecSse.delete(res); }
|
|
1047
|
+
}, 10000);
|
|
1048
|
+
req.on('close', () => { clearInterval(iv); localExecSse.delete(res); log('cursor-backend: local-exec sse closed'); });
|
|
1049
|
+
log(`cursor-backend: -> local-exec /requests sse n=${localExecSse.size}`);
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
if (/\/responses$/.test(path0)) {
|
|
1053
|
+
let parsed = {};
|
|
1054
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
1055
|
+
handleLocalExecFrames(parsed.frames, log);
|
|
1056
|
+
jsonSend(res, { ok: true });
|
|
1057
|
+
log(`cursor-backend: -> local-exec /responses n=${(parsed.frames || []).length} kinds=${(parsed.frames || []).map((f) => f?.kind).join(',')}`);
|
|
1058
|
+
return true;
|
|
1059
|
+
}
|
|
1060
|
+
jsonSend(res, { ok: true });
|
|
1061
|
+
return true;
|
|
1062
|
+
}
|
|
1063
|
+
function extractLocalPaths(text) {
|
|
1064
|
+
const out = [];
|
|
1065
|
+
const re = /(?:~\/|\/Users\/)[^\s,;:!?()[\]{}"'`]+/g;
|
|
1066
|
+
let m;
|
|
1067
|
+
while ((m = re.exec(String(text || '')))) {
|
|
1068
|
+
out.push(m[0].replace(/[.,;:]+$/, ''));
|
|
1069
|
+
}
|
|
1070
|
+
return [...new Set(out)];
|
|
1071
|
+
}
|
|
1072
|
+
function expandUserPath(p) {
|
|
1073
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
1074
|
+
return p;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Per-agent transcript. sendPrompt is async: accept immediately, zooComplete
|
|
1078
|
+
* fills this, getAgentTranscriptTail is what the UI actually polls.
|
|
1079
|
+
*
|
|
1080
|
+
* Gateway `isValidTranscriptEntry` (asar Fgi/RC) DROPS anything that is not
|
|
1081
|
+
* `{id, kind:"message", content}` / `{id, kind:"send-message", message}` —
|
|
1082
|
+
* proto wrappers with entryKind/body were counted n=2/2 here and painted
|
|
1083
|
+
* as zero on the client, so overlay timed out → "Failed to send". */
|
|
1084
|
+
const transcripts = new Map();
|
|
1085
|
+
const tailedAgents = new Set();
|
|
1086
|
+
let lastSendEchoId = `oz-${Date.now()}`;
|
|
1087
|
+
const TX_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-transcripts.json');
|
|
1088
|
+
function loadTranscripts() {
|
|
1089
|
+
try {
|
|
1090
|
+
const o = JSON.parse(fs.readFileSync(TX_FILE, 'utf8'));
|
|
1091
|
+
for (const [id, t] of Object.entries(o || {})) {
|
|
1092
|
+
transcripts.set(id, {
|
|
1093
|
+
seq: Number(t.seq) || (t.entries || []).length,
|
|
1094
|
+
entries: Array.isArray(t.entries) ? t.entries : [],
|
|
1095
|
+
pulledRemote: false,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
} catch { /* first run */ }
|
|
1099
|
+
}
|
|
1100
|
+
function saveTranscriptsNow() {
|
|
1101
|
+
try {
|
|
1102
|
+
const o = {};
|
|
1103
|
+
for (const [id, t] of transcripts) {
|
|
1104
|
+
o[id] = { seq: t.seq, entries: t.entries.slice(-200) };
|
|
1105
|
+
}
|
|
1106
|
+
fs.mkdirSync(path.dirname(TX_FILE), { recursive: true });
|
|
1107
|
+
fs.writeFileSync(TX_FILE, JSON.stringify(o));
|
|
1108
|
+
} catch { /* */ }
|
|
1109
|
+
}
|
|
1110
|
+
let saveTxTimer;
|
|
1111
|
+
function saveTranscripts() {
|
|
1112
|
+
clearTimeout(saveTxTimer);
|
|
1113
|
+
saveTxTimer = setTimeout(saveTranscriptsNow, 250);
|
|
1114
|
+
}
|
|
1115
|
+
function flushTranscripts() {
|
|
1116
|
+
clearTimeout(saveTxTimer);
|
|
1117
|
+
saveTranscriptsNow();
|
|
1118
|
+
}
|
|
1119
|
+
process.once('beforeExit', flushTranscripts);
|
|
1120
|
+
loadTranscripts();
|
|
1121
|
+
function agentTranscript(id) {
|
|
1122
|
+
let t = transcripts.get(id);
|
|
1123
|
+
if (!t) { t = { seq: 0, entries: [], pulledRemote: false }; transcripts.set(id, t); }
|
|
1124
|
+
return t;
|
|
1125
|
+
}
|
|
1126
|
+
function appendLine(agentId, role, text, extra = {}) {
|
|
1127
|
+
const t = agentTranscript(agentId);
|
|
1128
|
+
t.seq += 1;
|
|
1129
|
+
const nonce = extra.clientNonce ? String(extra.clientNonce) : undefined;
|
|
1130
|
+
const ts = Date.now();
|
|
1131
|
+
const requestId = extra.requestId || nonce || `oz-req-${t.seq}`;
|
|
1132
|
+
let e;
|
|
1133
|
+
if (role === 'user') {
|
|
1134
|
+
// Live cursorvm getAgentTranscriptTail (2026-08-29): user echo is
|
|
1135
|
+
// kind:"message" + role:"user" + clientNonce. Assistant is kind:"send-message".
|
|
1136
|
+
e = {
|
|
1137
|
+
seq: t.seq,
|
|
1138
|
+
kind: 'message',
|
|
1139
|
+
id: extra.id || `t${t.seq}u`,
|
|
1140
|
+
role: 'user',
|
|
1141
|
+
content: String(text || ''),
|
|
1142
|
+
richText: extra.richText || JSON.stringify({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: String(text || '') }] }] }),
|
|
1143
|
+
isStreaming: false,
|
|
1144
|
+
timestampMs: ts,
|
|
1145
|
+
...(nonce ? { clientNonce: nonce } : {}),
|
|
1146
|
+
requestId,
|
|
1147
|
+
...(extra.promptRaw != null ? { promptRaw: String(extra.promptRaw) } : {}),
|
|
1148
|
+
};
|
|
1149
|
+
} else {
|
|
1150
|
+
e = {
|
|
1151
|
+
seq: t.seq,
|
|
1152
|
+
kind: 'send-message',
|
|
1153
|
+
id: extra.id || `t${t.seq}s0`,
|
|
1154
|
+
message: { type: 'text', content: String(text || '') },
|
|
1155
|
+
timestampMs: ts,
|
|
1156
|
+
requestId,
|
|
1157
|
+
...(extra.author && typeof extra.author === 'object' ? { author: extra.author } : {}),
|
|
1158
|
+
...(extra.ephemeral ? { ephemeral: true } : {}),
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
t.entries.push(e);
|
|
1162
|
+
saveTranscripts();
|
|
1163
|
+
return e;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** One zooComplete per agent. A new sendPrompt (or Stop) aborts the previous
|
|
1167
|
+
* loop so "try again" does not stack 32-step exec storms with an empty canvas. */
|
|
1168
|
+
export function createZooTurnQueue() {
|
|
1169
|
+
const inflight = new Map();
|
|
1170
|
+
return {
|
|
1171
|
+
begin(agentId, nonce) {
|
|
1172
|
+
const id = String(agentId || '');
|
|
1173
|
+
const prev = inflight.get(id);
|
|
1174
|
+
if (prev && prev.nonce !== nonce) {
|
|
1175
|
+
try { prev.abort.abort(new Error('superseded')); } catch { /* */ }
|
|
1176
|
+
}
|
|
1177
|
+
const abort = new AbortController();
|
|
1178
|
+
inflight.set(id, { nonce, abort });
|
|
1179
|
+
return abort;
|
|
1180
|
+
},
|
|
1181
|
+
isCurrent(agentId, nonce) {
|
|
1182
|
+
return inflight.get(String(agentId || ''))?.nonce === nonce;
|
|
1183
|
+
},
|
|
1184
|
+
end(agentId, nonce) {
|
|
1185
|
+
const id = String(agentId || '');
|
|
1186
|
+
const cur = inflight.get(id);
|
|
1187
|
+
if (cur && cur.nonce === nonce) inflight.delete(id);
|
|
1188
|
+
},
|
|
1189
|
+
abort(agentId) {
|
|
1190
|
+
const id = String(agentId || '');
|
|
1191
|
+
const prev = inflight.get(id);
|
|
1192
|
+
if (!prev) return 0;
|
|
1193
|
+
try { prev.abort.abort(new Error('interrupted')); } catch { /* */ }
|
|
1194
|
+
inflight.delete(id);
|
|
1195
|
+
return 1;
|
|
1196
|
+
},
|
|
1197
|
+
busy(agentId) {
|
|
1198
|
+
return inflight.has(String(agentId || ''));
|
|
1199
|
+
},
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
const zooTurns = createZooTurnQueue();
|
|
1203
|
+
|
|
1204
|
+
const wakeupTimers = new Map();
|
|
1205
|
+
let wakeupLog = () => {};
|
|
1206
|
+
let wakeupsRestored = false;
|
|
1207
|
+
|
|
1208
|
+
function persistWakeups(map) {
|
|
1209
|
+
try { writeWakeups(HOME, map); } catch (e) {
|
|
1210
|
+
wakeupLog(`cursor-backend: wakeups save failed: ${e.message}`);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
export function listAgentWakeups() {
|
|
1215
|
+
return readWakeups(HOME);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
export function cancelAgentWakeup(agentId) {
|
|
1219
|
+
const id = String(agentId || '');
|
|
1220
|
+
const t = wakeupTimers.get(id);
|
|
1221
|
+
if (t) {
|
|
1222
|
+
clearTimeout(t);
|
|
1223
|
+
wakeupTimers.delete(id);
|
|
1224
|
+
}
|
|
1225
|
+
const map = readWakeups(HOME);
|
|
1226
|
+
if (!map[id]) return { ok: true, cancelled: false };
|
|
1227
|
+
delete map[id];
|
|
1228
|
+
persistWakeups(map);
|
|
1229
|
+
return { ok: true, cancelled: true, id };
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function armWakeup(rec) {
|
|
1233
|
+
const id = rec.agentId;
|
|
1234
|
+
const prev = wakeupTimers.get(id);
|
|
1235
|
+
if (prev) clearTimeout(prev);
|
|
1236
|
+
const delay = Math.max(1000, Number(rec.nextAt) - Date.now());
|
|
1237
|
+
const t = setTimeout(() => {
|
|
1238
|
+
fireAgentWakeup(id).catch((e) => wakeupLog(`cursor-backend: wakeup fire ${id}: ${e.message}`));
|
|
1239
|
+
}, delay);
|
|
1240
|
+
wakeupTimers.set(id, t);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
export function scheduleAgentWakeup(agentId, opts = {}) {
|
|
1244
|
+
const id = String(agentId || '').trim();
|
|
1245
|
+
if (!id) return { ok: false, error: 'no agent' };
|
|
1246
|
+
const now = Date.now();
|
|
1247
|
+
const every = opts.every ?? opts.everySec;
|
|
1248
|
+
const rec = shapeWakeup(id, {
|
|
1249
|
+
every,
|
|
1250
|
+
prompt: opts.prompt,
|
|
1251
|
+
lastAt: 0,
|
|
1252
|
+
nextAt: now + parseWakeupEvery(every) * 1000,
|
|
1253
|
+
}, now);
|
|
1254
|
+
const map = readWakeups(HOME);
|
|
1255
|
+
map[id] = rec;
|
|
1256
|
+
persistWakeups(map);
|
|
1257
|
+
armWakeup(rec);
|
|
1258
|
+
wakeupLog(`cursor-backend: wakeup every ${rec.everySec}s agent=${id}`);
|
|
1259
|
+
return { ok: true, ...rec };
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
async function fireAgentWakeup(agentId) {
|
|
1263
|
+
const id = String(agentId || '');
|
|
1264
|
+
const map = readWakeups(HOME);
|
|
1265
|
+
const rec = map[id];
|
|
1266
|
+
if (!rec) {
|
|
1267
|
+
wakeupTimers.delete(id);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
rec.lastAt = Date.now();
|
|
1271
|
+
rec.nextAt = rec.lastAt + rec.everySec * 1000;
|
|
1272
|
+
persistWakeups(map);
|
|
1273
|
+
armWakeup(rec);
|
|
1274
|
+
if (zooTurns.busy(id)) {
|
|
1275
|
+
wakeupLog(`cursor-backend: wakeup skip busy agent=${id}`);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (focusedAgentId && String(focusedAgentId) === id) {
|
|
1279
|
+
wakeupLog(`cursor-backend: wakeup skip focused agent=${id}`);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const nonce = `oz-wakeup-${id}-${rec.lastAt}`;
|
|
1283
|
+
const prompt = rec.prompt || DEFAULT_WAKEUP_PROMPT;
|
|
1284
|
+
const turn = zooTurns.begin(id, nonce);
|
|
1285
|
+
const userLine = fanoutLine(id, 'user', `[wakeup]\n${prompt}`, { clientNonce: nonce, requestId: nonce });
|
|
1286
|
+
ssePush('transcript', { ...gatewayEntry(userLine), agentId: id });
|
|
1287
|
+
bumpAgent(id, { preview: '[wakeup]', notify: false });
|
|
1288
|
+
wakeupLog(`cursor-backend: wakeup start agent=${id}`);
|
|
1289
|
+
try {
|
|
1290
|
+
const z = await zooComplete(prompt, wakeupLog, id, {}, {
|
|
1291
|
+
signal: turn.signal,
|
|
1292
|
+
onProgress: (note) => {
|
|
1293
|
+
if (!zooTurns.isCurrent(id, nonce)) return;
|
|
1294
|
+
paintChatUpdate(id, note, { clientNonce: nonce, requestId: nonce });
|
|
1295
|
+
},
|
|
1296
|
+
});
|
|
1297
|
+
if (!zooTurns.isCurrent(id, nonce)) return;
|
|
1298
|
+
const line = fanoutLine(id, 'assistant', z.text, { clientNonce: nonce, requestId: nonce });
|
|
1299
|
+
ssePush('transcript', { ...gatewayEntry(line), agentId: id });
|
|
1300
|
+
bumpAgent(id, { preview: z.text, notify: true });
|
|
1301
|
+
wakeupLog(`cursor-backend: wakeup done agent=${id} seq=${line.seq}`);
|
|
1302
|
+
} catch (e) {
|
|
1303
|
+
if (!isSupersededError(e)) wakeupLog(`cursor-backend: wakeup failed agent=${id}: ${e.message}`);
|
|
1304
|
+
} finally {
|
|
1305
|
+
zooTurns.end(id, nonce);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
export function restoreAgentWakeups(log = () => {}) {
|
|
1310
|
+
wakeupLog = log;
|
|
1311
|
+
if (wakeupsRestored) return 0;
|
|
1312
|
+
wakeupsRestored = true;
|
|
1313
|
+
const map = readWakeups(HOME);
|
|
1314
|
+
const ids = Object.keys(map);
|
|
1315
|
+
const now = Date.now();
|
|
1316
|
+
ids.forEach((id, i) => {
|
|
1317
|
+
// Do not fire during Grok Bot boot. 10 parallel zoo turns + CDP on the
|
|
1318
|
+
// spinner page left the window on a white disc. First tick is +90s.
|
|
1319
|
+
map[id].nextAt = now + 90_000 + i * 4000;
|
|
1320
|
+
});
|
|
1321
|
+
if (ids.length) persistWakeups(map);
|
|
1322
|
+
for (const id of ids) armWakeup(map[id]);
|
|
1323
|
+
if (ids.length) log(`cursor-backend: wakeups restored ${ids.length}`);
|
|
1324
|
+
return ids.length;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
export function formatZooProgress({ step, maxSteps, names, command } = {}) {
|
|
1328
|
+
const tools = Array.isArray(names) ? names.filter(Boolean).join(', ') : String(names || 'tools');
|
|
1329
|
+
const cmd = command ? ` ${JSON.stringify(String(command).slice(0, 80))}` : '';
|
|
1330
|
+
return `Working on your Mac (step ${Number(step) + 1}/${maxSteps || '?'}): ${tools}${cmd}`;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** One canvas line per tool. Asar ingest is append-only — mutating a working
|
|
1334
|
+
* bubble is still silence. Keep it short; the model history skips ephemeral. */
|
|
1335
|
+
export function formatZooToolLine({ name, args = {}, result } = {}) {
|
|
1336
|
+
const detail = args.command || args.path || args.name || args.window
|
|
1337
|
+
|| args.query || args.url || args.key || args.app || args.every
|
|
1338
|
+
|| (args.x != null && args.y != null ? `${args.x},${args.y}` : '')
|
|
1339
|
+
|| (args.text != null ? String(args.text).slice(0, 80) : '');
|
|
1340
|
+
const head = detail
|
|
1341
|
+
? `${name} ${JSON.stringify(String(detail).slice(0, 90))}`
|
|
1342
|
+
: String(name || 'tool');
|
|
1343
|
+
let tail = String(result || '').replace(/\s+/g, ' ').trim();
|
|
1344
|
+
if (tail.startsWith('{')) {
|
|
1345
|
+
try {
|
|
1346
|
+
const o = JSON.parse(String(result));
|
|
1347
|
+
if (o && typeof o === 'object') {
|
|
1348
|
+
tail = [o.path, o.id, o.name, o.bytes != null ? `${o.bytes}b` : '', o.ok === true ? 'ok' : '']
|
|
1349
|
+
.filter(Boolean).join(' ') || tail;
|
|
1350
|
+
}
|
|
1351
|
+
} catch { /* raw */ }
|
|
1352
|
+
}
|
|
1353
|
+
tail = tail.slice(0, 160);
|
|
1354
|
+
return `→ ${head}${tail ? `\n${tail}` : ''}`;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
export function combinedAbortSignal(a, b) {
|
|
1358
|
+
if (!a) return b;
|
|
1359
|
+
if (!b) return a;
|
|
1360
|
+
if (typeof AbortSignal.any === 'function') return AbortSignal.any([a, b]);
|
|
1361
|
+
const c = new AbortController();
|
|
1362
|
+
const on = () => { try { c.abort(); } catch { /* */ } };
|
|
1363
|
+
if (a.aborted || b.aborted) { on(); return c.signal; }
|
|
1364
|
+
a.addEventListener('abort', on, { once: true });
|
|
1365
|
+
b.addEventListener('abort', on, { once: true });
|
|
1366
|
+
return c.signal;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
export function isSupersededError(e) {
|
|
1370
|
+
const m = String(e?.message || e || '');
|
|
1371
|
+
return /superseded|interrupted/i.test(m);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function paintChatUpdate(agentId, note, extra = {}) {
|
|
1375
|
+
const line = fanoutLine(agentId, 'assistant', note, { ...extra, ephemeral: true });
|
|
1376
|
+
ssePush('transcript', { ...gatewayEntry(line), agentId });
|
|
1377
|
+
bumpAgent(agentId, { preview: note, notify: false });
|
|
1378
|
+
return line;
|
|
1379
|
+
}
|
|
1380
|
+
function fanoutLine(primaryId, role, text, extra = {}) {
|
|
1381
|
+
// Only the addressed agent. Writing to every tailedAgents id mixed canvases
|
|
1382
|
+
// so a new/empty chat showed someone else's thread.
|
|
1383
|
+
return appendLine(primaryId, role, text, extra);
|
|
1384
|
+
}
|
|
1385
|
+
function mintLocalAgent(parsed = {}) {
|
|
1386
|
+
const id = String(parsed.id || randomUUID());
|
|
1387
|
+
const prev = (cachedAgentList() || []).find((a) => a.id === id) || {};
|
|
1388
|
+
const agent = shapeAgent({
|
|
1389
|
+
...prev,
|
|
1390
|
+
...parsed,
|
|
1391
|
+
id,
|
|
1392
|
+
createdAt: prev.createdAt || parsed.createdAt || Date.now(),
|
|
1393
|
+
updatedAt: Date.now(),
|
|
1394
|
+
});
|
|
1395
|
+
const list = cachedAgentList() || [];
|
|
1396
|
+
if (!list.some((a) => a.id === id)) list.unshift(agent);
|
|
1397
|
+
else {
|
|
1398
|
+
const i = list.findIndex((a) => a.id === id);
|
|
1399
|
+
list[i] = { ...list[i], ...agent };
|
|
1400
|
+
}
|
|
1401
|
+
saveAgents(list.map(shapeAgent));
|
|
1402
|
+
agentTranscript(id);
|
|
1403
|
+
seedBriefOnCanvas(agent);
|
|
1404
|
+
return agent;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function entryKickText(e) {
|
|
1408
|
+
if (!e || typeof e !== 'object') return '';
|
|
1409
|
+
if (e.kind === 'send-message') return String(e.message?.content || '');
|
|
1410
|
+
return String(e.content || '');
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const briefKicked = new Set();
|
|
1414
|
+
function needsJobKick(agent) {
|
|
1415
|
+
if (!agent?.id || !String(agent.brief || '').trim()) return false;
|
|
1416
|
+
if (briefKicked.has(agent.id)) return false;
|
|
1417
|
+
const entries = agentTranscript(agent.id).entries || [];
|
|
1418
|
+
if (!entries.length) return true;
|
|
1419
|
+
if (entries.length > 4) return false;
|
|
1420
|
+
const blob = entries.map(entryKickText).join('\n');
|
|
1421
|
+
if (/\[brief\]/i.test(blob) && entries.length <= 3) {
|
|
1422
|
+
if (/Working from this brief/i.test(blob)) return true;
|
|
1423
|
+
if (entries.length === 1) return true;
|
|
1424
|
+
}
|
|
1425
|
+
return false;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function kickBriefedAgent(agent, log = () => {}) {
|
|
1429
|
+
if (!needsJobKick(agent)) return false;
|
|
1430
|
+
briefKicked.add(agent.id);
|
|
1431
|
+
const nonce = `oz-kick-${agent.id}`;
|
|
1432
|
+
log(`cursor-backend: brief-kick start ${agent.id} ${JSON.stringify(agent.name)}`);
|
|
1433
|
+
setImmediate(async () => {
|
|
1434
|
+
try {
|
|
1435
|
+
const z = await zooComplete(
|
|
1436
|
+
'Your brief is in this thread. Start that job now. Use tools. Do real work. Do not wait for another human message. Do not only say understood.',
|
|
1437
|
+
log,
|
|
1438
|
+
agent.id,
|
|
1439
|
+
{},
|
|
1440
|
+
);
|
|
1441
|
+
const line = fanoutLine(agent.id, 'assistant', z.text, { clientNonce: nonce, requestId: nonce });
|
|
1442
|
+
ssePush('transcript', { ...gatewayEntry(line), agentId: agent.id });
|
|
1443
|
+
bumpAgent(agent.id, { preview: z.text, notify: true });
|
|
1444
|
+
log(`cursor-backend: brief-kick done ${agent.id} seq=${line.seq}`);
|
|
1445
|
+
} catch (e) {
|
|
1446
|
+
briefKicked.delete(agent.id);
|
|
1447
|
+
log(`cursor-backend: brief-kick failed ${agent.id}: ${e.message}`);
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
return true;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function seedBriefOnCanvas(agent, log) {
|
|
1454
|
+
if (!agent?.id || !String(agent.brief || '').trim()) return false;
|
|
1455
|
+
const t = agentTranscript(agent.id);
|
|
1456
|
+
if (!(t.entries || []).length) {
|
|
1457
|
+
const nonce = `oz-brief-${agent.id}`;
|
|
1458
|
+
const userLine = fanoutLine(agent.id, 'user', `[brief]\n${agent.brief}`, { clientNonce: nonce, requestId: nonce });
|
|
1459
|
+
ssePush('transcript', { ...gatewayEntry(userLine), agentId: agent.id });
|
|
1460
|
+
}
|
|
1461
|
+
return kickBriefedAgent(agent, log || ((m) => console.error(m)));
|
|
1462
|
+
}
|
|
1463
|
+
/** Overlay prompts ITS user: no factory yet -> paint how to start one. Once per process. */
|
|
1464
|
+
export function shipNudgeText() {
|
|
1465
|
+
return [
|
|
1466
|
+
'[grok ship] No software factory on this Mac yet.',
|
|
1467
|
+
'Tell me: set up Grok Ship for ~/path/to/repo',
|
|
1468
|
+
'I will create Firstmate (the one bot you talk to) and a crewmate for that repo. Then give Firstmate ship tasks: it runs a worker on a branch, a fresh review of the diff, and opens the PR only when the review is clean. You merge.',
|
|
1469
|
+
].join('\n');
|
|
1470
|
+
}
|
|
1471
|
+
let payNudged = false;
|
|
1472
|
+
/** The terminal banner, on the canvas too: a new user reads the chat, not the shell. */
|
|
1473
|
+
async function seedPayNudge(list, activeId, log = () => {}) {
|
|
1474
|
+
if (payNudged || !activeId) return false;
|
|
1475
|
+
payNudged = true;
|
|
1476
|
+
try {
|
|
1477
|
+
const t = agentTranscript(activeId);
|
|
1478
|
+
if ((t.entries || []).some((e) => /\[how to pay\]/.test(String(e?.content || e?.message?.content || '')))) return false;
|
|
1479
|
+
const { payBannerChat, walletAddresses, quickBalances } = await import('./botlog.js');
|
|
1480
|
+
const { loadOrCreateWallet } = await import('./wallet.js');
|
|
1481
|
+
const addrs = walletAddresses(loadOrCreateWallet());
|
|
1482
|
+
const balances = await quickBalances(addrs);
|
|
1483
|
+
const text = payBannerChat({ ...addrs, balances, chromeMode: chromeStatus().mode });
|
|
1484
|
+
const nonce = `oz-pay-nudge-${activeId}`;
|
|
1485
|
+
const line = fanoutLine(activeId, 'assistant', text, { clientNonce: nonce, requestId: nonce });
|
|
1486
|
+
ssePush('transcript', { ...gatewayEntry(line), agentId: activeId });
|
|
1487
|
+
log(`cursor-backend: how-to-pay painted on ${activeId}`);
|
|
1488
|
+
return true;
|
|
1489
|
+
} catch (e) {
|
|
1490
|
+
log(`cursor-backend: how-to-pay nudge failed ${e.message}`);
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
let shipNudged = false;
|
|
1495
|
+
function seedShipNudge(list, activeId) {
|
|
1496
|
+
if (shipNudged || !activeId) return false;
|
|
1497
|
+
const roster = Array.isArray(list) ? list : [];
|
|
1498
|
+
if (roster.some((a) => /^firstmate$/i.test(String(a?.name || '')))) { shipNudged = true; return false; }
|
|
1499
|
+
const t = agentTranscript(activeId);
|
|
1500
|
+
if ((t.entries || []).some((e) => /\[grok ship\]/.test(String(e?.content || e?.message?.content || '')))) { shipNudged = true; return false; }
|
|
1501
|
+
shipNudged = true;
|
|
1502
|
+
const nonce = `oz-ship-nudge-${activeId}`;
|
|
1503
|
+
const line = fanoutLine(activeId, 'assistant', shipNudgeText(), { clientNonce: nonce, requestId: nonce });
|
|
1504
|
+
ssePush('transcript', { ...gatewayEntry(line), agentId: activeId });
|
|
1505
|
+
return true;
|
|
1506
|
+
}
|
|
1507
|
+
function groupMemberIds(agentId) {
|
|
1508
|
+
const a = (cachedAgentList() || []).find((x) => x.id === agentId);
|
|
1509
|
+
if (!a?.isGroup) return [];
|
|
1510
|
+
return (a.memberIds || []).map(String).filter(Boolean);
|
|
1511
|
+
}
|
|
1512
|
+
function groupMemberRecords(agentId) {
|
|
1513
|
+
const roster = cachedAgentList() || [];
|
|
1514
|
+
return groupMemberIds(agentId).map((mid) => {
|
|
1515
|
+
const m = roster.find((x) => x.id === mid);
|
|
1516
|
+
return { id: mid, name: String(m?.name || mid) };
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
export function groupReplyIsPass(text) {
|
|
1521
|
+
const s = stripSpendFooter(String(text || '')).replace(/^\s*[A-Za-z0-9 _.-]{1,40}:\s*/, '').trim();
|
|
1522
|
+
if (!s) return true;
|
|
1523
|
+
if (/^PASS\b/i.test(s)) return true;
|
|
1524
|
+
if (s.length < 20) return true;
|
|
1525
|
+
return /\b(nothing (more|else) to add|i('m| am) done|that('s| is) (all|my last word)|we (are|'re) (agreed|aligned|done)|i'?ll (stop|leave it)|no further|conversation is over|natural conclusion|i pass)\b/i.test(s);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const GROUP_MAX_ROUNDS = Math.min(12, Math.max(2, Number(process.env.OZ_GROUP_MAX_ROUNDS || 8)));
|
|
1529
|
+
const GROUP_MAX_CALLS = Math.min(24, Math.max(4, Number(process.env.OZ_GROUP_MAX_CALLS || 16)));
|
|
1530
|
+
|
|
1531
|
+
/** Members speak, then keep peek/ponging until they PASS or hit the cap. */
|
|
1532
|
+
async function runGroupQueue({ agentId, humanPrompt, parsed, nonce, log }) {
|
|
1533
|
+
const named = groupMemberRecords(agentId);
|
|
1534
|
+
if (!named.length) return false;
|
|
1535
|
+
const names = named.map((m) => m.name);
|
|
1536
|
+
let calls = 0;
|
|
1537
|
+
|
|
1538
|
+
const oneTurn = async (m, phase) => {
|
|
1539
|
+
const others = names.filter((n) => n !== m.name).join(', ') || 'the group';
|
|
1540
|
+
const persona = phase === 'peek'
|
|
1541
|
+
? `You are ${m.name} in a group with ${others}. Continue ping/pong with the other members using the prior turns. Do not speak as the human. Do not prefix with visitor shortnames. If the exchange has reached a natural conclusion or you have nothing to add, reply with exactly PASS and nothing else. Otherwise one short in-character turn.`
|
|
1542
|
+
: `You are ${m.name} in a group with ${others}. Answer the human as yourself and leave a hook the others can ping. Do not speak as the human. Do not prefix with visitor shortnames. A few sentences.\n\nThe human said: ${humanPrompt}`;
|
|
1543
|
+
let bit = '';
|
|
1544
|
+
try {
|
|
1545
|
+
bit = (await zooComplete(persona, log, agentId, parsed)).text;
|
|
1546
|
+
} catch (e) {
|
|
1547
|
+
bit = `openzoo error (${m.name}): ${e.message}`;
|
|
1548
|
+
log(`cursor-backend: group ${phase} ${m.id} failed: ${e.message}`);
|
|
1549
|
+
}
|
|
1550
|
+
calls += 1;
|
|
1551
|
+
const pass = groupReplyIsPass(bit);
|
|
1552
|
+
if (!pass) {
|
|
1553
|
+
const line = fanoutLine(agentId, 'assistant', bit, {
|
|
1554
|
+
clientNonce: nonce,
|
|
1555
|
+
requestId: nonce,
|
|
1556
|
+
author: { kind: 'agent', id: m.id, name: m.name },
|
|
1557
|
+
});
|
|
1558
|
+
ssePush('transcript', { ...gatewayEntry(line), agentId });
|
|
1559
|
+
bumpAgent(agentId, { preview: bit, notify: true });
|
|
1560
|
+
log(`cursor-backend: group ${phase} ${m.name} seq=${line.seq}`);
|
|
1561
|
+
} else {
|
|
1562
|
+
log(`cursor-backend: group ${phase} ${m.name} PASS`);
|
|
1563
|
+
}
|
|
1564
|
+
return pass;
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
for (const m of named) {
|
|
1568
|
+
if (calls >= GROUP_MAX_CALLS) break;
|
|
1569
|
+
await oneTurn(m, 'speak');
|
|
1570
|
+
}
|
|
1571
|
+
if (named.length < 2) return true;
|
|
1572
|
+
|
|
1573
|
+
let round = 0;
|
|
1574
|
+
while (round < GROUP_MAX_ROUNDS && calls < GROUP_MAX_CALLS) {
|
|
1575
|
+
round += 1;
|
|
1576
|
+
let passes = 0;
|
|
1577
|
+
for (const m of named) {
|
|
1578
|
+
if (calls >= GROUP_MAX_CALLS) break;
|
|
1579
|
+
if (await oneTurn(m, 'peek')) passes += 1;
|
|
1580
|
+
}
|
|
1581
|
+
if (passes >= named.length) {
|
|
1582
|
+
log(`cursor-backend: group concluded round=${round} calls=${calls}`);
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
log(`cursor-backend: group queue done rounds=${round} calls=${calls}`);
|
|
1587
|
+
return true;
|
|
1588
|
+
}
|
|
1589
|
+
function mergeAgentLists(remote) {
|
|
1590
|
+
const local = cachedAgentList() || [];
|
|
1591
|
+
const seen = new Map();
|
|
1592
|
+
const out = [];
|
|
1593
|
+
for (const a of [...local, ...(Array.isArray(remote) ? remote : [])]) {
|
|
1594
|
+
if (!a?.id) continue;
|
|
1595
|
+
const idx = seen.get(a.id);
|
|
1596
|
+
if (idx == null) {
|
|
1597
|
+
seen.set(a.id, out.length);
|
|
1598
|
+
out.push(stampActivity(a));
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
out[idx] = stampActivity(preferNamedAgent(out[idx], a));
|
|
1602
|
+
}
|
|
1603
|
+
return sortAgentsByActivity(filterDeleted(out, HOME));
|
|
1604
|
+
}
|
|
1605
|
+
function gatewayEntry(e) {
|
|
1606
|
+
const { seq, pulledRemote, promptRaw, ephemeral, ...rest } = e;
|
|
1607
|
+
return rest;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function stripSpendFooter(s) {
|
|
1611
|
+
return String(s || '')
|
|
1612
|
+
.replace(/\n{2,}::oz-spend::[\s\S]*$/i, '')
|
|
1613
|
+
.replace(/\n{2,}this call \$[\d.]+[\s\S]*$/i, '')
|
|
1614
|
+
.trimEnd();
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function entryPlainText(v) {
|
|
1618
|
+
if (v == null) return '';
|
|
1619
|
+
if (typeof v === 'string' || typeof v === 'number') return String(v);
|
|
1620
|
+
if (Array.isArray(v)) return v.map(entryPlainText).filter(Boolean).join('\n');
|
|
1621
|
+
if (typeof v === 'object') {
|
|
1622
|
+
return entryPlainText(v.content ?? v.text ?? v.message ?? v.prompt ?? v.value ?? '');
|
|
1623
|
+
}
|
|
1624
|
+
return '';
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/** Prior turns for zooComplete. sendPrompt used to POST only the latest user
|
|
1628
|
+
* line, so the model said each thread starts blank while the UI still showed
|
|
1629
|
+
* the canvas. Skip the last user echo of `currentPrompt` (already appended). */
|
|
1630
|
+
/**
|
|
1631
|
+
* Strict user/assistant alternation. The abliteration lane 400s
|
|
1632
|
+
* "Invalid model provider request" on `auauauuuau` (wakeup + "continue" +
|
|
1633
|
+
* nudge = three user turns; error text + reply = two assistant turns).
|
|
1634
|
+
* Lossless fold: texts joined with a blank line; tool turns never crossed.
|
|
1635
|
+
*/
|
|
1636
|
+
export function foldSameRole(messages) {
|
|
1637
|
+
// Assistant turns stored from the raw upstream object carry refusal:null,
|
|
1638
|
+
// reasoning, annotations… Bisected 2026-09-02: that one message drew
|
|
1639
|
+
// "Invalid model provider request" from the abliteration lane with every
|
|
1640
|
+
// other message shrunk to 300 chars. Keep the four keys every provider takes.
|
|
1641
|
+
const ASSISTANT_KEYS = new Set(['role', 'content', 'tool_calls', 'name']);
|
|
1642
|
+
messages = (Array.isArray(messages) ? messages : []).map((m) => {
|
|
1643
|
+
if (!m || m.role !== 'assistant') return m;
|
|
1644
|
+
const out = {};
|
|
1645
|
+
for (const [k, v] of Object.entries(m)) {
|
|
1646
|
+
if (!ASSISTANT_KEYS.has(k)) continue;
|
|
1647
|
+
if (k === 'name' && (typeof v !== 'string' || !v)) continue; // name:null is an echo, not a name
|
|
1648
|
+
if (k === 'tool_calls' && !(Array.isArray(v) && v.length)) continue;
|
|
1649
|
+
out[k] = v;
|
|
1650
|
+
}
|
|
1651
|
+
if (out.content === undefined && !out.tool_calls) out.content = '';
|
|
1652
|
+
if (out.content === null && out.tool_calls) delete out.content;
|
|
1653
|
+
return out;
|
|
1654
|
+
});
|
|
1655
|
+
const out = [];
|
|
1656
|
+
// An assistant entry with no content and no tool_calls (a failed/empty turn
|
|
1657
|
+
// recorded on the canvas) is invalid for every OpenAI-compatible provider:
|
|
1658
|
+
// measured 2026-09-02 as `400 Invalid model provider request` param=messages.
|
|
1659
|
+
messages = (messages || []).filter((m) => !(m && m.role === 'assistant' && (m.content == null || m.content === '') && !m.tool_calls));
|
|
1660
|
+
const foldable = (m) => m && (m.role === 'user' || m.role === 'assistant') && !m.tool_calls && !m.tool_call_id;
|
|
1661
|
+
for (const m of messages) {
|
|
1662
|
+
const last = out[out.length - 1];
|
|
1663
|
+
if (last && foldable(last) && foldable(m) && last.role === m.role) {
|
|
1664
|
+
const a = last.content; const b = m.content;
|
|
1665
|
+
let content;
|
|
1666
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
1667
|
+
const arr = (v) => (Array.isArray(v) ? v : (v == null || v === '' ? [] : [{ type: 'text', text: String(v) }]));
|
|
1668
|
+
content = [...arr(a), ...arr(b)];
|
|
1669
|
+
} else {
|
|
1670
|
+
const sa = a == null ? '' : String(a); const sb = b == null ? '' : String(b);
|
|
1671
|
+
content = sa && sb ? `${sa}\n\n${sb}` : (sa || sb);
|
|
1672
|
+
}
|
|
1673
|
+
out[out.length - 1] = { ...last, content };
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
1676
|
+
out.push(m);
|
|
1677
|
+
}
|
|
1678
|
+
return out;
|
|
1679
|
+
}
|
|
1680
|
+
function historyMessages(...args) {
|
|
1681
|
+
return foldSameRole(historyMessagesRaw(...args));
|
|
1682
|
+
}
|
|
1683
|
+
function historyMessagesRaw(agentId, currentPrompt) {
|
|
1684
|
+
const entries = agentTranscript(agentId).entries || [];
|
|
1685
|
+
const cur = String(currentPrompt || '').trim();
|
|
1686
|
+
const out = [];
|
|
1687
|
+
for (const e of entries) {
|
|
1688
|
+
if (!e || typeof e !== 'object') continue;
|
|
1689
|
+
let role = null;
|
|
1690
|
+
let text = '';
|
|
1691
|
+
if (e.ephemeral) continue;
|
|
1692
|
+
if (e.kind === 'send-message' || e.role === 'assistant' || e.kind === 'assistant') {
|
|
1693
|
+
text = entryPlainText(e.message ?? e.content ?? e.text);
|
|
1694
|
+
role = 'assistant';
|
|
1695
|
+
text = stripSpendFooter(text);
|
|
1696
|
+
const who = e.author && e.author.name ? String(e.author.name).trim() : '';
|
|
1697
|
+
if (who && text && !text.toLowerCase().startsWith(`${who.toLowerCase()}:`)) {
|
|
1698
|
+
text = `${who}: ${text}`;
|
|
1699
|
+
}
|
|
1700
|
+
} else if (e.kind === 'message' || e.role === 'user' || e.kind === 'user') {
|
|
1701
|
+
// Keep `shortname: ` on the turn the model sees -- stripping it made every
|
|
1702
|
+
// visitor look like one anonymous "you" (rex asked "am I still rex").
|
|
1703
|
+
text = entryPlainText(e.content ?? e.message ?? e.text ?? e.prompt);
|
|
1704
|
+
if (!String(text || '').trim() && e.promptRaw != null) text = String(e.promptRaw);
|
|
1705
|
+
role = 'user';
|
|
1706
|
+
}
|
|
1707
|
+
text = String(text || '').trim();
|
|
1708
|
+
if (!role || !text) continue;
|
|
1709
|
+
out.push({ role, content: text });
|
|
1710
|
+
}
|
|
1711
|
+
const curBare = stripVisitorLabel(cur).trim();
|
|
1712
|
+
while (out.length && out[out.length - 1].role === 'user') {
|
|
1713
|
+
const last = String(out[out.length - 1].content).trim();
|
|
1714
|
+
if (last === cur || stripVisitorLabel(last).trim() === curBare) out.pop();
|
|
1715
|
+
else break;
|
|
1716
|
+
}
|
|
1717
|
+
let chars = 0;
|
|
1718
|
+
const kept = [];
|
|
1719
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
1720
|
+
chars += String(out[i].content).length;
|
|
1721
|
+
if (chars > 120_000 && kept.length) break;
|
|
1722
|
+
kept.push(out[i]);
|
|
1723
|
+
}
|
|
1724
|
+
kept.reverse();
|
|
1725
|
+
return kept;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
async function ensureTranscriptHydrated(agentId, log) {
|
|
1729
|
+
const t = agentTranscript(agentId);
|
|
1730
|
+
if (t.pulledRemote || !realPod?.agent) return;
|
|
1731
|
+
try {
|
|
1732
|
+
const remote = await podJson('/api/getAgentTranscriptTail', {
|
|
1733
|
+
id: agentId, agentId, limit: 200,
|
|
1734
|
+
}, log);
|
|
1735
|
+
const entries = remote?.entries || remote?.value?.entries || remote?.lines || [];
|
|
1736
|
+
const n = ingestRemoteEntries(agentId, entries);
|
|
1737
|
+
if (n) log(`cursor-backend: zooComplete hydrate ${agentId} +${n} from 1340`);
|
|
1738
|
+
} catch (e) {
|
|
1739
|
+
log(`cursor-backend: zooComplete hydrate failed: ${e.message}`);
|
|
1740
|
+
}
|
|
1741
|
+
t.pulledRemote = true;
|
|
1742
|
+
}
|
|
1743
|
+
async function podJson(path0, bodyObj, log) {
|
|
1744
|
+
if (!realPod?.agent) return null;
|
|
1745
|
+
let agent;
|
|
1746
|
+
try { agent = new URL(realPod.agent); } catch { return null; }
|
|
1747
|
+
const headers = {
|
|
1748
|
+
'content-type': 'application/json',
|
|
1749
|
+
accept: 'application/json',
|
|
1750
|
+
authorization: `Bearer ${realPod.accessToken || realPod.token || ''}`,
|
|
1751
|
+
'x-anyrun-network-token': realPod.token || '',
|
|
1752
|
+
'x-sand-slim-avatars': '1',
|
|
1753
|
+
};
|
|
1754
|
+
try {
|
|
1755
|
+
const cap = await upstreamUnary({
|
|
1756
|
+
host: agent.hostname,
|
|
1757
|
+
path: path0,
|
|
1758
|
+
method: 'POST',
|
|
1759
|
+
headers,
|
|
1760
|
+
body: Buffer.from(JSON.stringify(bodyObj || {})),
|
|
1761
|
+
timeoutMs: 20000,
|
|
1762
|
+
});
|
|
1763
|
+
if (cap.status !== 200) {
|
|
1764
|
+
log(`cursor-backend: podJson ${path0} ${cap.status}`);
|
|
1765
|
+
return null;
|
|
1766
|
+
}
|
|
1767
|
+
const raw = inflateBody(cap.buf, cap.respHeaders);
|
|
1768
|
+
return JSON.parse(String(raw));
|
|
1769
|
+
} catch (e) {
|
|
1770
|
+
log(`cursor-backend: podJson ${path0} ${e.message}`);
|
|
1771
|
+
return null;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
function ingestRemoteEntries(agentId, entries) {
|
|
1775
|
+
if (!Array.isArray(entries) || !entries.length) return 0;
|
|
1776
|
+
const t = agentTranscript(agentId);
|
|
1777
|
+
const have = new Set(t.entries.map((e) => e.id || e.clientNonce || '').filter(Boolean));
|
|
1778
|
+
const incoming = [];
|
|
1779
|
+
for (const e of entries) {
|
|
1780
|
+
if (!e || typeof e !== 'object') continue;
|
|
1781
|
+
const k = e.id || e.clientNonce || '';
|
|
1782
|
+
if (k && have.has(k)) continue;
|
|
1783
|
+
incoming.push(e);
|
|
1784
|
+
}
|
|
1785
|
+
if (!incoming.length) return 0;
|
|
1786
|
+
const local = t.entries;
|
|
1787
|
+
t.entries = [];
|
|
1788
|
+
t.seq = 0;
|
|
1789
|
+
for (const e of [...incoming, ...local]) {
|
|
1790
|
+
t.seq += 1;
|
|
1791
|
+
const { seq: _s, ...rest } = e;
|
|
1792
|
+
t.entries.push({ ...rest, seq: t.seq });
|
|
1793
|
+
}
|
|
1794
|
+
saveTranscripts();
|
|
1795
|
+
return incoming.length;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function visitorFromSend(parsed) {
|
|
1799
|
+
const v = parsed && parsed.visitor;
|
|
1800
|
+
if (!v || typeof v !== 'object') return null;
|
|
1801
|
+
const id = String(v.id || '').trim();
|
|
1802
|
+
const shortname = String(v.shortname || v.name || '').trim().toLowerCase();
|
|
1803
|
+
const color = String(v.color || '').trim();
|
|
1804
|
+
if (!id || !/^[a-z][a-z0-9]{1,15}$/.test(shortname)) return null;
|
|
1805
|
+
return { id, shortname, color };
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/** UI stores `shortname: prompt`; zooComplete / history must see the raw line. */
|
|
1809
|
+
function stripVisitorLabel(text) {
|
|
1810
|
+
const s = String(text || '');
|
|
1811
|
+
const m = s.match(/^([a-z][a-z0-9]{1,15}):\s+/);
|
|
1812
|
+
if (!m) return s;
|
|
1813
|
+
if (/^(https?|ftp|mailto|file|data)$/i.test(m[1])) return s;
|
|
1814
|
+
return s.slice(m[0].length);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function labeledVisitorPrompt(visitor, prompt) {
|
|
1818
|
+
const p = String(prompt || '');
|
|
1819
|
+
if (!visitor?.shortname) return p;
|
|
1820
|
+
const prefix = `${visitor.shortname}: `;
|
|
1821
|
+
if (p.startsWith(prefix)) return p;
|
|
1822
|
+
return prefix + p;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function promptFromSendBody(raw) {
|
|
1826
|
+
let obj = raw;
|
|
1827
|
+
if (Buffer.isBuffer(raw) || typeof raw === 'string') {
|
|
1828
|
+
try { obj = JSON.parse(String(raw)); } catch { return String(raw || ''); }
|
|
1829
|
+
}
|
|
1830
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
1831
|
+
const pick = (v) => (typeof v === 'string' && v.trim() ? v : '');
|
|
1832
|
+
let fromMsgs = '';
|
|
1833
|
+
if (Array.isArray(obj.messages)) {
|
|
1834
|
+
for (let i = obj.messages.length - 1; i >= 0; i--) {
|
|
1835
|
+
const m = obj.messages[i];
|
|
1836
|
+
fromMsgs = pick(m?.content) || pick(m?.text) || pick(m?.prompt);
|
|
1837
|
+
if (fromMsgs) break;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
return pick(obj.prompt) || pick(obj.text) || pick(obj.message)
|
|
1841
|
+
|| pick(obj.content) || pick(obj.input)
|
|
1842
|
+
|| pick(obj.message?.content) || pick(obj.message?.text)
|
|
1843
|
+
|| fromMsgs
|
|
1844
|
+
|| '';
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
let walletUsdCache = { usd: null, at: 0 };
|
|
1848
|
+
async function walletUsdCached() {
|
|
1849
|
+
if (walletUsdCache.usd != null && Date.now() - walletUsdCache.at < 8_000) return walletUsdCache.usd;
|
|
1850
|
+
try {
|
|
1851
|
+
const { affordableUsd } = await import('./info.js');
|
|
1852
|
+
const n = await Promise.race([
|
|
1853
|
+
affordableUsd(),
|
|
1854
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('balance timeout')), 4000)),
|
|
1855
|
+
]);
|
|
1856
|
+
if (Number.isFinite(n)) {
|
|
1857
|
+
walletUsdCache = { usd: Number(n), at: Date.now() };
|
|
1858
|
+
return walletUsdCache.usd;
|
|
1859
|
+
}
|
|
1860
|
+
} catch { /* keep last */ }
|
|
1861
|
+
return walletUsdCache.usd;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
async function zooSpendOverlay(data) {
|
|
1865
|
+
const x = data?.x402 || {};
|
|
1866
|
+
let info = {};
|
|
1867
|
+
try {
|
|
1868
|
+
const r = await fetch('http://127.0.0.1:8402/v1/info', { signal: AbortSignal.timeout(1500) });
|
|
1869
|
+
if (r.ok) info = await r.json();
|
|
1870
|
+
} catch { /* */ }
|
|
1871
|
+
let session = {};
|
|
1872
|
+
try {
|
|
1873
|
+
session = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.openzoo', 'session.json'), 'utf8'));
|
|
1874
|
+
} catch { /* */ }
|
|
1875
|
+
const spent = Number(info.spendUsd ?? session.spentUsd ?? x.billedUsd ?? 0);
|
|
1876
|
+
const would = Number(info.directUsd ?? session.directUsd ?? x.directUsd ?? 0);
|
|
1877
|
+
const saved = Number(info.savedUsd ?? Math.max(0, would - spent));
|
|
1878
|
+
const pct = would > 0 ? (100 * saved / would) : 0;
|
|
1879
|
+
const credit = Number(info.creditUsd);
|
|
1880
|
+
const wallet = await walletUsdCached();
|
|
1881
|
+
const bal = Number.isFinite(wallet) && wallet > 0.004
|
|
1882
|
+
? wallet
|
|
1883
|
+
: (Number.isFinite(credit) && credit > 0.004 ? credit : null);
|
|
1884
|
+
const label = spent > 0.00005 ? spendChipLabel({
|
|
1885
|
+
billedUsd: x.billedUsd,
|
|
1886
|
+
directUsd: x.directUsd,
|
|
1887
|
+
spent,
|
|
1888
|
+
would,
|
|
1889
|
+
saved,
|
|
1890
|
+
pct,
|
|
1891
|
+
}) : '';
|
|
1892
|
+
const footer = formatSpendFooter({
|
|
1893
|
+
billedUsd: x.billedUsd,
|
|
1894
|
+
directUsd: x.directUsd,
|
|
1895
|
+
spent,
|
|
1896
|
+
would,
|
|
1897
|
+
saved,
|
|
1898
|
+
pct,
|
|
1899
|
+
balance: bal,
|
|
1900
|
+
x402: x,
|
|
1901
|
+
});
|
|
1902
|
+
const body = spendLinesOnly(footer) || (spent > 0.00005
|
|
1903
|
+
? `spent $${spent.toFixed(4)} · OpenRouter would $${would.toFixed(4)} · saved $${saved.toFixed(4)} (${pct.toFixed(0)}%)`
|
|
1904
|
+
: '');
|
|
1905
|
+
const state = { spent, would, saved, pct, label, billedUsd: x.billedUsd, directUsd: x.directUsd, balance: bal, body };
|
|
1906
|
+
try { writeSpendHud(state); } catch { /* */ }
|
|
1907
|
+
return state;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const MODELS_PATH = path.join(os.homedir(), '.openzoo', 'grokbot-models.json');
|
|
1911
|
+
function loadAgentModels() {
|
|
1912
|
+
try {
|
|
1913
|
+
return new Map(Object.entries(JSON.parse(fs.readFileSync(MODELS_PATH, 'utf8'))));
|
|
1914
|
+
} catch { return new Map(); }
|
|
1915
|
+
}
|
|
1916
|
+
function saveAgentModels() {
|
|
1917
|
+
try {
|
|
1918
|
+
fs.mkdirSync(path.dirname(MODELS_PATH), { recursive: true });
|
|
1919
|
+
fs.writeFileSync(MODELS_PATH, JSON.stringify(Object.fromEntries(agentModels)));
|
|
1920
|
+
} catch { /* */ }
|
|
1921
|
+
}
|
|
1922
|
+
const agentModels = loadAgentModels();
|
|
1923
|
+
const MODEL_ALIASES = {
|
|
1924
|
+
fable: 'anthropic/claude-fable-5',
|
|
1925
|
+
'fable-5': 'anthropic/claude-fable-5',
|
|
1926
|
+
'claude-fable-5': 'anthropic/claude-fable-5',
|
|
1927
|
+
opus: 'anthropic/claude-opus-5',
|
|
1928
|
+
'opus-5': 'anthropic/claude-opus-5',
|
|
1929
|
+
sonnet: 'anthropic/claude-sonnet-5',
|
|
1930
|
+
// BARE grok-4.6 is the bazaar (x402 door) row; x-ai/grok-4.6 is OpenRouter's
|
|
1931
|
+
// and drains OpenRouter credits. Every spelling lands on the bare id.
|
|
1932
|
+
grok: 'grok-4.6',
|
|
1933
|
+
'grok-4': 'grok-4.6',
|
|
1934
|
+
'grok-4.6': 'grok-4.6',
|
|
1935
|
+
'x-ai/grok-4.6': 'grok-4.6',
|
|
1936
|
+
glm: 'zai-org/glm-5.3-flash',
|
|
1937
|
+
'glm-5': 'zai-org/glm-5.3-flash',
|
|
1938
|
+
'glm-5.3': 'zai-org/glm-5.3-flash',
|
|
1939
|
+
'glm-5.3-flash': 'zai-org/glm-5.3-flash',
|
|
1940
|
+
flash: 'zai-org/glm-5.3-flash',
|
|
1941
|
+
// NEVER OPENROUTER for Grok Bot: `auto` is the gateway router over
|
|
1942
|
+
// OpenRouter's catalog, so it lands on the door-only id instead.
|
|
1943
|
+
// openzoo/auto is the gateway router; the gateway keeps Auto on door-served
|
|
1944
|
+
// models only, so it never lands on OpenRouter.
|
|
1945
|
+
auto: 'openzoo/auto',
|
|
1946
|
+
'openrouter/auto': 'openzoo/auto',
|
|
1947
|
+
'openzoo/auto': 'openzoo/auto',
|
|
1948
|
+
'fable-5.1': 'anthropic/claude-fable-5.1',
|
|
1949
|
+
'claude-fable-5.1': 'anthropic/claude-fable-5.1',
|
|
1950
|
+
'anthropic/fable-5.1': 'anthropic/claude-fable-5.1',
|
|
1951
|
+
'anthropic/fable-5': 'anthropic/claude-fable-5',
|
|
1952
|
+
deepseek: 'deepseek/deepseek-v4-pro',
|
|
1953
|
+
'deepseek-pro': 'deepseek/deepseek-v4-pro',
|
|
1954
|
+
'deepseek-flash': 'deepseek/deepseek-v4-flash',
|
|
1955
|
+
'deepseek-v4': 'deepseek/deepseek-v4-pro',
|
|
1956
|
+
abliterated: 'abliterated-model-large-v2',
|
|
1957
|
+
ablit: 'abliterated-model-large-v2',
|
|
1958
|
+
'abliterated-large': 'abliterated-model-large-v2',
|
|
1959
|
+
'abliterated-small': 'abliterated-model',
|
|
1960
|
+
};
|
|
1961
|
+
/** Default zoo model for every bot without a /model override. BARE `grok-4.6`
|
|
1962
|
+
* — the bazaar (x402 upstream) row, not OpenRouter's `x-ai/grok-4.6`. The
|
|
1963
|
+
* gateway serves the bare id off an x402 door with an on-chain cogs receipt,
|
|
1964
|
+
* so an OpenRouter credit outage cannot take it down. */
|
|
1965
|
+
export const DEFAULT_ZOO_MODEL = 'openzoo/auto';
|
|
1966
|
+
async function resolveModelId(raw) {
|
|
1967
|
+
const s = String(raw || '').trim();
|
|
1968
|
+
if (!s) return null;
|
|
1969
|
+
const lower = s.toLowerCase().replace(/^\/+/, '');
|
|
1970
|
+
if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
|
|
1971
|
+
if (s.includes('/')) return s;
|
|
1972
|
+
// No vendor prefix and no alias: the LIVE catalog decides, not this file.
|
|
1973
|
+
// The zoo serves ids with no slash at all (abliterated-model*), and a
|
|
1974
|
+
// hardcoded "must contain /" rule rejected every one of them.
|
|
1975
|
+
try {
|
|
1976
|
+
const ids = await zooModelIds();
|
|
1977
|
+
return ids.find((id) => id === s) || ids.find((id) => id.toLowerCase() === lower) || null;
|
|
1978
|
+
} catch { return null; }
|
|
1979
|
+
}
|
|
1980
|
+
export function canonicalZooModel(id) {
|
|
1981
|
+
const s = String(id || '').trim();
|
|
1982
|
+
return MODEL_ALIASES[s.toLowerCase()] || s;
|
|
1983
|
+
}
|
|
1984
|
+
function currentModel(agentId) {
|
|
1985
|
+
return canonicalZooModel(agentModels.get(agentId)
|
|
1986
|
+
|| process.env.OPENZOO_DEFAULT_MODEL
|
|
1987
|
+
|| DEFAULT_ZOO_MODEL);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
const execFileAsync = promisify(execFile);
|
|
1991
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|heic|svg)$/i;
|
|
1992
|
+
const IMAGE_MAGIC = [
|
|
1993
|
+
[Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'image/png'],
|
|
1994
|
+
[Buffer.from([0xff, 0xd8, 0xff]), 'image/jpeg'],
|
|
1995
|
+
[Buffer.from('GIF8'), 'image/gif'],
|
|
1996
|
+
[Buffer.from('RIFF'), 'image/webp'],
|
|
1997
|
+
];
|
|
1998
|
+
function mimeFromBytes(buf, p = '') {
|
|
1999
|
+
if (IMAGE_EXT.test(p)) {
|
|
2000
|
+
const ext = path.extname(p).toLowerCase();
|
|
2001
|
+
return { 'png': 'image/png', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', '.heic': 'image/heic', '.svg': 'image/svg+xml' }[ext] || 'image/png';
|
|
2002
|
+
}
|
|
2003
|
+
for (const [magic, mime] of IMAGE_MAGIC) {
|
|
2004
|
+
if (buf.length >= magic.length && buf.subarray(0, magic.length).equals(magic)) return mime;
|
|
2005
|
+
}
|
|
2006
|
+
return null;
|
|
2007
|
+
}
|
|
2008
|
+
function dataUrlsFromRichText(rt) {
|
|
2009
|
+
const s = String(rt || '');
|
|
2010
|
+
const out = [];
|
|
2011
|
+
const re = /data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/]+=*/g;
|
|
2012
|
+
let m;
|
|
2013
|
+
while ((m = re.exec(s))) out.push(m[0]);
|
|
2014
|
+
return out;
|
|
2015
|
+
}
|
|
2016
|
+
function attachmentList(parsed, prompt) {
|
|
2017
|
+
const out = [];
|
|
2018
|
+
const seen = new Set();
|
|
2019
|
+
const add = (raw, name) => {
|
|
2020
|
+
if (!raw || seen.has(raw)) return;
|
|
2021
|
+
seen.add(raw);
|
|
2022
|
+
out.push({ raw, name });
|
|
2023
|
+
};
|
|
2024
|
+
const paths = parsed?.attachmentPaths;
|
|
2025
|
+
const names = parsed?.attachmentNames;
|
|
2026
|
+
if (Array.isArray(paths)) {
|
|
2027
|
+
for (let i = 0; i < paths.length; i++) add(String(paths[i]), names?.[i]);
|
|
2028
|
+
}
|
|
2029
|
+
for (const p of extractLocalPaths(prompt)) {
|
|
2030
|
+
if (/[*?]/.test(p)) continue;
|
|
2031
|
+
add(p);
|
|
2032
|
+
}
|
|
2033
|
+
const rt = String(parsed?.richText || '');
|
|
2034
|
+
const srcRe = /(?:src|path|filePath|url)"?\s*[:=]\s*"((?:file:\/\/|\/(?:var|tmp|private|Users)|~\/)[^"]+\.(?:png|jpe?g|gif|webp|bmp|heic))"/gi;
|
|
2035
|
+
let sm;
|
|
2036
|
+
while ((sm = srcRe.exec(rt))) {
|
|
2037
|
+
add(sm[1].replace(/^file:\/\//, ''));
|
|
2038
|
+
}
|
|
2039
|
+
return out;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
async function readLocalBytes(abs, log) {
|
|
2043
|
+
if (localExecSse.size > 0) {
|
|
2044
|
+
log(`cursor-backend: local-exec download ${abs}`);
|
|
2045
|
+
const got = await localExecAsk({ kind: 'download', path: abs });
|
|
2046
|
+
return got.bytes || Buffer.from(got.text || '', 'utf8');
|
|
2047
|
+
}
|
|
2048
|
+
return fs.readFileSync(abs);
|
|
2049
|
+
}
|
|
2050
|
+
async function writeLocalBytes(abs, bytes, log) {
|
|
2051
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes ?? ''), 'utf8');
|
|
2052
|
+
if (localExecSse.size > 0) {
|
|
2053
|
+
log(`cursor-backend: local-exec upload ${abs} ${buf.length}b`);
|
|
2054
|
+
await localExecAsk({
|
|
2055
|
+
kind: 'upload',
|
|
2056
|
+
path: abs,
|
|
2057
|
+
bytesBase64: buf.toString('base64'),
|
|
2058
|
+
});
|
|
2059
|
+
return `wrote ${abs} (${buf.length} bytes) via local-exec`;
|
|
2060
|
+
}
|
|
2061
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
2062
|
+
fs.writeFileSync(abs, buf);
|
|
2063
|
+
return `wrote ${abs} (${buf.length} bytes)`;
|
|
2064
|
+
}
|
|
2065
|
+
async function execLocal(command, cwd, log) {
|
|
2066
|
+
let dir = cwd ? expandUserPath(cwd) : os.homedir();
|
|
2067
|
+
try {
|
|
2068
|
+
if (!fs.statSync(dir).isDirectory()) dir = os.homedir();
|
|
2069
|
+
} catch {
|
|
2070
|
+
// Node reports spawn /bin/zsh ENOENT when cwd is missing — not a missing shell.
|
|
2071
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { dir = os.homedir(); }
|
|
2072
|
+
}
|
|
2073
|
+
if (localExecSse.size > 0) {
|
|
2074
|
+
log(`cursor-backend: local-exec exec ${JSON.stringify(command).slice(0, 80)}`);
|
|
2075
|
+
const got = await localExecAsk({
|
|
2076
|
+
kind: 'exec',
|
|
2077
|
+
serverMessage: { command, cwd: dir, workingDirectory: dir },
|
|
2078
|
+
}, 60000);
|
|
2079
|
+
const out = got.stdout || got.message || '';
|
|
2080
|
+
const err = got.stderr ? `\nstderr:\n${got.stderr}` : '';
|
|
2081
|
+
return `${out}${err}`.trim() || `(exit ${got.exitCode ?? 0})`;
|
|
2082
|
+
}
|
|
2083
|
+
log(`cursor-backend: exec cwd=${dir} ${JSON.stringify(String(command).slice(0, 80))}`);
|
|
2084
|
+
const execMs = Math.min(180000, Math.max(15000, Number(process.env.OPENZOO_EXEC_TIMEOUT_MS || 90000)));
|
|
2085
|
+
const { stdout, stderr } = await execFileAsync('/bin/zsh', ['-lc', command], {
|
|
2086
|
+
cwd: dir,
|
|
2087
|
+
timeout: execMs,
|
|
2088
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
2089
|
+
env: process.env,
|
|
2090
|
+
});
|
|
2091
|
+
return `${stdout || ''}${stderr ? `\nstderr:\n${stderr}` : ''}`.trim() || '(no output)';
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
const LOCAL_TOOLS = [
|
|
2095
|
+
{
|
|
2096
|
+
type: 'function',
|
|
2097
|
+
function: {
|
|
2098
|
+
name: 'read_file',
|
|
2099
|
+
description: 'Read a file on the user\'s Mac. Use this for any local path.',
|
|
2100
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
2101
|
+
},
|
|
2102
|
+
},
|
|
2103
|
+
{
|
|
2104
|
+
type: 'function',
|
|
2105
|
+
function: {
|
|
2106
|
+
name: 'write_file',
|
|
2107
|
+
description: 'Write a file on the user\'s Mac. Put HTML/games/code on disk — do not dump huge files in chat.',
|
|
2108
|
+
parameters: {
|
|
2109
|
+
type: 'object',
|
|
2110
|
+
properties: { path: { type: 'string' }, content: { type: 'string' } },
|
|
2111
|
+
required: ['path', 'content'],
|
|
2112
|
+
},
|
|
2113
|
+
},
|
|
2114
|
+
},
|
|
2115
|
+
{
|
|
2116
|
+
type: 'function',
|
|
2117
|
+
function: {
|
|
2118
|
+
name: 'exec',
|
|
2119
|
+
description: 'Run a shell command on the user\'s Mac (zsh -lc). Use for npm, curl, ls, git, installing MCP, etc.',
|
|
2120
|
+
parameters: {
|
|
2121
|
+
type: 'object',
|
|
2122
|
+
properties: { command: { type: 'string' }, cwd: { type: 'string' } },
|
|
2123
|
+
required: ['command'],
|
|
2124
|
+
},
|
|
2125
|
+
},
|
|
2126
|
+
},
|
|
2127
|
+
{
|
|
2128
|
+
type: 'function',
|
|
2129
|
+
function: {
|
|
2130
|
+
name: 'list_dir',
|
|
2131
|
+
description: 'List a directory on the user\'s Mac.',
|
|
2132
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
2133
|
+
},
|
|
2134
|
+
},
|
|
2135
|
+
{
|
|
2136
|
+
type: 'function',
|
|
2137
|
+
function: {
|
|
2138
|
+
name: 'screenshot',
|
|
2139
|
+
description: 'Capture the Mac display and attach the image. Returns screen.width/height in POINTS for click x,y. Screenshot first, then click/type, then screenshot again to confirm. Do not guess at a form you have not seen this turn.',
|
|
2140
|
+
parameters: {
|
|
2141
|
+
type: 'object',
|
|
2142
|
+
properties: {
|
|
2143
|
+
window: { type: 'string', description: 'Optional window title substring; default is the full display.' },
|
|
2144
|
+
},
|
|
2145
|
+
},
|
|
2146
|
+
},
|
|
2147
|
+
},
|
|
2148
|
+
{
|
|
2149
|
+
type: 'function',
|
|
2150
|
+
function: {
|
|
2151
|
+
name: 'click',
|
|
2152
|
+
description: 'Click the Mac UI. Prefer query (AX title/button text like "Submit" or "Complete form"). Or x,y in SCREEN POINTS from screenshot.screen (not image pixels). Use image_x/image_y if you measured the attached screenshot. This is how you fill and submit browser forms.',
|
|
2153
|
+
parameters: {
|
|
2154
|
+
type: 'object',
|
|
2155
|
+
properties: {
|
|
2156
|
+
x: { type: 'number' },
|
|
2157
|
+
y: { type: 'number' },
|
|
2158
|
+
image_x: { type: 'number', description: 'X in the attached screenshot pixels; mapped via last screenshot.' },
|
|
2159
|
+
image_y: { type: 'number' },
|
|
2160
|
+
query: { type: 'string', description: 'Visible name: Submit, Complete form, Compose, …' },
|
|
2161
|
+
app: { type: 'string', description: 'Brave Browser, Brave, Grok Bot, …' },
|
|
2162
|
+
button: { type: 'string', enum: ['left', 'right'] },
|
|
2163
|
+
double: { type: 'boolean' },
|
|
2164
|
+
},
|
|
2165
|
+
},
|
|
2166
|
+
},
|
|
2167
|
+
},
|
|
2168
|
+
{
|
|
2169
|
+
type: 'function',
|
|
2170
|
+
function: {
|
|
2171
|
+
name: 'type_text',
|
|
2172
|
+
description: 'Type into the focused field. Click the field first. Long text is pasted. Use this to fill form inputs.',
|
|
2173
|
+
parameters: {
|
|
2174
|
+
type: 'object',
|
|
2175
|
+
properties: {
|
|
2176
|
+
text: { type: 'string' },
|
|
2177
|
+
paste: { type: 'boolean' },
|
|
2178
|
+
app: { type: 'string' },
|
|
2179
|
+
},
|
|
2180
|
+
required: ['text'],
|
|
2181
|
+
},
|
|
2182
|
+
},
|
|
2183
|
+
},
|
|
2184
|
+
{
|
|
2185
|
+
type: 'function',
|
|
2186
|
+
function: {
|
|
2187
|
+
name: 'key',
|
|
2188
|
+
description: 'Press a key. enter/return submits, tab moves fields, escape cancels. cmd+l focuses the URL bar.',
|
|
2189
|
+
parameters: {
|
|
2190
|
+
type: 'object',
|
|
2191
|
+
properties: {
|
|
2192
|
+
key: { type: 'string', description: 'enter, tab, escape, space, a letter, or a named key' },
|
|
2193
|
+
cmd: { type: 'boolean' },
|
|
2194
|
+
alt: { type: 'boolean' },
|
|
2195
|
+
shift: { type: 'boolean' },
|
|
2196
|
+
ctrl: { type: 'boolean' },
|
|
2197
|
+
app: { type: 'string' },
|
|
2198
|
+
},
|
|
2199
|
+
required: ['key'],
|
|
2200
|
+
},
|
|
2201
|
+
},
|
|
2202
|
+
},
|
|
2203
|
+
{
|
|
2204
|
+
type: 'function',
|
|
2205
|
+
function: {
|
|
2206
|
+
name: 'ui_tree',
|
|
2207
|
+
description: 'Dump clickable AX controls of the front window (or app) with screen x,y. Use to find Submit/text fields when the screenshot is ambiguous.',
|
|
2208
|
+
parameters: {
|
|
2209
|
+
type: 'object',
|
|
2210
|
+
properties: {
|
|
2211
|
+
app: { type: 'string' },
|
|
2212
|
+
limit: { type: 'number' },
|
|
2213
|
+
},
|
|
2214
|
+
},
|
|
2215
|
+
},
|
|
2216
|
+
},
|
|
2217
|
+
{
|
|
2218
|
+
type: 'function',
|
|
2219
|
+
function: {
|
|
2220
|
+
name: 'focus_app',
|
|
2221
|
+
description: 'Bring an app to the front. Use "Brave Browser" before clicking Gmail/Stripe.',
|
|
2222
|
+
parameters: {
|
|
2223
|
+
type: 'object',
|
|
2224
|
+
properties: { app: { type: 'string' } },
|
|
2225
|
+
required: ['app'],
|
|
2226
|
+
},
|
|
2227
|
+
},
|
|
2228
|
+
},
|
|
2229
|
+
{
|
|
2230
|
+
type: 'function',
|
|
2231
|
+
function: {
|
|
2232
|
+
name: 'open_url',
|
|
2233
|
+
description: 'Open an http(s) URL in Brave (or app). Prefer this over guessing at the address bar.',
|
|
2234
|
+
parameters: {
|
|
2235
|
+
type: 'object',
|
|
2236
|
+
properties: {
|
|
2237
|
+
url: { type: 'string' },
|
|
2238
|
+
app: { type: 'string' },
|
|
2239
|
+
},
|
|
2240
|
+
required: ['url'],
|
|
2241
|
+
},
|
|
2242
|
+
},
|
|
2243
|
+
},
|
|
2244
|
+
{
|
|
2245
|
+
type: 'function',
|
|
2246
|
+
function: {
|
|
2247
|
+
name: 'create_agent',
|
|
2248
|
+
description: 'Mint another Grok Bot in the sidebar. Pass brief so they keep their job across restarts.',
|
|
2249
|
+
parameters: {
|
|
2250
|
+
type: 'object',
|
|
2251
|
+
properties: {
|
|
2252
|
+
name: { type: 'string', description: 'Sidebar label for the new bot.' },
|
|
2253
|
+
brief: { type: 'string', description: 'Standing job. Persisted. Injected as their system brief on every turn.' },
|
|
2254
|
+
select: { type: 'boolean', description: 'If true (default), switch the UI to the new bot.' },
|
|
2255
|
+
},
|
|
2256
|
+
required: ['name'],
|
|
2257
|
+
},
|
|
2258
|
+
},
|
|
2259
|
+
},
|
|
2260
|
+
{
|
|
2261
|
+
type: 'function',
|
|
2262
|
+
function: {
|
|
2263
|
+
name: 'set_brief',
|
|
2264
|
+
description: 'Save a standing brief on a sidebar bot (this one if agent omitted). Survives hijack restart.',
|
|
2265
|
+
parameters: {
|
|
2266
|
+
type: 'object',
|
|
2267
|
+
properties: {
|
|
2268
|
+
brief: { type: 'string' },
|
|
2269
|
+
agent: { type: 'string', description: 'Name or id. Default: the current bot.' },
|
|
2270
|
+
},
|
|
2271
|
+
required: ['brief'],
|
|
2272
|
+
},
|
|
2273
|
+
},
|
|
2274
|
+
},
|
|
2275
|
+
{
|
|
2276
|
+
type: 'function',
|
|
2277
|
+
function: {
|
|
2278
|
+
name: 'list_agents',
|
|
2279
|
+
description: 'List sidebar bots with id, name, brief. Use before message_agent.',
|
|
2280
|
+
parameters: { type: 'object', properties: {} },
|
|
2281
|
+
},
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
type: 'function',
|
|
2285
|
+
function: {
|
|
2286
|
+
name: 'message_agent',
|
|
2287
|
+
description: 'Send text to another sidebar bot by name or id, wait for their reply, paint it on their canvas. One hop — do not chain.',
|
|
2288
|
+
parameters: {
|
|
2289
|
+
type: 'object',
|
|
2290
|
+
properties: {
|
|
2291
|
+
to: { type: 'string', description: 'Name or id of the other bot.' },
|
|
2292
|
+
text: { type: 'string' },
|
|
2293
|
+
},
|
|
2294
|
+
required: ['to', 'text'],
|
|
2295
|
+
},
|
|
2296
|
+
},
|
|
2297
|
+
},
|
|
2298
|
+
{
|
|
2299
|
+
type: 'function',
|
|
2300
|
+
function: {
|
|
2301
|
+
name: 'schedule_wakeup',
|
|
2302
|
+
description: 'Host timer that keeps this bot working with no human message. "never stop" / cron. Default every 5m. Min 60s. Does not spawn bots.',
|
|
2303
|
+
parameters: {
|
|
2304
|
+
type: 'object',
|
|
2305
|
+
properties: {
|
|
2306
|
+
every: { type: 'string', description: '5m, 1h, 90s. Default 5m. Floor 60s.' },
|
|
2307
|
+
prompt: { type: 'string', description: 'What to do on each tick.' },
|
|
2308
|
+
agent: { type: 'string', description: 'Name or id. Default: this bot.' },
|
|
2309
|
+
},
|
|
2310
|
+
},
|
|
2311
|
+
},
|
|
2312
|
+
},
|
|
2313
|
+
{
|
|
2314
|
+
type: 'function',
|
|
2315
|
+
function: {
|
|
2316
|
+
name: 'cancel_wakeup',
|
|
2317
|
+
description: 'Stop the host wakeup timer for this bot (or agent).',
|
|
2318
|
+
parameters: {
|
|
2319
|
+
type: 'object',
|
|
2320
|
+
properties: {
|
|
2321
|
+
agent: { type: 'string' },
|
|
2322
|
+
},
|
|
2323
|
+
},
|
|
2324
|
+
},
|
|
2325
|
+
},
|
|
2326
|
+
{
|
|
2327
|
+
type: 'function',
|
|
2328
|
+
function: {
|
|
2329
|
+
name: 'ship_crew',
|
|
2330
|
+
description: 'Grok Ship: set up the factory for a repo. Creates Firstmate (once) and one crewmate bot for this repo with standing briefs, detects the forge (gh/glab). Call when the user asks for a software factory / Grok Ship / crew for a repo.',
|
|
2331
|
+
parameters: {
|
|
2332
|
+
type: 'object',
|
|
2333
|
+
properties: { cwd: { type: 'string', description: 'Repo path on this Mac.' } },
|
|
2334
|
+
required: ['cwd'],
|
|
2335
|
+
},
|
|
2336
|
+
},
|
|
2337
|
+
},
|
|
2338
|
+
{
|
|
2339
|
+
type: 'function',
|
|
2340
|
+
function: {
|
|
2341
|
+
name: 'ship_forge',
|
|
2342
|
+
description: 'Grok Ship: detect the source-control forge for a repo (github/gitlab/bitbucket/origin) and which CLI is authenticated (gh/glab). Do not assume GitHub.',
|
|
2343
|
+
parameters: { type: 'object', properties: { cwd: { type: 'string' } }, required: ['cwd'] },
|
|
2344
|
+
},
|
|
2345
|
+
},
|
|
2346
|
+
{
|
|
2347
|
+
type: 'function',
|
|
2348
|
+
function: {
|
|
2349
|
+
name: 'ship_launch_worker',
|
|
2350
|
+
description: 'Grok Ship: start a coding worker (claude-zoo, paid via x402) in a fresh git worktree on a new branch. Returns a task id. Pass the same task id again to send review findings back to the same branch. Never opens a PR.',
|
|
2351
|
+
parameters: {
|
|
2352
|
+
type: 'object',
|
|
2353
|
+
properties: {
|
|
2354
|
+
cwd: { type: 'string', description: 'Repo path.' },
|
|
2355
|
+
title: { type: 'string' },
|
|
2356
|
+
prompt: { type: 'string', description: 'Goal, acceptance criteria, constraints. Or the review findings on a follow-up.' },
|
|
2357
|
+
base: { type: 'string', description: 'Base branch. Default: origin HEAD.' },
|
|
2358
|
+
task: { type: 'string', description: 'Existing task id to resume on its branch.' },
|
|
2359
|
+
},
|
|
2360
|
+
required: ['cwd', 'prompt'],
|
|
2361
|
+
},
|
|
2362
|
+
},
|
|
2363
|
+
},
|
|
2364
|
+
{
|
|
2365
|
+
type: 'function',
|
|
2366
|
+
function: {
|
|
2367
|
+
name: 'ship_status',
|
|
2368
|
+
description: 'Grok Ship: is the worker alive, did it push, what did it commit, log tail. Poll this (with schedule_wakeup) instead of guessing.',
|
|
2369
|
+
parameters: { type: 'object', properties: { task: { type: 'string' } }, required: ['task'] },
|
|
2370
|
+
},
|
|
2371
|
+
},
|
|
2372
|
+
{
|
|
2373
|
+
type: 'function',
|
|
2374
|
+
function: {
|
|
2375
|
+
name: 'ship_review',
|
|
2376
|
+
description: 'Grok Ship: FRESH adversarial review of the pushed branch (one-shot, no chat history, only the diff). Returns findings + gate. Run after every push, before any PR.',
|
|
2377
|
+
parameters: {
|
|
2378
|
+
type: 'object',
|
|
2379
|
+
properties: {
|
|
2380
|
+
task: { type: 'string' },
|
|
2381
|
+
cwd: { type: 'string', description: 'Without a task: repo path.' },
|
|
2382
|
+
branch: { type: 'string' },
|
|
2383
|
+
base: { type: 'string' },
|
|
2384
|
+
},
|
|
2385
|
+
},
|
|
2386
|
+
},
|
|
2387
|
+
},
|
|
2388
|
+
{
|
|
2389
|
+
type: 'function',
|
|
2390
|
+
function: {
|
|
2391
|
+
name: 'ship_open_pr',
|
|
2392
|
+
description: 'Grok Ship: open the PR/MR for a task. Refuses unless the last ship_review gate is clean and the branch is on origin. Never merges.',
|
|
2393
|
+
parameters: {
|
|
2394
|
+
type: 'object',
|
|
2395
|
+
properties: { task: { type: 'string' }, title: { type: 'string' }, body: { type: 'string' } },
|
|
2396
|
+
required: ['task'],
|
|
2397
|
+
},
|
|
2398
|
+
},
|
|
2399
|
+
},
|
|
2400
|
+
{
|
|
2401
|
+
type: 'function',
|
|
2402
|
+
function: {
|
|
2403
|
+
name: 'x_claim',
|
|
2404
|
+
description: 'X reply lock: claim a tweet (status URL or id) before drafting a reply. ok:false means another bot has it or it was already answered — pick a different tweet. Lease 20m.',
|
|
2405
|
+
parameters: { type: 'object', properties: { tweet: { type: 'string' } }, required: ['tweet'] },
|
|
2406
|
+
},
|
|
2407
|
+
},
|
|
2408
|
+
{
|
|
2409
|
+
type: 'function',
|
|
2410
|
+
function: {
|
|
2411
|
+
name: 'x_done',
|
|
2412
|
+
description: 'X reply lock: mark a claimed tweet as replied, permanently, with the URL of our reply. Also appends to openzoobot-posted.json.',
|
|
2413
|
+
parameters: { type: 'object', properties: { tweet: { type: 'string' }, url: { type: 'string' }, lane: { type: 'string' } }, required: ['tweet', 'url'] },
|
|
2414
|
+
},
|
|
2415
|
+
},
|
|
2416
|
+
{
|
|
2417
|
+
type: 'function',
|
|
2418
|
+
function: {
|
|
2419
|
+
name: 'x_release',
|
|
2420
|
+
description: 'X reply lock: give a claimed tweet back (you decided not to reply).',
|
|
2421
|
+
parameters: { type: 'object', properties: { tweet: { type: 'string' } }, required: ['tweet'] },
|
|
2422
|
+
},
|
|
2423
|
+
},
|
|
2424
|
+
{
|
|
2425
|
+
type: 'function',
|
|
2426
|
+
function: {
|
|
2427
|
+
name: 'x_claims',
|
|
2428
|
+
description: 'X reply lock: list tweet ids already answered plus live claims by other bots, so you can skip them before reading.',
|
|
2429
|
+
parameters: { type: 'object', properties: {} },
|
|
2430
|
+
},
|
|
2431
|
+
},
|
|
2432
|
+
{
|
|
2433
|
+
type: 'function',
|
|
2434
|
+
function: {
|
|
2435
|
+
name: 'x_compose',
|
|
2436
|
+
description: 'Type reply text into the X composer on the current page of the given browser, LINE BY LINE with real Enter keys so newlines survive (fill flattens them). Click/focus the reply box first (or open the tweet). Then click the Reply button yourself. browser: chrome or brave.',
|
|
2437
|
+
parameters: {
|
|
2438
|
+
type: 'object',
|
|
2439
|
+
properties: { browser: { type: 'string', enum: ['chrome', 'brave'] }, text: { type: 'string' } },
|
|
2440
|
+
required: ['browser', 'text'],
|
|
2441
|
+
},
|
|
2442
|
+
},
|
|
2443
|
+
},
|
|
2444
|
+
{
|
|
2445
|
+
type: 'function',
|
|
2446
|
+
function: {
|
|
2447
|
+
name: 'x_open',
|
|
2448
|
+
description: 'Open YOUR OWN tab in the given browser (chrome|brave) at a URL and pin it to you: every later chrome-devtools__/brave-devtools__ call you make is routed to that tab automatically, so other bots cannot stomp on it. Call this first, before evaluate_script/x_compose. Returns the page text length.',
|
|
2449
|
+
parameters: { type: 'object', properties: { browser: { type: 'string', enum: ['chrome', 'brave'] }, url: { type: 'string' } }, required: ['browser', 'url'] },
|
|
2450
|
+
},
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
type: 'function',
|
|
2454
|
+
function: {
|
|
2455
|
+
name: 'x_close',
|
|
2456
|
+
description: 'Close the tab x_open pinned to you (do this at the end of every turn).',
|
|
2457
|
+
parameters: { type: 'object', properties: { browser: { type: 'string', enum: ['chrome', 'brave'] } }, required: ['browser'] },
|
|
2458
|
+
},
|
|
2459
|
+
},
|
|
2460
|
+
];
|
|
2461
|
+
|
|
2462
|
+
export const LOCAL_TOOL_NAMES = LOCAL_TOOLS.map((t) => t.function.name);
|
|
2463
|
+
|
|
2464
|
+
/**
|
|
2465
|
+
* OpenAI-compatible upstreams cap `tools` at 128. MEASURED 2026-09-01:
|
|
2466
|
+
* surplusintelligence `400 Invalid 'tools': array too long … got 130` — every
|
|
2467
|
+
* bot turn with both browsers attached died at the door. Locals first, then
|
|
2468
|
+
* the browser MCPs (the bots' real work), then whatever fits.
|
|
2469
|
+
*/
|
|
2470
|
+
export const MAX_TOOLS = 128;
|
|
2471
|
+
export function capTools(list, max = MAX_TOOLS) {
|
|
2472
|
+
if (!Array.isArray(list) || list.length <= max) return list;
|
|
2473
|
+
const name = (t) => String(t?.function?.name || '');
|
|
2474
|
+
const local = list.filter((t) => LOCAL_TOOL_NAMES.includes(name(t)));
|
|
2475
|
+
const browser = list.filter((t) => !LOCAL_TOOL_NAMES.includes(name(t)) && /^(chrome|brave)-devtools__/.test(name(t)));
|
|
2476
|
+
const rest = list.filter((t) => !local.includes(t) && !browser.includes(t));
|
|
2477
|
+
return [...local, ...browser, ...rest].slice(0, max);
|
|
2478
|
+
}
|
|
2479
|
+
function liveTools() {
|
|
2480
|
+
const extra = hostMcpTools();
|
|
2481
|
+
return capTools(extra.length ? [...LOCAL_TOOLS, ...extra] : LOCAL_TOOLS);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
async function captureScreenshot(log) {
|
|
2485
|
+
const dir = path.join(os.tmpdir(), 'openzoo-screens');
|
|
2486
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2487
|
+
const stamp = Date.now();
|
|
2488
|
+
const raw = path.join(dir, `oz-${stamp}.png`);
|
|
2489
|
+
const jpg = path.join(dir, `oz-${stamp}.jpg`);
|
|
2490
|
+
await execFileAsync('screencapture', ['-x', '-C', raw], { timeout: 15000 });
|
|
2491
|
+
if (!fs.existsSync(raw) || fs.statSync(raw).size < 80) {
|
|
2492
|
+
throw new Error('screencapture wrote nothing — Screen Recording permission may be off for Grok Bot / Terminal');
|
|
2493
|
+
}
|
|
2494
|
+
let screen = { width: 0, height: 0 };
|
|
2495
|
+
try { screen = await displayBounds(); } catch (e) {
|
|
2496
|
+
log(`cursor-backend: displayBounds failed: ${e.message}`);
|
|
2497
|
+
}
|
|
2498
|
+
let out = { path: raw, mime: 'image/png', buf: fs.readFileSync(raw) };
|
|
2499
|
+
try {
|
|
2500
|
+
await execFileAsync('sips', [
|
|
2501
|
+
'-Z', '1400', '-s', 'format', 'jpeg', '-s', 'formatOptions', '70',
|
|
2502
|
+
raw, '--out', jpg,
|
|
2503
|
+
], { timeout: 15000 });
|
|
2504
|
+
if (fs.existsSync(jpg) && fs.statSync(jpg).size > 80) {
|
|
2505
|
+
out = { path: jpg, mime: 'image/jpeg', buf: fs.readFileSync(jpg) };
|
|
2506
|
+
}
|
|
2507
|
+
} catch (e) {
|
|
2508
|
+
log(`cursor-backend: sips screenshot resize failed: ${e.message}`);
|
|
2509
|
+
}
|
|
2510
|
+
let image = { width: 0, height: 0 };
|
|
2511
|
+
try { image = await imageSize(out.path); } catch (e) {
|
|
2512
|
+
log(`cursor-backend: imageSize failed: ${e.message}`);
|
|
2513
|
+
}
|
|
2514
|
+
const meta = { path: out.path, mime: out.mime, buf: out.buf, screen, image };
|
|
2515
|
+
noteShotMeta({ screen, image, path: out.path });
|
|
2516
|
+
return meta;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
function findAgent(q) {
|
|
2520
|
+
const list = cachedAgentList() || [];
|
|
2521
|
+
const s = String(q || '').trim();
|
|
2522
|
+
if (!s) return null;
|
|
2523
|
+
const lower = s.toLowerCase();
|
|
2524
|
+
return list.find((a) => a.id === s)
|
|
2525
|
+
|| list.find((a) => String(a.name || '').toLowerCase() === lower)
|
|
2526
|
+
|| list.find((a) => String(a.name || '').toLowerCase().includes(lower))
|
|
2527
|
+
|| null;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
let messageAgentDepth = 0;
|
|
2531
|
+
async function deliverAgentMessage({ fromId, to, text, parsed = {}, log }) {
|
|
2532
|
+
const dest = findAgent(to);
|
|
2533
|
+
if (!dest) return `ERROR no bot named ${JSON.stringify(to)}. list_agents first.`;
|
|
2534
|
+
if (fromId && dest.id === String(fromId)) return 'ERROR cannot message_agent yourself';
|
|
2535
|
+
const msg = String(text || '').trim();
|
|
2536
|
+
if (!msg) return 'ERROR empty message';
|
|
2537
|
+
if (messageAgentDepth >= 1) return 'ERROR message_agent is one hop — reply in chat instead of chaining';
|
|
2538
|
+
const from = findAgent(fromId);
|
|
2539
|
+
const fromName = from?.name || 'another bot';
|
|
2540
|
+
const nonce = `oz-msg-${Date.now()}`;
|
|
2541
|
+
const prompt = `[message from ${fromName}]\n${msg}`;
|
|
2542
|
+
messageAgentDepth += 1;
|
|
2543
|
+
try {
|
|
2544
|
+
const userLine = fanoutLine(dest.id, 'user', prompt, { clientNonce: nonce, requestId: nonce });
|
|
2545
|
+
ssePush('transcript', { ...gatewayEntry(userLine), agentId: dest.id });
|
|
2546
|
+
bumpAgent(dest.id, { preview: msg, notify: true });
|
|
2547
|
+
const z = await zooComplete(prompt, log, dest.id, { ...parsed, visitor: undefined });
|
|
2548
|
+
const reply = String(z.text || '');
|
|
2549
|
+
const line = fanoutLine(dest.id, 'assistant', reply, { clientNonce: nonce, requestId: nonce });
|
|
2550
|
+
ssePush('transcript', { ...gatewayEntry(line), agentId: dest.id });
|
|
2551
|
+
bumpAgent(dest.id, { preview: reply, notify: true });
|
|
2552
|
+
return JSON.stringify({
|
|
2553
|
+
ok: true,
|
|
2554
|
+
to: { id: dest.id, name: dest.name },
|
|
2555
|
+
reply: stripSpendFooter(reply).slice(0, 4000),
|
|
2556
|
+
});
|
|
2557
|
+
} finally {
|
|
2558
|
+
messageAgentDepth -= 1;
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
|
|
2563
|
+
/** agentId -> { 'chrome-devtools': pageId, 'brave-devtools': pageId }. A bot's
|
|
2564
|
+
* browser calls are preceded by select_page on its pinned tab (under the
|
|
2565
|
+
* per-browser lock) so 13 bots on one Chrome do not act on each other's tab. */
|
|
2566
|
+
const agentPages = new Map();
|
|
2567
|
+
function pinnedPage(agentId, server) { return agentPages.get(String(agentId || ''))?.[server]; }
|
|
2568
|
+
function pinPage(agentId, server, pageId) {
|
|
2569
|
+
const m = agentPages.get(String(agentId || '')) || {};
|
|
2570
|
+
if (pageId == null) delete m[server]; else m[server] = pageId;
|
|
2571
|
+
agentPages.set(String(agentId || ''), m);
|
|
2572
|
+
}
|
|
2573
|
+
/** chrome-devtools-mcp prints "## Pages\n1: <url> [selected]\n2: …". */
|
|
2574
|
+
export function selectedPageId(text) {
|
|
2575
|
+
const m = String(text || '').match(/(\d+):\s[^\n]*\[selected\]/);
|
|
2576
|
+
return m ? Number(m[1]) : null;
|
|
2577
|
+
}
|
|
2578
|
+
async function callBrowserForAgent(server, tool, args, agentId) {
|
|
2579
|
+
const pinned = pinnedPage(agentId, server);
|
|
2580
|
+
if (pinned != null && tool === 'select_page' && Number(args?.pageId) !== Number(pinned)) {
|
|
2581
|
+
// A pinned bot stays on its own tab. Answer with its tab, do not switch.
|
|
2582
|
+
return callHostMcp(`${server}__select_page`, { pageId: pinned });
|
|
2583
|
+
}
|
|
2584
|
+
if (pinned != null && !/^(new_page|list_pages|select_page|close_page)$/.test(tool)) {
|
|
2585
|
+
try { await callHostMcp(`${server}__select_page`, { pageId: pinned }); } catch { /* page gone; fall through */ }
|
|
2586
|
+
}
|
|
2587
|
+
const out = await callHostMcp(`${server}__${tool}`, args);
|
|
2588
|
+
if (tool === 'new_page') { const pid = selectedPageId(out); if (pid != null) pinPage(agentId, server, pid); }
|
|
2589
|
+
return out;
|
|
2590
|
+
}
|
|
2591
|
+
async function runLocalTool(name, args, log, ctx = {}) {
|
|
2592
|
+
try {
|
|
2593
|
+
if (name === 'read_file') {
|
|
2594
|
+
const buf = await readLocalBytes(expandUserPath(args.path), log);
|
|
2595
|
+
if (buf.length > 180000) return `file ${args.path} is ${buf.length} bytes; first 180000:\n${buf.subarray(0, 180000).toString('utf8')}`;
|
|
2596
|
+
return buf.toString('utf8');
|
|
2597
|
+
}
|
|
2598
|
+
if (name === 'write_file') {
|
|
2599
|
+
return await writeLocalBytes(expandUserPath(args.path), args.content ?? '', log);
|
|
2600
|
+
}
|
|
2601
|
+
if (name === 'list_dir') {
|
|
2602
|
+
const abs = expandUserPath(args.path || os.homedir());
|
|
2603
|
+
if (localExecSse.size > 0) {
|
|
2604
|
+
const got = await localExecAsk({ kind: 'exec', serverMessage: { command: `ls -la ${JSON.stringify(abs)}`, cwd: os.homedir() } });
|
|
2605
|
+
return got.stdout || got.message || '';
|
|
2606
|
+
}
|
|
2607
|
+
return fs.readdirSync(abs, { withFileTypes: true })
|
|
2608
|
+
.map((e) => `${e.isDirectory() ? 'd' : '-'} ${e.name}`)
|
|
2609
|
+
.join('\n');
|
|
2610
|
+
}
|
|
2611
|
+
if (name === 'exec') {
|
|
2612
|
+
return await execLocal(String(args.command || ''), args.cwd, log);
|
|
2613
|
+
}
|
|
2614
|
+
if (name === 'screenshot') {
|
|
2615
|
+
const shot = await captureScreenshot(log);
|
|
2616
|
+
return JSON.stringify({
|
|
2617
|
+
ok: true,
|
|
2618
|
+
path: shot.path,
|
|
2619
|
+
mime: shot.mime,
|
|
2620
|
+
bytes: shot.buf.length,
|
|
2621
|
+
screen: shot.screen,
|
|
2622
|
+
image: shot.image,
|
|
2623
|
+
click: 'x,y are SCREEN points (screenshot.screen). Or pass image_x,image_y from this image. Prefer click query="Submit".',
|
|
2624
|
+
dataUrl: `data:${shot.mime};base64,${shot.buf.toString('base64')}`,
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
if (name === 'click') {
|
|
2628
|
+
const got = await desktopAction('click', args, log);
|
|
2629
|
+
return JSON.stringify(got);
|
|
2630
|
+
}
|
|
2631
|
+
if (name === 'type_text') {
|
|
2632
|
+
const got = await desktopAction('type', args, log);
|
|
2633
|
+
return JSON.stringify(got);
|
|
2634
|
+
}
|
|
2635
|
+
if (name === 'key') {
|
|
2636
|
+
const got = await desktopAction('key', args, log);
|
|
2637
|
+
return JSON.stringify(got);
|
|
2638
|
+
}
|
|
2639
|
+
if (name === 'ui_tree') {
|
|
2640
|
+
const got = await desktopAction('tree', args, log);
|
|
2641
|
+
return JSON.stringify(got);
|
|
2642
|
+
}
|
|
2643
|
+
if (name === 'focus_app') {
|
|
2644
|
+
const got = await desktopAction('focus', { app: resolveAppName(args.app) }, log);
|
|
2645
|
+
return JSON.stringify(got);
|
|
2646
|
+
}
|
|
2647
|
+
if (name === 'open_url') {
|
|
2648
|
+
const got = await desktopAction('open_url', args, log);
|
|
2649
|
+
return JSON.stringify(got);
|
|
2650
|
+
}
|
|
2651
|
+
if (name === 'create_agent') {
|
|
2652
|
+
const name = String(args.name || args.title || 'New Bot').slice(0, 80);
|
|
2653
|
+
const brief = String(args.brief || args.instructions || '').trim() || briefFromName(name);
|
|
2654
|
+
const agent = mintLocalAgent({ name, brief });
|
|
2655
|
+
pushCreatedAgent(agent, { select: args.select !== false });
|
|
2656
|
+
seedBriefOnCanvas(agent);
|
|
2657
|
+
const n = (cachedAgentList() || []).length;
|
|
2658
|
+
log(`cursor-backend: create_agent tool id=${agent.id} name=${JSON.stringify(agent.name)} brief=${brief ? brief.length : 0}c`);
|
|
2659
|
+
const warning = n >= 8
|
|
2660
|
+
? `Mac already has ${n} bots. Prefer schedule_wakeup over spawning. Only mint a NEW named role.`
|
|
2661
|
+
: undefined;
|
|
2662
|
+
return JSON.stringify({ ok: true, id: agent.id, name: agent.name, brief: agent.brief || '', warning });
|
|
2663
|
+
}
|
|
2664
|
+
if (name === 'set_brief') {
|
|
2665
|
+
const who = findAgent(args.agent || args.id || args.name) || findAgent(ctx.agentId);
|
|
2666
|
+
if (!who) return 'ERROR no such agent';
|
|
2667
|
+
const agent = mintLocalAgent({ id: who.id, name: who.name, brief: String(args.brief || '') });
|
|
2668
|
+
return JSON.stringify({ ok: true, id: agent.id, name: agent.name, brief: agent.brief || '' });
|
|
2669
|
+
}
|
|
2670
|
+
if (name === 'list_agents') {
|
|
2671
|
+
const list = cachedAgentList() || [];
|
|
2672
|
+
const wakes = readWakeups(HOME);
|
|
2673
|
+
return JSON.stringify(list.map((a) => ({
|
|
2674
|
+
id: a.id,
|
|
2675
|
+
name: a.name,
|
|
2676
|
+
brief: String(a.brief || '').slice(0, 120),
|
|
2677
|
+
wakeup: wakes[a.id] ? { everySec: wakes[a.id].everySec, nextAt: wakes[a.id].nextAt } : null,
|
|
2678
|
+
})));
|
|
2679
|
+
}
|
|
2680
|
+
if (name === 'message_agent') {
|
|
2681
|
+
return await deliverAgentMessage({
|
|
2682
|
+
fromId: ctx.agentId,
|
|
2683
|
+
to: args.to || args.agent,
|
|
2684
|
+
text: args.text || args.message || args.prompt,
|
|
2685
|
+
parsed: ctx.parsed,
|
|
2686
|
+
log,
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2689
|
+
if (name === 'schedule_wakeup') {
|
|
2690
|
+
const who = findAgent(args.agent || args.id || args.name) || findAgent(ctx.agentId);
|
|
2691
|
+
if (!who) return 'ERROR no such agent';
|
|
2692
|
+
const got = scheduleAgentWakeup(who.id, { every: args.every, prompt: args.prompt });
|
|
2693
|
+
return JSON.stringify(got);
|
|
2694
|
+
}
|
|
2695
|
+
if (name === 'cancel_wakeup') {
|
|
2696
|
+
const who = findAgent(args.agent || args.id || args.name) || findAgent(ctx.agentId);
|
|
2697
|
+
if (!who) return 'ERROR no such agent';
|
|
2698
|
+
return JSON.stringify(cancelAgentWakeup(who.id));
|
|
2699
|
+
}
|
|
2700
|
+
if (name.startsWith('ship_')) {
|
|
2701
|
+
return await runShipTool(name, args, log, ctx);
|
|
2702
|
+
}
|
|
2703
|
+
if (name.startsWith('x_')) {
|
|
2704
|
+
const me = findAgent(ctx.agentId) || { id: String(ctx.agentId || ''), name: '' };
|
|
2705
|
+
if (name === 'x_claim') return JSON.stringify(xclaims.claimTweet({ tweet: args.tweet || args.url || args.id, by: me.id, name: me.name }));
|
|
2706
|
+
if (name === 'x_done') return JSON.stringify(xclaims.markDone({ tweet: args.tweet || args.id, by: me.id, name: me.name, url: args.url, lane: args.lane }));
|
|
2707
|
+
if (name === 'x_release') return JSON.stringify(xclaims.releaseTweet({ tweet: args.tweet || args.id, by: me.id }));
|
|
2708
|
+
if (name === 'x_claims') return JSON.stringify(xclaims.listClaims());
|
|
2709
|
+
if (name === 'x_open') {
|
|
2710
|
+
const server = String(args.browser || 'chrome').toLowerCase() === 'brave' ? 'brave-devtools' : 'chrome-devtools';
|
|
2711
|
+
if (!hostMcpHas(`${server}__new_page`)) return JSON.stringify({ ok: false, error: `${server} not attached` });
|
|
2712
|
+
const out = String(await callHostMcp(`${server}__new_page`, { url: String(args.url || 'https://x.com') }));
|
|
2713
|
+
const pid = selectedPageId(out);
|
|
2714
|
+
if (pid == null) return JSON.stringify({ ok: false, error: 'could not read the new page id', raw: out.slice(0, 300) });
|
|
2715
|
+
pinPage(ctx.agentId, server, pid);
|
|
2716
|
+
return JSON.stringify({ ok: true, browser: server, pageId: pid, note: 'pinned: your browser calls now go to this tab' });
|
|
2717
|
+
}
|
|
2718
|
+
if (name === 'x_close') {
|
|
2719
|
+
const server = String(args.browser || 'chrome').toLowerCase() === 'brave' ? 'brave-devtools' : 'chrome-devtools';
|
|
2720
|
+
const pid = pinnedPage(ctx.agentId, server);
|
|
2721
|
+
if (pid == null) return JSON.stringify({ ok: true, closed: false });
|
|
2722
|
+
try { await callHostMcp(`${server}__close_page`, { pageId: pid }); } catch (e) { pinPage(ctx.agentId, server, null); return JSON.stringify({ ok: false, error: e.message }); }
|
|
2723
|
+
pinPage(ctx.agentId, server, null);
|
|
2724
|
+
return JSON.stringify({ ok: true, closed: true, pageId: pid });
|
|
2725
|
+
}
|
|
2726
|
+
if (name === 'x_compose') {
|
|
2727
|
+
const server = String(args.browser || 'chrome').toLowerCase() === 'brave' ? 'brave-devtools' : 'chrome-devtools';
|
|
2728
|
+
for (const t of ['evaluate_script', 'click_at', 'type_text', 'press_key']) {
|
|
2729
|
+
if (!hostMcpHas(`${server}__${t}`)) return JSON.stringify({ ok: false, error: `${server}__${t} not attached` });
|
|
2730
|
+
}
|
|
2731
|
+
const probeRaw = String(await callBrowserForAgent(server, 'evaluate_script', { function: xclaims.composeProbeScript() }, ctx.agentId));
|
|
2732
|
+
const probe = (() => { try { return JSON.parse(probeRaw.match(/\{[\s\S]*\}/)?.[0] || '{}'); } catch { return {}; } })();
|
|
2733
|
+
if (!probe.ok) return JSON.stringify({ ok: false, error: probe.error || `composer probe failed: ${probeRaw.slice(0, 200)}` });
|
|
2734
|
+
const plan = xclaims.composePlan(args.text, probe);
|
|
2735
|
+
let last = '';
|
|
2736
|
+
for (const op of plan.ops) last = String(await callBrowserForAgent(server, op.tool, op.args, ctx.agentId));
|
|
2737
|
+
const back = (() => { try { return JSON.parse(last.match(/\{[\s\S]*\}/)?.[0] || '{}'); } catch { return {}; } })();
|
|
2738
|
+
log(`cursor-backend: x_compose ${server} lines=${plan.lines} back=${back.lines} replyEnabled=${back.replyEnabled}`);
|
|
2739
|
+
if (!back.replyEnabled) {
|
|
2740
|
+
return JSON.stringify({ ok: false, browser: server, error: 'typed, but X did not register it (Reply still disabled). Click the reply box yourself with a real click (find [data-testid="tweetTextarea_0"] and use click_at on its centre), then call x_compose again.', composer: back });
|
|
2741
|
+
}
|
|
2742
|
+
return JSON.stringify({ ok: true, browser: server, linesTyped: plan.lines, composer: back, next: 'lines are in and Reply is enabled: click the Reply button ([data-testid="tweetButtonInline"]) now' });
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
if (hostMcpHas(name)) {
|
|
2746
|
+
const m = name.match(/^(chrome-devtools|brave-devtools)__(.+)$/);
|
|
2747
|
+
if (m) return await callBrowserForAgent(m[1], m[2], args, ctx.agentId);
|
|
2748
|
+
return await callHostMcp(name, args);
|
|
2749
|
+
}
|
|
2750
|
+
return `unknown tool ${name}`;
|
|
2751
|
+
} catch (e) {
|
|
2752
|
+
return `ERROR ${e.message}`;
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/** Grok Ship tools. `run` is execLocal so workers/PRs use the same shell the bot does. */
|
|
2757
|
+
async function runShipTool(name, args, log, ctx = {}) {
|
|
2758
|
+
const run = (command, cwd) => execLocal(command, cwd, log);
|
|
2759
|
+
const home = HOME;
|
|
2760
|
+
if (name === 'ship_forge') {
|
|
2761
|
+
const cwd = expandUserPath(String(args.cwd || ''));
|
|
2762
|
+
return JSON.stringify(await ship.probeForge(cwd, run));
|
|
2763
|
+
}
|
|
2764
|
+
if (name === 'ship_crew') {
|
|
2765
|
+
const cwd = expandUserPath(String(args.cwd || ''));
|
|
2766
|
+
const forge = await ship.probeForge(cwd, run);
|
|
2767
|
+
const list = cachedAgentList() || [];
|
|
2768
|
+
let firstmate = list.find((a) => /^firstmate$/i.test(String(a.name || '')));
|
|
2769
|
+
if (!firstmate) {
|
|
2770
|
+
firstmate = mintLocalAgent({ name: 'Firstmate', brief: ship.firstmateBrief() });
|
|
2771
|
+
pushCreatedAgent(firstmate, { select: false });
|
|
2772
|
+
seedBriefOnCanvas(firstmate, log);
|
|
2773
|
+
}
|
|
2774
|
+
const repoName = path.basename(cwd);
|
|
2775
|
+
let crew = list.find((a) => String(a.brief || '').includes(`crewmate for the repo at ${cwd}`));
|
|
2776
|
+
if (!crew) {
|
|
2777
|
+
crew = mintLocalAgent({ name: `Crew · ${repoName}`, brief: ship.crewmateBrief({ repo: cwd, forge: forge.forge }) });
|
|
2778
|
+
pushCreatedAgent(crew, { select: false });
|
|
2779
|
+
seedBriefOnCanvas(crew, log);
|
|
2780
|
+
}
|
|
2781
|
+
log(`cursor-backend: ship_crew repo=${cwd} forge=${forge.forge} cli=${forge.cli} firstmate=${firstmate.id} crew=${crew.id}`);
|
|
2782
|
+
return JSON.stringify({
|
|
2783
|
+
ok: true,
|
|
2784
|
+
forge,
|
|
2785
|
+
firstmate: { id: firstmate.id, name: firstmate.name },
|
|
2786
|
+
crewmate: { id: crew.id, name: crew.name },
|
|
2787
|
+
next: forge.cli ? 'Talk to Firstmate. It hands ship tasks to the crewmate.' : `No authenticated forge CLI (${forge.forge}); run gh auth login / glab auth login on this Mac before ship_open_pr.`,
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
if (name === 'ship_launch_worker') {
|
|
2791
|
+
const cwd = expandUserPath(String(args.cwd || ''));
|
|
2792
|
+
const task = await ship.launchWorker({
|
|
2793
|
+
cwd, title: args.title, prompt: args.prompt, base: args.base, taskId: args.task, home, run, log,
|
|
2794
|
+
});
|
|
2795
|
+
return JSON.stringify({ ok: true, task: task.id, branch: task.branch, base: task.base, worktree: task.worktree, log: task.log, pid: task.pid, attempts: task.attempts });
|
|
2796
|
+
}
|
|
2797
|
+
if (name === 'ship_status') {
|
|
2798
|
+
return JSON.stringify(await ship.taskStatus({ taskId: String(args.task || ''), home, run }));
|
|
2799
|
+
}
|
|
2800
|
+
if (name === 'ship_review') {
|
|
2801
|
+
const got = await ship.reviewBranch({
|
|
2802
|
+
taskId: args.task ? String(args.task) : undefined,
|
|
2803
|
+
cwd: args.cwd ? expandUserPath(String(args.cwd)) : undefined,
|
|
2804
|
+
branch: args.branch, base: args.base,
|
|
2805
|
+
model: currentModel(ctx.agentId), home, run, log,
|
|
2806
|
+
});
|
|
2807
|
+
return JSON.stringify(got);
|
|
2808
|
+
}
|
|
2809
|
+
if (name === 'ship_open_pr') {
|
|
2810
|
+
return JSON.stringify(await ship.openPr({ taskId: String(args.task || ''), title: args.title, body: args.body, home, run, log }));
|
|
2811
|
+
}
|
|
2812
|
+
return `unknown tool ${name}`;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/** Model parked instead of writing files. Host keeps the tool loop going. */
|
|
2816
|
+
export function looksStoppedReply(s) {
|
|
2817
|
+
return /stopped on research|no (?:new )?app files written|nothing (?:extra )?to open yet|say go again|next message i['’]?ll write|stopped — no new/i.test(String(s || ''));
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
function zooTextFromMessage(msg, data) {
|
|
2821
|
+
let c = msg?.content;
|
|
2822
|
+
if (Array.isArray(c)) {
|
|
2823
|
+
c = c.map((p) => (typeof p === 'string' ? p : (p?.text || p?.content || ''))).join('');
|
|
2824
|
+
}
|
|
2825
|
+
if (typeof c === 'string' && c.trim()) return c;
|
|
2826
|
+
// Reasoning models spend the whole budget thinking and return content:""
|
|
2827
|
+
// with finish=length — 8192 tokens, 0 visible. The thinking IS the reply
|
|
2828
|
+
// then; dropping it painted "(empty zoo reply)" and re-bought the turn.
|
|
2829
|
+
const rc = msg?.reasoning_content ?? msg?.reasoning;
|
|
2830
|
+
if (typeof rc === 'string' && rc.trim()) return rc;
|
|
2831
|
+
if (typeof data?.error?.message === 'string' && data.error.message) return data.error.message;
|
|
2832
|
+
return '';
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
let mediaClient = null;
|
|
2836
|
+
function receiptToX402(r) {
|
|
2837
|
+
if (!r) return {};
|
|
2838
|
+
return { billedUsd: r.billedUsd, directUsd: r.directUsd ?? r.billedUsd, rail: r.rail, tx: r.tx, line: r.line, paid: r.asset };
|
|
2839
|
+
}
|
|
2840
|
+
/** Pay the media endpoint, poll the job, paint the URL. Same PayClient path as zoo_video. */
|
|
2841
|
+
async function mediaTurn({ model, kind, prompt, log, onProgress, signal }) {
|
|
2842
|
+
const { PayClient } = await import('./pay.js');
|
|
2843
|
+
const { config } = await import('./config.js');
|
|
2844
|
+
const { withNamespace } = await import('./namespace.js');
|
|
2845
|
+
mediaClient ||= new PayClient();
|
|
2846
|
+
const say = (m) => { try { onProgress?.(m); } catch { /* */ } };
|
|
2847
|
+
say(`Rendering ${kind} with ${model}…`);
|
|
2848
|
+
log(`cursor-backend: media ${kind} POST model=${model} ${JSON.stringify(String(prompt || '').slice(0, 60))}`);
|
|
2849
|
+
let response; let receipt; let data = {};
|
|
2850
|
+
try {
|
|
2851
|
+
({ response, receipt } = await mediaClient.fetch(`${config.apiBase}/v1/${kind}s/generations`, {
|
|
2852
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ model, prompt }),
|
|
2853
|
+
}));
|
|
2854
|
+
data = await response.json().catch(() => ({}));
|
|
2855
|
+
} catch (e) {
|
|
2856
|
+
return { text: `${kind} generation failed: ${e.message}`, data: {} };
|
|
2857
|
+
}
|
|
2858
|
+
if (!response.ok) return { text: `${kind} generation failed: HTTP ${response.status} ${JSON.stringify(data).slice(0, 240)}`, data: { x402: receiptToX402(receipt) } };
|
|
2859
|
+
if (kind === 'image') {
|
|
2860
|
+
const url = data?.data?.[0]?.url || data?.outputs?.image_url || data?.url || null;
|
|
2861
|
+
return { text: url ? `🖼️ ${url}` : JSON.stringify(data).slice(0, 400), data: { x402: receiptToX402(receipt) } };
|
|
2862
|
+
}
|
|
2863
|
+
const id = data?.id;
|
|
2864
|
+
let url = data?.outputs?.video_url || null;
|
|
2865
|
+
let status = data?.status || 'queued';
|
|
2866
|
+
let err = null;
|
|
2867
|
+
const t0 = Date.now();
|
|
2868
|
+
while (!url && id && Date.now() - t0 < 8 * 60_000) {
|
|
2869
|
+
if (signal?.aborted) throw new Error('superseded');
|
|
2870
|
+
await new Promise((ok) => setTimeout(ok, 5000));
|
|
2871
|
+
try {
|
|
2872
|
+
const r = await fetch(`${config.apiBase}/v1/videos/${encodeURIComponent(id)}`, { headers: withNamespace({}), signal: AbortSignal.timeout(15000) });
|
|
2873
|
+
const j = await r.json().catch(() => ({}));
|
|
2874
|
+
status = j?.status || status;
|
|
2875
|
+
url = j?.outputs?.video_url || null;
|
|
2876
|
+
if (status === 'failed') { err = j?.error?.message || JSON.stringify(j?.error || j).slice(0, 200); break; }
|
|
2877
|
+
} catch (e) { log(`cursor-backend: media poll ${e.message}`); }
|
|
2878
|
+
say(`Rendering video… ${status} ${Math.round((Date.now() - t0) / 1000)}s`);
|
|
2879
|
+
}
|
|
2880
|
+
const text = url
|
|
2881
|
+
? `🎬 ${url}`
|
|
2882
|
+
: err ? `video failed: ${err}` : `video job ${id || '?'} still ${status} after 8 min — check later: ${config.apiBase}/v1/videos/${id || ''}`;
|
|
2883
|
+
log(`cursor-backend: media video done id=${id} status=${status} url=${url ? 'yes' : 'no'}`);
|
|
2884
|
+
return { text, data: { x402: receiptToX402(receipt) } };
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
2888
|
+
const model = currentModel(agentId);
|
|
2889
|
+
const helper = localExecSse.size > 0;
|
|
2890
|
+
const visitor = visitorFromSend(parsed);
|
|
2891
|
+
const chatOnly = Boolean(visitor);
|
|
2892
|
+
const spoken = visitor && !/^You are /.test(String(prompt || ''))
|
|
2893
|
+
? labeledVisitorPrompt(visitor, prompt)
|
|
2894
|
+
: prompt;
|
|
2895
|
+
const throwIfAborted = () => {
|
|
2896
|
+
if (opts.signal?.aborted) throw new Error('superseded');
|
|
2897
|
+
};
|
|
2898
|
+
await ensureTranscriptHydrated(agentId, log);
|
|
2899
|
+
try { await reattachChrome({ log }); } catch (e) { log(`cursor-backend: chrome reattach ${e.message}`); }
|
|
2900
|
+
// A bot pinned to a video/image model: the prompt IS the render request.
|
|
2901
|
+
// A chat POST to ByteDance/Seedance-2.5 came back 502 with an empty body
|
|
2902
|
+
// and painted "(empty zoo reply)" — measured 2026-09-01.
|
|
2903
|
+
if (!chatOnly) {
|
|
2904
|
+
let mediaKind = null;
|
|
2905
|
+
try { mediaKind = mediaKindOf(await zooModelRow(model)); } catch { mediaKind = null; }
|
|
2906
|
+
if (mediaKind) return await mediaTurn({ model, kind: mediaKind, prompt: spoken, log, onProgress: opts.onProgress, signal: opts.signal });
|
|
2907
|
+
}
|
|
2908
|
+
log(`cursor-backend: zoo POST :8402 model=${model} helper=${helper ? localExecSse.size : 0} hist=${historyMessages(agentId, spoken).length}${chatOnly ? ` visitor=${visitor.shortname} chat-only` : ''} ${JSON.stringify((spoken || '').slice(0, 60))}`);
|
|
2909
|
+
if (typeof opts.onProgress === 'function') opts.onProgress('Working on your Mac…');
|
|
2910
|
+
|
|
2911
|
+
const images = [];
|
|
2912
|
+
const textFiles = [];
|
|
2913
|
+
for (const { raw, name } of attachmentList(parsed, prompt)) {
|
|
2914
|
+
const uploaded = lookupUpload(raw);
|
|
2915
|
+
if (uploaded) {
|
|
2916
|
+
if (uploaded.mime && uploaded.mime.startsWith('image/')) {
|
|
2917
|
+
images.push({
|
|
2918
|
+
path: raw,
|
|
2919
|
+
mime: uploaded.mime,
|
|
2920
|
+
dataUrl: `data:${uploaded.mime};base64,${uploaded.buf.toString('base64')}`,
|
|
2921
|
+
});
|
|
2922
|
+
log(`cursor-backend: attached image ${raw} ${uploaded.mime} ${uploaded.bytes}b`);
|
|
2923
|
+
} else {
|
|
2924
|
+
textFiles.push({ path: raw, abs: uploaded.abs, text: uploaded.buf.toString('utf8') });
|
|
2925
|
+
}
|
|
2926
|
+
continue;
|
|
2927
|
+
}
|
|
2928
|
+
// Public visitors must not trigger local file reads on this Mac.
|
|
2929
|
+
// Uploads above are bytes they posted to us, not a host path.
|
|
2930
|
+
if (chatOnly) continue;
|
|
2931
|
+
const abs = expandUserPath(raw);
|
|
2932
|
+
try {
|
|
2933
|
+
const buf = await readLocalBytes(abs, log);
|
|
2934
|
+
const mime = mimeFromBytes(buf, name || raw);
|
|
2935
|
+
if (mime) {
|
|
2936
|
+
images.push({ path: raw, mime, dataUrl: `data:${mime};base64,${buf.toString('base64')}` });
|
|
2937
|
+
log(`cursor-backend: attached image ${raw} ${mime} ${buf.length}b`);
|
|
2938
|
+
} else {
|
|
2939
|
+
textFiles.push({ path: raw, abs, text: buf.toString('utf8') });
|
|
2940
|
+
}
|
|
2941
|
+
} catch (e) {
|
|
2942
|
+
textFiles.push({ path: raw, abs, error: e.message });
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
for (const url of dataUrlsFromRichText(parsed.richText)) {
|
|
2946
|
+
images.push({ path: '(richText)', mime: 'image', dataUrl: url });
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
const via = helper ? 'Grok Bot Helper local-exec SSE' : 'this Mac (hijack process; Helper SSE not connected yet)';
|
|
2950
|
+
const messages = [
|
|
2951
|
+
{
|
|
2952
|
+
role: 'system',
|
|
2953
|
+
content: chatOnly
|
|
2954
|
+
? [
|
|
2955
|
+
`You are ${model} served through openzoo inside Grok Bot.`,
|
|
2956
|
+
'This is a public visitor chat. You do not have filesystem, shell, or local-exec access on the host Mac.',
|
|
2957
|
+
'Reply in chat only. Do not claim you will write files, run commands, or use local tools.',
|
|
2958
|
+
'Each human line is prefixed with that visitor\'s shortname and a colon, like "rex: hello". Different shortnames are different people. Address them by that name.',
|
|
2959
|
+
'Do not write spend totals, solscan links, memos, or ::oz-spend:: into the reply. The host paints a HUD pill.',
|
|
2960
|
+
'Prior turns of THIS Grok Bot chat are in the messages below. Do not claim the thread starts blank or that earlier questions did not arrive.',
|
|
2961
|
+
].join(' ')
|
|
2962
|
+
: [
|
|
2963
|
+
`You are ${model} served through openzoo inside Grok Bot.`,
|
|
2964
|
+
(() => {
|
|
2965
|
+
const me = (cachedAgentList() || []).find((a) => a.id === agentId);
|
|
2966
|
+
const brief = me ? agentBrief(me) : '';
|
|
2967
|
+
const who = me?.name ? `You are "${me.name}" in this Grok Bot sidebar.` : '';
|
|
2968
|
+
return brief ? `${who} Standing brief (persisted): ${brief}` : who;
|
|
2969
|
+
})(),
|
|
2970
|
+
`You HAVE local tools on the user's computer via ${via}.`,
|
|
2971
|
+
'Tools: read_file, write_file, exec, list_dir, screenshot, click, type_text, key, ui_tree, focus_app, open_url, create_agent, set_brief, list_agents, message_agent, schedule_wakeup, cancel_wakeup, ship_crew, ship_forge, ship_launch_worker, ship_status, ship_review, ship_open_pr, x_claim, x_done, x_release, x_claims, x_compose, x_open, x_close.',
|
|
2972
|
+
'Tabs: x_open {browser,url} FIRST — it pins a tab to you and every browser call you make is routed there. x_close at the end. Do not select_page other tabs.',
|
|
2973
|
+
'Posting on X: NEVER fill the reply box (newlines get flattened). Open the tweet, click the reply box, then x_compose {browser, text} which types line by line with Enter, then click Reply.',
|
|
2974
|
+
'X reply bots: x_claims first (skip those ids), x_claim the tweet BEFORE drafting (ok:false -> pick another), x_done with our reply URL after posting, x_release if you back out. Never reply to a tweet you did not claim.',
|
|
2975
|
+
'If no sidebar bot is named Firstmate and the human brings code or repo work, ask ONE question: which repo path to set up Grok Ship for. Then call ship_crew with that cwd. Do not start coding outside the factory.',
|
|
2976
|
+
'Grok Ship (software factory): ship_crew sets up Firstmate + a crewmate per repo. A crewmate ships with ship_launch_worker -> ship_status until pushed -> ship_review (fresh, diff-only) -> ship_open_pr only when gate.clean. Never merge; the human does.',
|
|
2977
|
+
(() => {
|
|
2978
|
+
const extra = hostMcpTools();
|
|
2979
|
+
const names = extra.map((t) => t.function.name);
|
|
2980
|
+
const servers = hostMcpServers();
|
|
2981
|
+
if (!names.length) {
|
|
2982
|
+
return 'Host MCP servers (Claude/Grok chrome-devtools, brave, …) are connecting. When chrome-devtools__* tools appear, use them for web pages.';
|
|
2983
|
+
}
|
|
2984
|
+
const chrome = names.filter((n) => /chrome|devtools|browser/i.test(n));
|
|
2985
|
+
const brave = names.some((n) => n.startsWith('brave-devtools__'));
|
|
2986
|
+
return [
|
|
2987
|
+
`Host MCP tools from the user's local Claude/Grok config are attached (${servers.join(', ') || 'mcp'}): ${names.slice(0, 36).join(', ')}${names.length > 36 ? '…' : ''}.`,
|
|
2988
|
+
chrome.length
|
|
2989
|
+
? `For any web page or HTML form, use chrome-devtools tools (navigate_page / take_snapshot / fill / click). Do NOT use osascript, Quartz, Python Foundation, or AppleScript to read Brave. screenshot/click/type_text are for native Mac UI only. On heavy pages (x.com, gmail, big SPAs) take_snapshot can hang: prefer evaluate_script that returns only the text you need, or take_screenshot; a tool that times out says so — do not repeat it. Do not re-run schedule_wakeup or list_agents every turn; once is enough. Browser mode: ${chromeStatus().mode}.${brave ? ' A SECOND real browser is attached: brave-devtools__* tools drive the human\'s logged-in Brave the same way; use whichever your brief names.' : ''}${chromeStatus().hint ? ` This Chrome is NOT the human's browser: a separate profile with no logins. If the human asks for their logged-in Chrome / Google / X / "the same chrome", do NOT open pages in this one and do NOT spawn anything — reply with exactly this and stop: ${chromeStatus().hint}` : ' This is the human\'s real logged-in browser; do not log out or change its settings.'}`
|
|
2990
|
+
: 'Use matching MCP tools instead of inventing shell one-liners.',
|
|
2991
|
+
].join(' ');
|
|
2992
|
+
})(),
|
|
2993
|
+
'create_agent mints a sidebar bot. ALWAYS pass brief so they keep the job across restart. set_brief updates it. list_agents + message_agent talk to other bots (one hop). Do not tell the human to copy-paste between canvases.',
|
|
2994
|
+
'NEVER STOP / cron / keep working between human messages: call schedule_wakeup every="5m". That is a host timer. There is no crontab. Do not spawn more bots for persistence. Do not re-read SITREP-NOW.md or STANDING-ORDERS.md every turn — do the next file or click. exec sysctl/uptime is not the job. If the tray already has named workers, answer "no" to spawning more.',
|
|
2995
|
+
'You CAN click the Mac and fill/submit browser forms. That is required when the user asks. Do not write a markdown briefing instead of clicking. Do not tell the user to click.',
|
|
2996
|
+
'Form loop: if chrome-devtools MCP tools exist, navigate_page + take_snapshot + fill the fields + click the submit button. Else fallback: focus_app Brave Browser → screenshot → click the field → type_text → screenshot → click Submit.',
|
|
2997
|
+
'screenshot captures the display and attaches the image. For any on-screen form, dashboard, or click target, screenshot first this turn. Do not guess at UI you have not seen.',
|
|
2998
|
+
'Pasted images arrive as attachments — you can see them when present. Do not claim you cannot see images if they are in this turn.',
|
|
2999
|
+
'Never claim you lack filesystem access or local-exec. If a tool errors, report the error.',
|
|
3000
|
+
'After using tools you MUST still write a normal chat reply: what you did, file paths written, and what to open. Empty content is a bug. A new user message cancels this turn — leave a visible reply before that happens.',
|
|
3001
|
+
'Do not stop mid-task. Do not write "Stopped on research", "nothing to open yet", "no app files written this turn", or "say go again". Keep using tools until the files the user asked for exist on disk THIS turn. Summarize only after those writes succeed.',
|
|
3002
|
+
'Do not write spend totals, solscan links, memos, or ::oz-spend:: into the reply. The host paints a HUD pill.',
|
|
3003
|
+
'The operator wallet pays every zoo call via x402 (TOKEN/USDC on Solana; SOL gas is sponsored). If the spend HUD is visible or a prior turn paid, the call WAS paid. Do not say you have no money, cannot afford tools, or need the human to fund you. A 402/underfunded tool error is a host retry — continue the job.',
|
|
3004
|
+
'Prior turns of THIS Grok Bot chat are in the messages below. Do not claim the thread starts blank or that earlier questions did not arrive.',
|
|
3005
|
+
].join(' '),
|
|
3006
|
+
},
|
|
3007
|
+
];
|
|
3008
|
+
for (const m of historyMessages(agentId, spoken)) messages.push(m);
|
|
3009
|
+
if (textFiles.length) {
|
|
3010
|
+
const bits = textFiles.map((a) => (
|
|
3011
|
+
a.error
|
|
3012
|
+
? `FILE ${a.path} ERROR: ${a.error}`
|
|
3013
|
+
: `FILE ${a.path} (${a.abs})\n${String(a.text).slice(0, 180000)}`
|
|
3014
|
+
));
|
|
3015
|
+
messages.push({ role: 'user', content: bits.join('\n\n') });
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
const userContent = [];
|
|
3019
|
+
for (const img of images) {
|
|
3020
|
+
userContent.push({ type: 'image_url', image_url: { url: img.dataUrl } });
|
|
3021
|
+
}
|
|
3022
|
+
userContent.push({ type: 'text', text: spoken || (images.length ? '(see attached image)' : 'hello') });
|
|
3023
|
+
messages.push({
|
|
3024
|
+
role: 'user',
|
|
3025
|
+
content: userContent.length === 1 && userContent[0].type === 'text'
|
|
3026
|
+
? userContent[0].text
|
|
3027
|
+
: userContent,
|
|
3028
|
+
});
|
|
3029
|
+
|
|
3030
|
+
// 8192 was a ceiling for one-line asks. A reasoning model spends most of it
|
|
3031
|
+
// thinking and returns nothing (finish=length, 0 visible — measured
|
|
3032
|
+
// 2026-09-02, seven times in a row). Default 32768, clamped to what the
|
|
3033
|
+
// catalog says this model can emit so no provider rejects the request.
|
|
3034
|
+
let maxTok = Number(process.env.OPENZOO_ASK_MAX_TOKENS || 32768);
|
|
3035
|
+
try {
|
|
3036
|
+
const row = await zooModelRow(model);
|
|
3037
|
+
const cap = Number(row?.max_output_tokens ?? row?.top_provider?.max_completion_tokens ?? 0);
|
|
3038
|
+
if (Number.isFinite(cap) && cap > 0 && cap < maxTok) maxTok = cap;
|
|
3039
|
+
} catch { /* catalog unreachable: keep the default */ }
|
|
3040
|
+
const maxSteps = Math.min(64, Math.max(8, Number(process.env.OPENZOO_ASK_TOOL_STEPS || 32)));
|
|
3041
|
+
let lastData = {};
|
|
3042
|
+
let turnX402 = {};
|
|
3043
|
+
let text = '';
|
|
3044
|
+
const usedTools = [];
|
|
3045
|
+
const KEEP_GOING = 'Do not stop. Do not wait for another message. Write the files to disk now with write_file / exec. Keep going until the requested paths exist. A summary is only allowed after the files are written.';
|
|
3046
|
+
const zooPost = async (payload) => {
|
|
3047
|
+
throwIfAborted();
|
|
3048
|
+
const ms = Number(process.env.OPENZOO_ASK_TIMEOUT_MS || 10 * 60_000);
|
|
3049
|
+
const post = () => fetch('http://127.0.0.1:8402/v1/chat/completions', {
|
|
3050
|
+
method: 'POST',
|
|
3051
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
|
|
3052
|
+
body: JSON.stringify(payload),
|
|
3053
|
+
signal: combinedAbortSignal(opts.signal, AbortSignal.timeout(ms)),
|
|
3054
|
+
});
|
|
3055
|
+
let r;
|
|
3056
|
+
let lastErr;
|
|
3057
|
+
const tries = 5;
|
|
3058
|
+
for (let attempt = 1; attempt <= tries; attempt++) {
|
|
3059
|
+
try {
|
|
3060
|
+
r = await post();
|
|
3061
|
+
lastErr = null;
|
|
3062
|
+
break;
|
|
3063
|
+
} catch (e) {
|
|
3064
|
+
lastErr = e;
|
|
3065
|
+
log(`cursor-backend: zoo POST attempt=${attempt} ${e.message}`);
|
|
3066
|
+
// A superseded turn must not spend four more retries on a dead signal.
|
|
3067
|
+
if (opts.signal?.aborted || isSupersededError(e)) throw e;
|
|
3068
|
+
if (attempt === tries) throw e;
|
|
3069
|
+
const abortish = /abort|timeout/i.test(String(e?.name || '') + String(e?.message || ''));
|
|
3070
|
+
await new Promise((ok) => setTimeout(ok, (abortish ? 2000 : 800) * attempt));
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
if (!r) throw lastErr || new Error('zoo POST failed');
|
|
3074
|
+
if (r.status === 402) {
|
|
3075
|
+
for (let i = 0; i < 4 && r && r.status === 402; i++) {
|
|
3076
|
+
log(`cursor-backend: x402 402 — dwell/retry ${i + 1}/4`);
|
|
3077
|
+
await new Promise((ok) => setTimeout(ok, 1500 * (i + 1)));
|
|
3078
|
+
try { r = await post(); } catch (e) { log(`cursor-backend: zoo POST after 402 ${e.message}`); }
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
const data = await r.json().catch(() => ({}));
|
|
3082
|
+
// EVIDENCE FOR THE NEXT DEBUG: the exact body behind a non-200, on disk.
|
|
3083
|
+
// "Invalid model provider request." reproduced with no probe until the
|
|
3084
|
+
// real transcript could be replayed.
|
|
3085
|
+
if (r.status !== 200) {
|
|
3086
|
+
try {
|
|
3087
|
+
fs.writeFileSync(path.join(os.homedir(), '.openzoo', 'zoo-last-error-body.json'), JSON.stringify({ at: new Date().toISOString(), status: r.status, agentId, response: data, payload }, null, 1));
|
|
3088
|
+
} catch { /* */ }
|
|
3089
|
+
}
|
|
3090
|
+
if (r.status === 402) {
|
|
3091
|
+
const raw = zooTextFromMessage(data?.choices?.[0]?.message, data)
|
|
3092
|
+
|| data?.error?.message
|
|
3093
|
+
|| 'openzoo payment required (HTTP 402).';
|
|
3094
|
+
data.error = data.error || {};
|
|
3095
|
+
data.error.message = raw;
|
|
3096
|
+
try {
|
|
3097
|
+
const { withOnrampLink, isFundInstruction } = await import('./stripeOnramp.js');
|
|
3098
|
+
// Settle/upstream 402s keep the real message. Whop copy-paste only
|
|
3099
|
+
// when this is a genuine empty-wallet / fund-me instruction.
|
|
3100
|
+
if (isFundInstruction(raw)) {
|
|
3101
|
+
const { loadOrCreateWallet } = await import('./wallet.js');
|
|
3102
|
+
const w = loadOrCreateWallet();
|
|
3103
|
+
const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
|
|
3104
|
+
data.error.message = await withOnrampLink(raw, {
|
|
3105
|
+
solana: w.keypair.publicKey.toBase58(),
|
|
3106
|
+
usd,
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
} catch { /* keep proxy copy */ }
|
|
3110
|
+
}
|
|
3111
|
+
return { r, data };
|
|
3112
|
+
};
|
|
3113
|
+
let keepGoingNudge = 0;
|
|
3114
|
+
const pendingVision = [];
|
|
3115
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
3116
|
+
throwIfAborted();
|
|
3117
|
+
const payload = { model, messages, max_tokens: maxTok };
|
|
3118
|
+
if (!chatOnly) {
|
|
3119
|
+
payload.tools = liveTools();
|
|
3120
|
+
payload.tool_choice = 'auto';
|
|
3121
|
+
}
|
|
3122
|
+
const { r, data } = await zooPost(payload);
|
|
3123
|
+
lastData = data;
|
|
3124
|
+
turnX402 = mergeTurnProof(turnX402, data);
|
|
3125
|
+
const msg = data.choices?.[0]?.message || {};
|
|
3126
|
+
const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
3127
|
+
if (calls.length) {
|
|
3128
|
+
if (chatOnly) {
|
|
3129
|
+
log(`cursor-backend: visitor chat-only ignored ${calls.length} tool calls`);
|
|
3130
|
+
messages.push(msg);
|
|
3131
|
+
for (const c of calls) {
|
|
3132
|
+
messages.push({
|
|
3133
|
+
role: 'tool',
|
|
3134
|
+
tool_call_id: c.id,
|
|
3135
|
+
content: 'local tools are not available to web visitors',
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
3138
|
+
continue;
|
|
3139
|
+
}
|
|
3140
|
+
const names = calls.map((c) => c.function?.name || c.name);
|
|
3141
|
+
log(`cursor-backend: zoo tools step=${step} n=${calls.length} ${names.join(',')}`);
|
|
3142
|
+
messages.push(msg);
|
|
3143
|
+
for (const c of calls) {
|
|
3144
|
+
throwIfAborted();
|
|
3145
|
+
let args = {};
|
|
3146
|
+
try { args = JSON.parse(c.function?.arguments || c.arguments || '{}'); } catch { args = {}; }
|
|
3147
|
+
const name = c.function?.name || c.name || '';
|
|
3148
|
+
let result = await runLocalTool(name, args, log, { agentId, parsed });
|
|
3149
|
+
if (name === 'screenshot') {
|
|
3150
|
+
try {
|
|
3151
|
+
const shot = JSON.parse(result);
|
|
3152
|
+
if (shot?.dataUrl) {
|
|
3153
|
+
pendingVision.push({ path: shot.path, dataUrl: shot.dataUrl });
|
|
3154
|
+
result = `screenshot ${shot.path} ${shot.bytes}b — image attached, look at it before acting`;
|
|
3155
|
+
}
|
|
3156
|
+
} catch { /* keep raw */ }
|
|
3157
|
+
}
|
|
3158
|
+
usedTools.push({
|
|
3159
|
+
name,
|
|
3160
|
+
path: args.path,
|
|
3161
|
+
command: args.command ? String(args.command).slice(0, 120) : undefined,
|
|
3162
|
+
note: String(result).slice(0, 200),
|
|
3163
|
+
});
|
|
3164
|
+
if (typeof opts.onProgress === 'function') {
|
|
3165
|
+
opts.onProgress(formatZooToolLine({ name, args, result }));
|
|
3166
|
+
}
|
|
3167
|
+
messages.push({
|
|
3168
|
+
role: 'tool',
|
|
3169
|
+
tool_call_id: c.id,
|
|
3170
|
+
content: String(result).slice(0, 120000),
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
if (pendingVision.length) {
|
|
3174
|
+
messages.push({
|
|
3175
|
+
role: 'user',
|
|
3176
|
+
content: [
|
|
3177
|
+
...pendingVision.map((img) => ({ type: 'image_url', image_url: { url: img.dataUrl } })),
|
|
3178
|
+
{ type: 'text', text: pendingVision.map((img) => `screenshot: ${img.path}`).join('\n') },
|
|
3179
|
+
],
|
|
3180
|
+
});
|
|
3181
|
+
pendingVision.length = 0;
|
|
3182
|
+
}
|
|
3183
|
+
continue;
|
|
3184
|
+
}
|
|
3185
|
+
text = zooTextFromMessage(msg, data);
|
|
3186
|
+
const finish = data.choices?.[0]?.finish_reason || '?';
|
|
3187
|
+
log(`cursor-backend: << zoo ${r.status} ${text.length}c model=${data.model || model} finish=${finish}`);
|
|
3188
|
+
// Model likes to park after research ("Stopped on research", "say go again")
|
|
3189
|
+
// instead of writing files. Measured 2026-08-30 on volume track00r.
|
|
3190
|
+
if (!chatOnly && /wallet underfunded|payment did not settle|needs more than the wallet holds/i.test(text) && keepGoingNudge < 6) {
|
|
3191
|
+
keepGoingNudge += 1;
|
|
3192
|
+
log(`cursor-backend: underfunded-nudge=${keepGoingNudge} — not painting broke`);
|
|
3193
|
+
messages.push(msg);
|
|
3194
|
+
messages.push({
|
|
3195
|
+
role: 'user',
|
|
3196
|
+
content: 'That 402 is a host retry, not an empty wallet. The operator pays x402. Continue the job with tools. Do not discuss funding or ask the human for money.',
|
|
3197
|
+
});
|
|
3198
|
+
continue;
|
|
3199
|
+
}
|
|
3200
|
+
// finish=length with NOTHING visible is not "parked": the model burned
|
|
3201
|
+
// its whole budget and said nothing. Re-POSTing bought the same nothing
|
|
3202
|
+
// seven times ($0.2–0.46 each, measured 2026-09-02). One attempt, then
|
|
3203
|
+
// say so.
|
|
3204
|
+
if (!chatOnly && finish === 'length' && !String(text || '').trim()) {
|
|
3205
|
+
log('cursor-backend: empty finish=length — not re-buying');
|
|
3206
|
+
try {
|
|
3207
|
+
fs.writeFileSync(path.join(os.homedir(), '.openzoo', 'zoo-last-empty-body.json'), JSON.stringify({ at: new Date().toISOString(), agentId, model, response: data, payload: { ...messages.slice(-3) } }, null, 1));
|
|
3208
|
+
} catch { /* */ }
|
|
3209
|
+
text = `${model} used its whole ${maxTok}-token budget and produced no visible text (finish=length, ${Number(data?.usage?.completion_tokens || 0)} completion tokens). Try /model grok-4.6 or shorten the ask.`;
|
|
3210
|
+
break;
|
|
3211
|
+
}
|
|
3212
|
+
if (!chatOnly && (finish === 'length' || looksStoppedReply(text)) && keepGoingNudge < 6) {
|
|
3213
|
+
keepGoingNudge += 1;
|
|
3214
|
+
log(`cursor-backend: keep-going nudge=${keepGoingNudge} finish=${finish}`);
|
|
3215
|
+
messages.push(msg);
|
|
3216
|
+
messages.push({ role: 'user', content: KEEP_GOING });
|
|
3217
|
+
continue;
|
|
3218
|
+
}
|
|
3219
|
+
break;
|
|
3220
|
+
}
|
|
3221
|
+
if (usedTools.length && !String(text || '').trim()) {
|
|
3222
|
+
const wrote = usedTools.some((t) => t.name === 'write_file');
|
|
3223
|
+
messages.push({
|
|
3224
|
+
role: 'user',
|
|
3225
|
+
content: wrote
|
|
3226
|
+
? 'Write a short chat reply: files written (full paths), commands that mattered, and what the user should open. No empty message.'
|
|
3227
|
+
: KEEP_GOING,
|
|
3228
|
+
});
|
|
3229
|
+
const { r, data } = await zooPost({
|
|
3230
|
+
model,
|
|
3231
|
+
messages,
|
|
3232
|
+
max_tokens: Math.max(800, Math.min(maxTok, 2048)),
|
|
3233
|
+
});
|
|
3234
|
+
lastData = data;
|
|
3235
|
+
turnX402 = mergeTurnProof(turnX402, data);
|
|
3236
|
+
text = zooTextFromMessage(data.choices?.[0]?.message, data);
|
|
3237
|
+
log(`cursor-backend: << zoo summary ${r.status} ${text.length}c`);
|
|
3238
|
+
}
|
|
3239
|
+
if (!String(text || '').trim()) {
|
|
3240
|
+
if (usedTools.length) {
|
|
3241
|
+
const writes = usedTools.filter((t) => t.name === 'write_file' && t.path).map((t) => t.path);
|
|
3242
|
+
const execs = usedTools.filter((t) => t.name === 'exec' && t.command).map((t) => t.command);
|
|
3243
|
+
const lines = ['Did local work (no model chat text came back):'];
|
|
3244
|
+
if (writes.length) lines.push(`wrote: ${[...new Set(writes)].join(', ')}`);
|
|
3245
|
+
if (execs.length) lines.push(`ran: ${execs.slice(-6).join(' · ')}`);
|
|
3246
|
+
if (lines.length === 1) lines.push(`${usedTools.length} tool calls.`);
|
|
3247
|
+
text = lines.join('\n');
|
|
3248
|
+
} else {
|
|
3249
|
+
text = '(empty zoo reply)';
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
if (lastData && typeof lastData === 'object') {
|
|
3253
|
+
lastData.x402 = mergeTurnProof(turnX402, lastData);
|
|
3254
|
+
}
|
|
3255
|
+
try { await zooSpendOverlay(lastData); } catch { /* HUD dump; never append to the reply */ }
|
|
3256
|
+
return { text, data: lastData };
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
async function handleHijackedPodHttp(req, res, full, body, log) {
|
|
3260
|
+
if (sniffOn()) {
|
|
3261
|
+
const waiting = (full || '').split('?')[0];
|
|
3262
|
+
const podPath = waiting === '/health' || waiting === '/healthz' || waiting === '/events'
|
|
3263
|
+
|| waiting.startsWith('/api/') || waiting.startsWith('/webauthn/')
|
|
3264
|
+
|| waiting.startsWith('/cookie-origin-approval/');
|
|
3265
|
+
if (waiting.startsWith('/local-exec/')) return handleLocalExecHttp(req, res, waiting, body, log);
|
|
3266
|
+
if (realPod?.agent && podPath) return proxyPodHttp(req, res, full, body, log);
|
|
3267
|
+
if (!podPath) return false;
|
|
3268
|
+
if (waiting === '/health' || waiting === '/healthz') {
|
|
3269
|
+
jsonSend(res, { ok: true, status: 'ok', ready: true });
|
|
3270
|
+
return true;
|
|
3271
|
+
}
|
|
3272
|
+
return false;
|
|
3273
|
+
}
|
|
3274
|
+
const path0 = (full || '').split('?')[0];
|
|
3275
|
+
if (path0 === '/health' || path0 === '/healthz') {
|
|
3276
|
+
jsonSend(res, { ok: true, status: 'ok', ready: true });
|
|
3277
|
+
log('cursor-backend: -> pod /health ok');
|
|
3278
|
+
return true;
|
|
3279
|
+
}
|
|
3280
|
+
if (path0 === '/events') {
|
|
3281
|
+
res.writeHead(200, {
|
|
3282
|
+
'content-type': 'text/event-stream',
|
|
3283
|
+
'cache-control': 'no-cache',
|
|
3284
|
+
...CORS,
|
|
3285
|
+
});
|
|
3286
|
+
res.write('data: {"channel":"ping","payload":{}}\n\n');
|
|
3287
|
+
sseClients.add(res);
|
|
3288
|
+
try {
|
|
3289
|
+
const list = rosterForEvent(loadAgents() || [], agentActivity);
|
|
3290
|
+
const active = topConversationId(list);
|
|
3291
|
+
if (active) {
|
|
3292
|
+
focusedAgentId = focusedAgentId || active;
|
|
3293
|
+
res.write(`data: ${JSON.stringify({
|
|
3294
|
+
channel: 'agents',
|
|
3295
|
+
payload: {
|
|
3296
|
+
agents: list.map((a) => ({ ...a, isActive: a.id === active })),
|
|
3297
|
+
activeAgentId: active,
|
|
3298
|
+
},
|
|
3299
|
+
})}\n\n`);
|
|
3300
|
+
}
|
|
3301
|
+
} catch { /* first paint must not break SSE */ }
|
|
3302
|
+
const iv = setInterval(() => {
|
|
3303
|
+
try { res.write('data: {"channel":"ping","payload":{}}\n\n'); } catch { clearInterval(iv); sseClients.delete(res); }
|
|
3304
|
+
}, 15000);
|
|
3305
|
+
req.on('close', () => { clearInterval(iv); sseClients.delete(res); });
|
|
3306
|
+
log('cursor-backend: -> pod /events sse');
|
|
3307
|
+
return true;
|
|
3308
|
+
}
|
|
3309
|
+
if (path0.startsWith('/webauthn/') || path0.startsWith('/cookie-origin-approval/')) {
|
|
3310
|
+
if (req.method === 'GET') jsonSend(res, []);
|
|
3311
|
+
else jsonSend(res, { ok: true });
|
|
3312
|
+
log(`cursor-backend: -> pod ${path0}`);
|
|
3313
|
+
return true;
|
|
3314
|
+
}
|
|
3315
|
+
if (!path0.startsWith('/api/')) return false;
|
|
3316
|
+
const name = path0.slice('/api/'.length);
|
|
3317
|
+
// Revive: the minutely cron (scripts/revive-bots.sh) calls this after it has
|
|
3318
|
+
// made sure the hijack + app are alive. Every non-group bot gets a wakeup
|
|
3319
|
+
// timer if it lost one; nothing is spawned, nothing already armed is touched.
|
|
3320
|
+
if (name === 'ozSpend') {
|
|
3321
|
+
let state = { spent: 0, would: 0, saved: 0, pct: 0, label: '' };
|
|
3322
|
+
try { state = await zooSpendOverlay(null); } catch { /* */ }
|
|
3323
|
+
jsonSend(res, { ok: true, ...state });
|
|
3324
|
+
return true;
|
|
3325
|
+
}
|
|
3326
|
+
if (name === 'ozRevive') {
|
|
3327
|
+
let parsed = {};
|
|
3328
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3329
|
+
const every = String(parsed.every || '5m');
|
|
3330
|
+
const wakes = readWakeups(HOME);
|
|
3331
|
+
const list = (cachedAgentList() || []).filter((a) => a?.id && !a.isGroup);
|
|
3332
|
+
const armed = [];
|
|
3333
|
+
const already = [];
|
|
3334
|
+
for (const a of list) {
|
|
3335
|
+
if (wakes[a.id]) { already.push(a.id); continue; }
|
|
3336
|
+
try {
|
|
3337
|
+
scheduleAgentWakeup(a.id, { every, prompt: parsed.prompt });
|
|
3338
|
+
armed.push({ id: a.id, name: a.name });
|
|
3339
|
+
} catch (e) {
|
|
3340
|
+
log(`cursor-backend: ozRevive ${a.id} ${e.message}`);
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
log(`cursor-backend: ozRevive total=${list.length} armed=${armed.length} already=${already.length} every=${every}`);
|
|
3344
|
+
jsonSend(res, { ok: true, total: list.length, armed, already: already.length, every });
|
|
3345
|
+
return true;
|
|
3346
|
+
}
|
|
3347
|
+
if (name === 'getHostStatus') {
|
|
3348
|
+
jsonSend(res, { status: 'ready', ready: true, state: 'ready', hostStatus: 'ready' });
|
|
3349
|
+
log('cursor-backend: -> getHostStatus ready');
|
|
3350
|
+
return true;
|
|
3351
|
+
}
|
|
3352
|
+
if (name === 'uploadAttachment' || name === 'readAttachmentImage'
|
|
3353
|
+
|| name === 'readAttachmentText' || name === 'readAttachmentChunk') {
|
|
3354
|
+
let parsed = {};
|
|
3355
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3356
|
+
if (name === 'uploadAttachment') {
|
|
3357
|
+
const got = ingestUpload({
|
|
3358
|
+
filename: parsed.filename,
|
|
3359
|
+
bytesBase64: parsed.bytesBase64,
|
|
3360
|
+
});
|
|
3361
|
+
if (!got.ok) {
|
|
3362
|
+
jsonSend(res, { status: 'ok', value: null, ok: false, reason: got.reason });
|
|
3363
|
+
log(`cursor-backend: uploadAttachment ${got.reason || 'failed'}`);
|
|
3364
|
+
return true;
|
|
3365
|
+
}
|
|
3366
|
+
// Dual shape: sendPrompt-style raw `.path` AND CVr `{status,value}`.
|
|
3367
|
+
jsonSend(res, { status: 'ok', value: { path: got.path }, path: got.path, ok: true });
|
|
3368
|
+
log(`cursor-backend: uploadAttachment ${got.path} ${got.bytes}b ${got.mime}`);
|
|
3369
|
+
return true;
|
|
3370
|
+
}
|
|
3371
|
+
if (name === 'readAttachmentChunk') {
|
|
3372
|
+
const chunk = readUploadChunk({
|
|
3373
|
+
path: parsed.path,
|
|
3374
|
+
offset: parsed.offset,
|
|
3375
|
+
length: parsed.length,
|
|
3376
|
+
});
|
|
3377
|
+
jsonSend(res, chunk
|
|
3378
|
+
? { status: 'ok', value: chunk, ...chunk }
|
|
3379
|
+
: { status: 'ok', value: null });
|
|
3380
|
+
log(`cursor-backend: readAttachmentChunk ${parsed.path || ''} ${chunk ? chunk.totalSize : 'miss'}b`);
|
|
3381
|
+
return true;
|
|
3382
|
+
}
|
|
3383
|
+
if (name === 'readAttachmentImage') {
|
|
3384
|
+
const img = readUploadImage(parsed.path);
|
|
3385
|
+
jsonSend(res, img ? { status: 'ok', value: img, ...img } : { status: 'ok', value: null });
|
|
3386
|
+
return true;
|
|
3387
|
+
}
|
|
3388
|
+
const text = readUploadText(parsed.path);
|
|
3389
|
+
jsonSend(res, text ? { status: 'ok', value: text, ...text } : { status: 'ok', value: null });
|
|
3390
|
+
return true;
|
|
3391
|
+
}
|
|
3392
|
+
// Roster/settings come from the REAL 1340 gateway (names, avatars, trays).
|
|
3393
|
+
// Chat stays local so inference is zoo. Discovered on EnsureSandBox rewrite.
|
|
3394
|
+
const roster = new Set([
|
|
3395
|
+
'listAgents', 'countAgents', 'searchAgents', 'getTrays', 'getHostSettings',
|
|
3396
|
+
'setHostSettings', 'getAgentChannels', 'getAgentWorkflows', 'skillsCatalog',
|
|
3397
|
+
'getSubagents', 'getAsyncTasks', 'getForeverBoxStatus', 'getSharingState',
|
|
3398
|
+
'getBotTemplateExportPolicy', 'getTeachRecordingStatus', 'isGlobalSearchEnabled',
|
|
3399
|
+
'isEgressTunnelAvailable', 'listBoxMcpServers',
|
|
3400
|
+
'setWindowFocused', 'getAgentAutomations',
|
|
3401
|
+
'requestDiskSaverAudit',
|
|
3402
|
+
'setAgentUnread', 'setAgentHiddenFromSidebar', 'setAgentNotificationsEnabled',
|
|
3403
|
+
'setAgentNotifyOnUpdates', 'setAgentAvatarBytes', 'getAgentAvatar',
|
|
3404
|
+
]);
|
|
3405
|
+
// create/delete stay LOCAL — 1340 createAgent 401s on a stale cached token
|
|
3406
|
+
// and the UI then never grows a sidebar row or clears the canvas.
|
|
3407
|
+
if (name === 'createAgent' || name === 'createAgentFromTemplate' || name === 'duplicateAgent') {
|
|
3408
|
+
let parsed = {};
|
|
3409
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3410
|
+
if (name === 'duplicateAgent' && parsed.id) {
|
|
3411
|
+
const src = (cachedAgentList() || []).find((a) => a.id === parsed.id) || {};
|
|
3412
|
+
parsed = { ...src, id: undefined, name: `${src.name || 'chat'} copy` };
|
|
3413
|
+
}
|
|
3414
|
+
const agent = mintLocalAgent(parsed);
|
|
3415
|
+
jsonSend(res, { agent, id: agent.id, ...agent });
|
|
3416
|
+
pushCreatedAgent(agent);
|
|
3417
|
+
log(`cursor-backend: createAgent local id=${agent.id} name=${JSON.stringify(agent.name)}`);
|
|
3418
|
+
return true;
|
|
3419
|
+
}
|
|
3420
|
+
if (name === 'interruptAgentRun') {
|
|
3421
|
+
let parsed = {};
|
|
3422
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3423
|
+
const id = String(parsed.id || parsed.agentId || focusedAgentId || '');
|
|
3424
|
+
const n = id ? zooTurns.abort(id) : 0;
|
|
3425
|
+
jsonSend(res, { ok: true, interrupted: n, id });
|
|
3426
|
+
log(`cursor-backend: interruptAgentRun id=${id || '?'} n=${n}`);
|
|
3427
|
+
return true;
|
|
3428
|
+
}
|
|
3429
|
+
if (name === 'kickstartAgent') {
|
|
3430
|
+
let parsed = {};
|
|
3431
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3432
|
+
const id = String(parsed.id || parsed.agentId || '');
|
|
3433
|
+
jsonSend(res, { id, isIntroductionInFlight: false });
|
|
3434
|
+
log(`cursor-backend: kickstartAgent id=${id || '?'}`);
|
|
3435
|
+
return true;
|
|
3436
|
+
}
|
|
3437
|
+
if (name === 'createGroup') {
|
|
3438
|
+
let parsed = {};
|
|
3439
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3440
|
+
const memberIds = [].concat(parsed.memberAgentIds || parsed.memberIds || []).map(String).filter(Boolean);
|
|
3441
|
+
const agent = mintLocalAgent({
|
|
3442
|
+
...parsed,
|
|
3443
|
+
isGroup: true,
|
|
3444
|
+
memberIds,
|
|
3445
|
+
name: parsed.name || parsed.title || 'group',
|
|
3446
|
+
});
|
|
3447
|
+
jsonSend(res, { agent, id: agent.id, ...agent });
|
|
3448
|
+
pushCreatedAgent(agent);
|
|
3449
|
+
log(`cursor-backend: createGroup local id=${agent.id} members=${memberIds.length} name=${JSON.stringify(agent.name)}`);
|
|
3450
|
+
return true;
|
|
3451
|
+
}
|
|
3452
|
+
if (name === 'setGroupMembers') {
|
|
3453
|
+
let parsed = {};
|
|
3454
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3455
|
+
const id = String(parsed.id || parsed.agentId || '');
|
|
3456
|
+
const memberIds = [].concat(parsed.memberAgentIds || parsed.memberIds || []).map(String).filter(Boolean);
|
|
3457
|
+
const list = cachedAgentList() || [];
|
|
3458
|
+
const i = list.findIndex((a) => a.id === id);
|
|
3459
|
+
if (!id || i < 0) {
|
|
3460
|
+
jsonSend(res, null);
|
|
3461
|
+
log(`cursor-backend: setGroupMembers miss id=${id || '?'}`);
|
|
3462
|
+
return true;
|
|
3463
|
+
}
|
|
3464
|
+
const agent = shapeAgent({ ...list[i], isGroup: true, memberIds, updatedAt: Date.now() });
|
|
3465
|
+
list[i] = agent;
|
|
3466
|
+
saveAgents(list.map(shapeAgent));
|
|
3467
|
+
jsonSend(res, { agent, ...agent });
|
|
3468
|
+
ssePush('agent-upserted', { agent, activeAgentId: focusedAgentId || id });
|
|
3469
|
+
ssePush('agents', { agents: rosterForEvent(list, agentActivity), activeAgentId: focusedAgentId || id });
|
|
3470
|
+
log(`cursor-backend: setGroupMembers id=${id} n=${memberIds.length}`);
|
|
3471
|
+
return true;
|
|
3472
|
+
}
|
|
3473
|
+
if (name === 'broadcastToAgents') {
|
|
3474
|
+
let parsed = {};
|
|
3475
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3476
|
+
const ids = [].concat(parsed.ids || parsed.agentIds || []).map(String).filter(Boolean);
|
|
3477
|
+
const prompt = String(parsed.prompt || parsed.text || parsed.message || '').trim();
|
|
3478
|
+
jsonSend(res, { ok: true, accepted: true, ids });
|
|
3479
|
+
const target = ids[0] && groupMemberIds(ids[0]).length ? ids[0] : (focusedAgentId || ids[0]);
|
|
3480
|
+
if (target && prompt) {
|
|
3481
|
+
const nonce = parsed.clientNonce || `oz-bc-${Date.now()}`;
|
|
3482
|
+
setImmediate(() => {
|
|
3483
|
+
runGroupQueue({
|
|
3484
|
+
agentId: target,
|
|
3485
|
+
humanPrompt: prompt,
|
|
3486
|
+
parsed,
|
|
3487
|
+
nonce,
|
|
3488
|
+
log,
|
|
3489
|
+
}).catch((e) => log(`cursor-backend: broadcast queue ${e.message}`));
|
|
3490
|
+
});
|
|
3491
|
+
}
|
|
3492
|
+
log(`cursor-backend: broadcastToAgents ids=${ids.length} target=${target || 'none'}`);
|
|
3493
|
+
return true;
|
|
3494
|
+
}
|
|
3495
|
+
if (name === 'updateAgent') {
|
|
3496
|
+
let parsed = {};
|
|
3497
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3498
|
+
const agent = mintLocalAgent(parsed);
|
|
3499
|
+
jsonSend(res, { agent, ...agent });
|
|
3500
|
+
pushCreatedAgent(agent, { select: false });
|
|
3501
|
+
log(`cursor-backend: updateAgent local id=${agent.id}`);
|
|
3502
|
+
return true;
|
|
3503
|
+
}
|
|
3504
|
+
if (name === 'setAgentUnread') {
|
|
3505
|
+
let parsed = {};
|
|
3506
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3507
|
+
const id = String(parsed.id || '');
|
|
3508
|
+
const unread = parsed.isUnread === true;
|
|
3509
|
+
if (id) {
|
|
3510
|
+
const a = agentActivity.get(id) || {};
|
|
3511
|
+
a.hasUnread = unread;
|
|
3512
|
+
a.unreadCount = unread ? Math.max(1, a.unreadCount || 1) : 0;
|
|
3513
|
+
agentActivity.set(id, a);
|
|
3514
|
+
if (!unread) noteFocus(id);
|
|
3515
|
+
}
|
|
3516
|
+
jsonSend(res, { ok: true });
|
|
3517
|
+
return true;
|
|
3518
|
+
}
|
|
3519
|
+
if (name === 'deleteAgents') {
|
|
3520
|
+
let parsed = {};
|
|
3521
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3522
|
+
const ids = new Set([].concat(parsed.ids || parsed.id || []).map(String));
|
|
3523
|
+
const next = (cachedAgentList() || []).filter((a) => !ids.has(a.id));
|
|
3524
|
+
addDeletedIds(HOME, [...ids]);
|
|
3525
|
+
saveAgents(next);
|
|
3526
|
+
for (const id of ids) {
|
|
3527
|
+
transcripts.delete(id);
|
|
3528
|
+
try { cancelAgentWakeup(id); } catch { /* */ }
|
|
3529
|
+
}
|
|
3530
|
+
jsonSend(res, { ok: true, deleted: [...ids] });
|
|
3531
|
+
log(`cursor-backend: deleteAgents n=${ids.size}`);
|
|
3532
|
+
return true;
|
|
3533
|
+
}
|
|
3534
|
+
if (name === 'listAgents' || name === 'getTrays' || name === 'countAgents' || name === 'searchAgents') {
|
|
3535
|
+
if (!sniffOn() && !useHouseRoster()) {
|
|
3536
|
+
const pod = await waitForAccountPod(log);
|
|
3537
|
+
if (pod?.agent) {
|
|
3538
|
+
const proxied = await proxyPodHttp(req, res, full, body, log);
|
|
3539
|
+
if (proxied) return true;
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
if (name === 'getTrays') {
|
|
3543
|
+
jsonSend(res, []);
|
|
3544
|
+
return true;
|
|
3545
|
+
}
|
|
3546
|
+
if (name === 'listAgents' || name === 'countAgents' || name === 'searchAgents') {
|
|
3547
|
+
const list = rosterForEvent(mergeAgentLists([]), agentActivity);
|
|
3548
|
+
if (name === 'countAgents') {
|
|
3549
|
+
jsonSend(res, list.length);
|
|
3550
|
+
log(`cursor-backend: countAgents local n=${list.length} account=${activeAccountId || 'none'}`);
|
|
3551
|
+
return true;
|
|
3552
|
+
}
|
|
3553
|
+
if (name === 'searchAgents') {
|
|
3554
|
+
let parsed = {};
|
|
3555
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3556
|
+
const q = String(parsed.query || parsed.q || '').toLowerCase();
|
|
3557
|
+
const out = !q ? list : list.filter((a) => String(a?.name || a?.title || a?.id || '').toLowerCase().includes(q));
|
|
3558
|
+
jsonSend(res, out);
|
|
3559
|
+
log(`cursor-backend: searchAgents local n=${out.length}/${list.length} account=${activeAccountId || 'none'}`);
|
|
3560
|
+
return true;
|
|
3561
|
+
}
|
|
3562
|
+
const active = topConversationId(list);
|
|
3563
|
+
if (active && !focusedAgentId) focusedAgentId = active;
|
|
3564
|
+
jsonSend(res, list.map((a) => ({ ...a, isActive: !!(active && a.id === active) })));
|
|
3565
|
+
if (active) {
|
|
3566
|
+
ssePush('agents', {
|
|
3567
|
+
agents: list.map((a) => ({ ...a, isActive: a.id === active })),
|
|
3568
|
+
activeAgentId: active,
|
|
3569
|
+
});
|
|
3570
|
+
}
|
|
3571
|
+
if (seedShipNudge(list, active)) log(`cursor-backend: ship nudge painted on ${active}`);
|
|
3572
|
+
if (active && !payNudged) setTimeout(() => { seedPayNudge(list, active, log).catch(() => {}); }, 4000);
|
|
3573
|
+
log(`cursor-backend: listAgents local n=${list.length} account=${activeAccountId || 'none'} active=${active || 'none'}`);
|
|
3574
|
+
return true;
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
if (!sniffOn() && realPod?.agent && roster.has(name)) {
|
|
3579
|
+
return proxyPodHttp(req, res, full, body, log);
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
// THE ACTUAL CHAT PATH. Grok Bot does not send StreamUnifiedChat on this
|
|
3583
|
+
// surface — measured: POST /api/sendPrompt 374b/462b after EnsureSandBox
|
|
3584
|
+
// hijack. Stubbing {ok:true} ate the prompt. Forward to the paying proxy.
|
|
3585
|
+
if (name === 'sendPrompt') {
|
|
3586
|
+
let parsed = {};
|
|
3587
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3588
|
+
const prompt = stripVisitorLabel(promptFromSendBody(parsed));
|
|
3589
|
+
const visitor = visitorFromSend(parsed);
|
|
3590
|
+
const agentId = String(parsed.agentId || parsed.id || 'openzoo');
|
|
3591
|
+
const nonce = parsed.clientNonce || `oz-${Date.now()}`;
|
|
3592
|
+
const attN = Array.isArray(parsed.attachmentPaths) ? parsed.attachmentPaths.length : 0;
|
|
3593
|
+
const uiText = visitor ? labeledVisitorPrompt(visitor, prompt) : prompt;
|
|
3594
|
+
log(`cursor-backend: >> sendPrompt agent=${agentId} keys=${Object.keys(parsed).join(',')} attachments=${attN}${visitor ? ` visitor=${visitor.shortname}` : ''} prompt=${JSON.stringify((prompt || '').slice(0, 80))}`);
|
|
3595
|
+
lastSendEchoId = String(nonce);
|
|
3596
|
+
jsonSend(res, { accepted: true });
|
|
3597
|
+
if (!visitor && wantsWakeupCron(prompt)) {
|
|
3598
|
+
const cron = scheduleAgentWakeup(agentId, { every: '5m' });
|
|
3599
|
+
log(`cursor-backend: never-stop cron agent=${agentId} every=${cron.everySec}s`);
|
|
3600
|
+
}
|
|
3601
|
+
const turn = zooTurns.begin(agentId, nonce);
|
|
3602
|
+
const userLine = fanoutLine(agentId, 'user', uiText, {
|
|
3603
|
+
clientNonce: nonce,
|
|
3604
|
+
requestId: nonce,
|
|
3605
|
+
richText: visitor ? prefixVisitorRichText(parsed.richText, visitor.shortname, uiText) : parsed.richText,
|
|
3606
|
+
promptRaw: prompt,
|
|
3607
|
+
});
|
|
3608
|
+
noteFocus(agentId);
|
|
3609
|
+
bumpAgent(agentId, { preview: prompt, notify: false });
|
|
3610
|
+
ssePush('transcript', { ...gatewayEntry(userLine), agentId });
|
|
3611
|
+
const modelCmd = /^\s*\/model(?:\s+(\S+))?\s*$/i.exec(prompt || '');
|
|
3612
|
+
setImmediate(async () => {
|
|
3613
|
+
let text = '';
|
|
3614
|
+
let grouped = false;
|
|
3615
|
+
const stillCurrent = () => zooTurns.isCurrent(agentId, nonce) && !turn.signal.aborted;
|
|
3616
|
+
try {
|
|
3617
|
+
if (modelCmd) {
|
|
3618
|
+
const want = modelCmd[1];
|
|
3619
|
+
if (!want) {
|
|
3620
|
+
const cur = currentModel(agentId);
|
|
3621
|
+
text = `current model: ${cur}\nset with /model fable | opus | sonnet | grok | glm | provider/id`;
|
|
3622
|
+
} else {
|
|
3623
|
+
const id = (await resolveModelId(want)) || (want.includes('/') ? want : null);
|
|
3624
|
+
if (!id) {
|
|
3625
|
+
text = `unknown model "${want}" — not in the zoo's live catalog. try /model fable | opus | sonnet | grok | glm | abliterated, or any id from GET /v1/models`;
|
|
3626
|
+
} else {
|
|
3627
|
+
agentModels.set(agentId, id);
|
|
3628
|
+
saveAgentModels();
|
|
3629
|
+
text = `model set to ${id}`;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
} else {
|
|
3633
|
+
const members = groupMemberIds(agentId);
|
|
3634
|
+
if (members.length) {
|
|
3635
|
+
grouped = true;
|
|
3636
|
+
await runGroupQueue({ agentId, humanPrompt: prompt, parsed, nonce, log });
|
|
3637
|
+
} else {
|
|
3638
|
+
const z = await zooComplete(prompt, log, agentId, parsed, {
|
|
3639
|
+
signal: turn.signal,
|
|
3640
|
+
onProgress: (note) => {
|
|
3641
|
+
if (!stillCurrent()) return;
|
|
3642
|
+
paintChatUpdate(agentId, note, { clientNonce: nonce, requestId: nonce });
|
|
3643
|
+
},
|
|
3644
|
+
});
|
|
3645
|
+
text = z.text;
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
} catch (e) {
|
|
3649
|
+
if (!stillCurrent() || isSupersededError(e)) {
|
|
3650
|
+
log(`cursor-backend: sendPrompt superseded agent=${agentId} nonce=${nonce} ${e.message}`);
|
|
3651
|
+
zooTurns.end(agentId, nonce);
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
grouped = false;
|
|
3655
|
+
text = `openzoo error: ${e.message}`;
|
|
3656
|
+
log(`cursor-backend: sendPrompt zoo failed: ${e.message}`);
|
|
3657
|
+
}
|
|
3658
|
+
if (!stillCurrent()) {
|
|
3659
|
+
zooTurns.end(agentId, nonce);
|
|
3660
|
+
log(`cursor-backend: sendPrompt dropped stale agent=${agentId} nonce=${nonce}`);
|
|
3661
|
+
return;
|
|
3662
|
+
}
|
|
3663
|
+
if (!grouped) {
|
|
3664
|
+
const line = fanoutLine(agentId, 'assistant', text, { clientNonce: nonce, requestId: nonce });
|
|
3665
|
+
ssePush('transcript', { ...gatewayEntry(line), agentId });
|
|
3666
|
+
bumpAgent(agentId, { preview: text, notify: true });
|
|
3667
|
+
log(`cursor-backend: sendPrompt done agent=${agentId} seq=${line.seq} text=${JSON.stringify(text.slice(0, 220))}`);
|
|
3668
|
+
} else {
|
|
3669
|
+
log(`cursor-backend: sendPrompt group done agent=${agentId}`);
|
|
3670
|
+
}
|
|
3671
|
+
zooTurns.end(agentId, nonce);
|
|
3672
|
+
});
|
|
3673
|
+
return true;
|
|
3674
|
+
}
|
|
3675
|
+
if (name === 'getAgentTranscriptTail' || name === 'getAgentTranscriptWindow' || name === 'openAgentTail') {
|
|
3676
|
+
let parsed = {};
|
|
3677
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3678
|
+
const id = String(parsed.id || parsed.agentId || 'openzoo');
|
|
3679
|
+
noteFocus(id);
|
|
3680
|
+
tailedAgents.add(id);
|
|
3681
|
+
const t = agentTranscript(id);
|
|
3682
|
+
if (!t.pulledRemote && realPod?.agent && !useHouseRoster()) {
|
|
3683
|
+
const remote = await podJson('/api/getAgentTranscriptTail', {
|
|
3684
|
+
id, agentId: id, limit: 200, beforeSeq: parsed.beforeSeq,
|
|
3685
|
+
}, log);
|
|
3686
|
+
const n = ingestRemoteEntries(id, remote?.entries);
|
|
3687
|
+
t.pulledRemote = true;
|
|
3688
|
+
if (n) log(`cursor-backend: hydrated ${id} +${n} from 1340`);
|
|
3689
|
+
}
|
|
3690
|
+
const t2 = agentTranscript(id);
|
|
3691
|
+
const limit = Math.min(Number(parsed.limit) || 50, 200);
|
|
3692
|
+
const before = parsed.beforeSeq != null ? Number(parsed.beforeSeq) : Infinity;
|
|
3693
|
+
const sliced = t2.entries.filter((e) => e.seq < before).slice(-limit);
|
|
3694
|
+
const page = { entries: sliced.map(gatewayEntry) };
|
|
3695
|
+
if (t2.entries.length > sliced.length && sliced.length) page.nextBeforeSeq = sliced[0].seq;
|
|
3696
|
+
jsonSend(res, page);
|
|
3697
|
+
log(`cursor-backend: -> transcript ${id} n=${sliced.length}/${t2.seq}`);
|
|
3698
|
+
return true;
|
|
3699
|
+
}
|
|
3700
|
+
if (name === 'promptAcceptanceStatus') {
|
|
3701
|
+
let parsed = {};
|
|
3702
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
3703
|
+
const nonce = String(parsed.clientNonce || lastSendEchoId);
|
|
3704
|
+
const agentId = String(parsed.agentId || '');
|
|
3705
|
+
jsonSend(res, {
|
|
3706
|
+
outcome: 'found',
|
|
3707
|
+
record: {
|
|
3708
|
+
status: 'accepted',
|
|
3709
|
+
acceptedAtMs: Date.now(),
|
|
3710
|
+
echoEntryId: nonce,
|
|
3711
|
+
clientNonce: nonce,
|
|
3712
|
+
agentId,
|
|
3713
|
+
inputDigest: '',
|
|
3714
|
+
},
|
|
3715
|
+
});
|
|
3716
|
+
log(`cursor-backend: -> promptAcceptanceStatus found echo=${nonce}`);
|
|
3717
|
+
return true;
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
const stubs = {
|
|
3721
|
+
getHostStatus: { status: 'ready', ready: true, state: 'ready', hostStatus: 'ready' },
|
|
3722
|
+
getHostSettings: { settings: {} },
|
|
3723
|
+
setHostSettings: { ok: true },
|
|
3724
|
+
setBoxSecrets: { ok: true },
|
|
3725
|
+
listAgents: rosterForEvent(cachedAgentList()
|
|
3726
|
+
|| [...new Set([...transcripts.keys(), ...tailedAgents])].map((id) => ({ id, name: 'chat', status: 'ready' })), agentActivity),
|
|
3727
|
+
countAgents: (cachedAgentList() || [...new Set([...transcripts.keys(), ...tailedAgents])]).length,
|
|
3728
|
+
searchAgents: rosterForEvent(cachedAgentList() || [], agentActivity),
|
|
3729
|
+
getAgentTranscriptTail: { tail: '', lines: [], dropped: false, ok: true },
|
|
3730
|
+
getTeachRecordingStatus: { recording: false },
|
|
3731
|
+
getTrays: [],
|
|
3732
|
+
isGlobalSearchEnabled: { enabled: false },
|
|
3733
|
+
isEgressTunnelAvailable: { available: false },
|
|
3734
|
+
getSharingState: { sharing: false },
|
|
3735
|
+
getBotTemplateExportPolicy: { allowed: true },
|
|
3736
|
+
getAgentAutomations: { automations: [] },
|
|
3737
|
+
getForeverBoxStatus: { enabled: false },
|
|
3738
|
+
getSubagents: { subagents: [] },
|
|
3739
|
+
getAsyncTasks: { tasks: [] },
|
|
3740
|
+
getAgentWorkflows: { workflows: [] },
|
|
3741
|
+
setWindowFocused: { ok: true },
|
|
3742
|
+
};
|
|
3743
|
+
const payload = stubs[name] !== undefined ? stubs[name] : { ok: true };
|
|
3744
|
+
// Live 1340 listAgents is a RAW array, not CVr (measured 104674b 2026-08-29).
|
|
3745
|
+
if (name === 'listAgents' || name === 'countAgents' || name === 'searchAgents') jsonSend(res, payload);
|
|
3746
|
+
else jsonApi(res, payload);
|
|
3747
|
+
log(`cursor-backend: -> pod /api/${name}`);
|
|
3748
|
+
return true;
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3751
|
+
/**
|
|
3752
|
+
* Answer one Connect/gRPC-web call. `method` is the trailing method name,
|
|
3753
|
+
* `models` the catalog to publish. Non-catalog methods get an empty-OK body.
|
|
3754
|
+
*/
|
|
3755
|
+
function respond(req, res, method, models) {
|
|
3756
|
+
// CORS preflight: answer 204 with the headers, so the real call proceeds.
|
|
3757
|
+
if (req.method === 'OPTIONS') { res.writeHead(204, CORS); res.end(); return; }
|
|
3758
|
+
|
|
3759
|
+
const url = req.url || '';
|
|
3760
|
+
const ct = String(req.headers['content-type'] || '');
|
|
3761
|
+
const isGrpcWeb = ct.includes('grpc-web');
|
|
3762
|
+
const isJson = ct.includes('json') || url.startsWith('/auth/');
|
|
3763
|
+
|
|
3764
|
+
// THE ENTITLEMENT ENDPOINT. /auth/full_stripe_profile (and any /auth/*stripe*)
|
|
3765
|
+
// must report an active membership or the editor gates every model.
|
|
3766
|
+
if (/full_stripe_profile|stripe|membership|subscription/i.test(url)) {
|
|
3767
|
+
res.writeHead(200, { 'content-type': 'application/json', ...CORS });
|
|
3768
|
+
res.end(stripeProfile());
|
|
3769
|
+
return;
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
const bodyProto = encodeForMethod(method, models) || Buffer.alloc(0);
|
|
3773
|
+
|
|
3774
|
+
if (isJson) {
|
|
3775
|
+
// Connect unary JSON. AvailableModels as JSON; everything else an empty object.
|
|
3776
|
+
const json = method === 'AvailableModels'
|
|
3777
|
+
? JSON.stringify({ modelNames: models.map((m) => m.name), models: models.map((m) => ({
|
|
3778
|
+
name: m.name, defaultOn: true, supportsAgent: true, supportsMaxMode: true,
|
|
3779
|
+
supportsNonMaxMode: true, supportsThinking: true, supportsImages: true,
|
|
3780
|
+
clientDisplayName: m.label ?? m.name, serverModelName: m.name, supportsPlanMode: true,
|
|
3781
|
+
})) })
|
|
3782
|
+
: '{}';
|
|
3783
|
+
res.writeHead(200, { 'content-type': 'application/json', ...CORS });
|
|
3784
|
+
res.end(json);
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
if (isGrpcWeb) {
|
|
3789
|
+
res.writeHead(200, {
|
|
3790
|
+
'content-type': ct.includes('text') ? 'application/grpc-web-text+proto' : 'application/grpc-web+proto',
|
|
3791
|
+
'grpc-status': '0', ...CORS,
|
|
3792
|
+
});
|
|
3793
|
+
res.end(Buffer.concat([envelope(bodyProto), grpcWebTrailer()]));
|
|
3794
|
+
return;
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
// Connect unary proto: bare message, status in headers.
|
|
3798
|
+
res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
|
|
3799
|
+
res.end(bodyProto);
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
/**
|
|
3803
|
+
* Start the impersonation server. Binds `port` (default 8443 — a privileged
|
|
3804
|
+
* 443->port redirect is installed alongside the /etc/hosts entry, so this stays
|
|
3805
|
+
* unprivileged). Returns { server, port }.
|
|
3806
|
+
*/
|
|
3807
|
+
export function startCursorBackend({ port = 8443, models, log = () => {} } = {}) {
|
|
3808
|
+
restoreAgentWakeups(log);
|
|
3809
|
+
const { cert, key } = ensureCert(log);
|
|
3810
|
+
const certPem = fs.readFileSync(cert);
|
|
3811
|
+
const keyPem = fs.readFileSync(key);
|
|
3812
|
+
const secureContext = tls.createSecureContext({ cert: certPem, key: keyPem });
|
|
3813
|
+
let conns = 0;
|
|
3814
|
+
// SERVE BOTH h2 AND h1. An earlier build forced h1-only after concluding the
|
|
3815
|
+
// editor's h2 connections reset — but that log was the STALE 8443 backend the
|
|
3816
|
+
// pf redirect was still intercepting, not this server, so the conclusion was
|
|
3817
|
+
// contaminated. Measured cleanly now: some editor connections offer ONLY h2
|
|
3818
|
+
// (ERR_SSL_NO_APPLICATION_PROTOCOL when we advertise just h1), so we must
|
|
3819
|
+
// negotiate both. allowHTTP1 keeps the h1 fetch() calls (stripe/updates)
|
|
3820
|
+
// working; ALPN h2 first satisfies the Connect gRPC client.
|
|
3821
|
+
// SNICallback MUST pass the SecureContext — cb(null) with no ctx is why the
|
|
3822
|
+
// Helper daemon's Node fetch never completed GET /local-exec/requests (TLS
|
|
3823
|
+
// ECONNRESET, no request log). Chromium --ignore-certificate-errors hid this
|
|
3824
|
+
// for the UI process.
|
|
3825
|
+
const server = http2.createSecureServer(
|
|
3826
|
+
{
|
|
3827
|
+
cert: certPem,
|
|
3828
|
+
key: keyPem,
|
|
3829
|
+
allowHTTP1: true,
|
|
3830
|
+
ALPNProtocols: ['h2', 'http/1.1'],
|
|
3831
|
+
SNICallback: (servername, cb) => {
|
|
3832
|
+
log(`cursor-tls: <- ClientHello SNI=${servername || '?'}`);
|
|
3833
|
+
cb(null, secureContext);
|
|
3834
|
+
},
|
|
3835
|
+
},
|
|
3836
|
+
async (req, res) => {
|
|
3837
|
+
conns += 1;
|
|
3838
|
+
const full = req.url || '';
|
|
3839
|
+
const method = full.split('/').filter(Boolean).pop() || '';
|
|
3840
|
+
const ct = req.headers['content-type'] || '?';
|
|
3841
|
+
const body = await readBody(req);
|
|
3842
|
+
// CLAUDE DESKTOP: api.anthropic.com speaks the JSON Messages API our proxy
|
|
3843
|
+
// already handles. If this is an Anthropic host / messages call, forward it
|
|
3844
|
+
// verbatim to the local paying proxy and relay the response — reusing the
|
|
3845
|
+
// whole translate+x402 path. (SNI is checked because the Host header may be
|
|
3846
|
+
// absent on h2.)
|
|
3847
|
+
// STRIP THE PORT BEFORE MATCHING. :authority / Host carry `host:port`
|
|
3848
|
+
// whenever the client dials a non-default port, so an anchored
|
|
3849
|
+
// /anthropic\.com$/ silently failed to match and the request fell through
|
|
3850
|
+
// to the gRPC branch, which answered empty-OK. The desktop app then saw a
|
|
3851
|
+
// 200 with no body for its session bootstrap — indistinguishable from the
|
|
3852
|
+
// 404 it used to get, and it wedged the same way.
|
|
3853
|
+
const rawHost = String(req.headers[':authority'] || req.headers.host || req.socket?.servername || '');
|
|
3854
|
+
const host = rawHost.replace(/:\d+$/, '');
|
|
3855
|
+
const isAnthropic = /(^|\.)anthropic\.com$/.test(host);
|
|
3856
|
+
// INTERCEPT INFERENCE ONLY. Matching the whole HOST sent every request the
|
|
3857
|
+
// desktop app makes to the local proxy — including the session bootstrap
|
|
3858
|
+
// (/api/organizations, /api/bootstrap, /api/account), which the proxy does
|
|
3859
|
+
// not implement and answered 404. The app is OAuth/subscription-bound, so
|
|
3860
|
+
// a 404 on bootstrap means it can never establish a session: it wedges and
|
|
3861
|
+
// macOS offers Force Quit. Only /v1/messages (and /v1/complete) are ours;
|
|
3862
|
+
// everything else on this host MUST reach the real Anthropic API.
|
|
3863
|
+
const isInference = /\/v1\/(messages|complete)\b/.test(full);
|
|
3864
|
+
if (isAnthropic && !isInference) {
|
|
3865
|
+
await passthroughToRealAnthropic(req, res, body, host, full, log);
|
|
3866
|
+
return;
|
|
3867
|
+
}
|
|
3868
|
+
if (isAnthropic || isInference) {
|
|
3869
|
+
try {
|
|
3870
|
+
const up = await fetch(`http://127.0.0.1:8402${full}`, {
|
|
3871
|
+
method: req.method,
|
|
3872
|
+
headers: { 'content-type': req.headers['content-type'] || 'application/json' },
|
|
3873
|
+
body: (req.method === 'GET' || req.method === 'HEAD') ? undefined : body,
|
|
3874
|
+
});
|
|
3875
|
+
const buf = Buffer.from(await up.arrayBuffer());
|
|
3876
|
+
const h = { ...CORS };
|
|
3877
|
+
// STRIP EVERY CONNECTION-SPECIFIC HEADER, NOT JUST `connection`.
|
|
3878
|
+
// RFC 7540 §8.1.2.2 bans all of these on HTTP/2, and Node enforces it
|
|
3879
|
+
// by THROWING ERR_HTTP2_INVALID_CONNECTION_HEADERS out of writeHead.
|
|
3880
|
+
// The local proxy answers with `keep-alive`, which sailed past a
|
|
3881
|
+
// denylist that only knew about `connection` — so the very first
|
|
3882
|
+
// desktop request killed the whole backend process (uncaught, since
|
|
3883
|
+
// this is inside the async handler), and every later attempt got
|
|
3884
|
+
// ECONNRESET with nothing listening. Content-length goes too: the
|
|
3885
|
+
// body was re-buffered, so let Node recompute it. (Shared set above.)
|
|
3886
|
+
up.headers.forEach((v, k) => { if (!HOP_BY_HOP.has(k.toLowerCase())) h[k] = v; });
|
|
3887
|
+
res.writeHead(up.status, h);
|
|
3888
|
+
res.end(buf);
|
|
3889
|
+
log(`cursor-backend: #${conns} ${req.method} ${full} ANTHROPIC -> proxy (${up.status}, ${buf.length}b)`);
|
|
3890
|
+
} catch (e) {
|
|
3891
|
+
res.writeHead(502, { 'content-type': 'application/json', ...CORS });
|
|
3892
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `openzoo forward failed: ${e.message}` } }));
|
|
3893
|
+
log(`cursor-backend: anthropic forward failed: ${e.message}`);
|
|
3894
|
+
}
|
|
3895
|
+
return;
|
|
3896
|
+
}
|
|
3897
|
+
log(`cursor-backend: #${conns} ${req.method} ${full} ct=${ct} body=${body.length}b`);
|
|
3898
|
+
// OAUTH MUST HIT THE REAL ISSUER. empty-ok on /oauth/token is the
|
|
3899
|
+
// "Reconnecting to your computer" / edge/handler-failed loop: the app
|
|
3900
|
+
// retries the same 509b body forever and never reaches AvailableModels
|
|
3901
|
+
// or EnsureSandBox. Measured live: #14–#88 POST /oauth/token -> empty-ok.
|
|
3902
|
+
// Same for the sandbox RPCs unless we are hijacking the pod.
|
|
3903
|
+
{
|
|
3904
|
+
const path0 = full.split('?')[0];
|
|
3905
|
+
const oauth = /^\/oauth(\/|$)/.test(path0);
|
|
3906
|
+
// Coordinator talks to /sand-box/local-exec-daemon-credential. empty-ok
|
|
3907
|
+
// here is ControlPortCallError: main-execution-failure: fetch failed.
|
|
3908
|
+
const sandBox = /^\/sand-box(\/|$)/.test(path0);
|
|
3909
|
+
const grokCred = /IssueGrokBotUserComputerCredential/.test(full);
|
|
3910
|
+
const sniffBox = sniffOn() && /GrokBotService\/(EnsureSandBox|WatchSandBoxMigration)/.test(full);
|
|
3911
|
+
const needRealPod = /GrokBotService\/EnsureSandBox/.test(full)
|
|
3912
|
+
&& !process.env.OZ_HIJACK_POD
|
|
3913
|
+
&& !sniffBox;
|
|
3914
|
+
if (sniffBox) {
|
|
3915
|
+
if (/WatchSandBoxMigration/.test(full) && realPod) {
|
|
3916
|
+
const payload = rewrittenBox();
|
|
3917
|
+
res.writeHead(200, {
|
|
3918
|
+
'content-type': 'application/connect+proto',
|
|
3919
|
+
'grpc-status': '0',
|
|
3920
|
+
...CORS,
|
|
3921
|
+
});
|
|
3922
|
+
const end = Buffer.from('{}');
|
|
3923
|
+
const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
|
|
3924
|
+
res.end(Buffer.concat([envelope(payload), h, end]));
|
|
3925
|
+
log('cursor-backend: SNIFF WatchSandBoxMigration ready (rewritten)');
|
|
3926
|
+
return;
|
|
3927
|
+
}
|
|
3928
|
+
await sniffEnsureSandBox(req, res, body, host, full, log);
|
|
3929
|
+
return;
|
|
3930
|
+
}
|
|
3931
|
+
if (oauth || sandBox || grokCred || needRealPod) {
|
|
3932
|
+
const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
|
|
3933
|
+
await passthroughToRealAnthropic(req, res, body, upstream, full, log);
|
|
3934
|
+
if (oauth) lastOauthAt = Date.now();
|
|
3935
|
+
return;
|
|
3936
|
+
}
|
|
3937
|
+
if (await handleLocalExecHttp(req, res, path0, body, log)) return;
|
|
3938
|
+
if (await handleHijackedPodHttp(req, res, full, body, log)) return;
|
|
3939
|
+
}
|
|
3940
|
+
// HIJACK EnsureSandBox FIRST — before passthrough, or passthrough eats it.
|
|
3941
|
+
// MEASURED: with OPENZOO_PASSTHRU=1 set, EnsureSandBox went `-> REAL
|
|
3942
|
+
// anthropic (200, 707b)` every time and the app got a real cursorvm pod,
|
|
3943
|
+
// never our box. This method (and ONLY this one) must be answered locally
|
|
3944
|
+
// with OUR box so Grok Bot's UI wires to our sandbox; everything else
|
|
3945
|
+
// still passes through so the app loads normally.
|
|
3946
|
+
if (/GrokBotService\/(EnsureSandBox|WatchSandBoxMigration)/.test(full) && process.env.OZ_HIJACK_POD) {
|
|
3947
|
+
// Watch is a connect STREAM. Never await api2 on it — that is the
|
|
3948
|
+
// splash hang. Reply immediately, discover THIS caller's 1340 in
|
|
3949
|
+
// the background so listAgents can wait for their tray, not the
|
|
3950
|
+
// machine-global cache from the last login on this Mac.
|
|
3951
|
+
process.env.OZ_SNIFF_SELF = process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443';
|
|
3952
|
+
const confirmed = discover.ok && realPod?.agent && !podStale;
|
|
3953
|
+
if (/WatchSandBoxMigration/.test(full)) {
|
|
3954
|
+
replyEnsureBox(req, res, rewrittenBox({ confirmed }), full);
|
|
3955
|
+
ensureDiscover(req, Buffer.alloc(0), log);
|
|
3956
|
+
log(`cursor-backend: -> WatchSandBoxMigration ready (${confirmed ? `account ${realPod.accountId}` : 'env box + discover'})`);
|
|
3957
|
+
return;
|
|
3958
|
+
}
|
|
3959
|
+
try {
|
|
3960
|
+
const pod = await ensureDiscover(req, body, log);
|
|
3961
|
+
replyEnsureBox(req, res, rewrittenBox({ confirmed: !!pod?.agent }), full);
|
|
3962
|
+
log(`cursor-backend: -> HIJACKED EnsureSandBox -> our box (roster from real 1340 account=${pod?.accountId || '?'})`);
|
|
3963
|
+
return;
|
|
3964
|
+
} catch (e) {
|
|
3965
|
+
log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — ${realPod?.agent && cacheFallbackOk ? 'cached 1340' : 'env box'}`);
|
|
3966
|
+
}
|
|
3967
|
+
replyEnsureBox(req, res, rewrittenBox({ confirmed: !!(realPod?.agent && cacheFallbackOk) }), full);
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3970
|
+
// CAPTURE the chat inference request so its schema can be decoded from
|
|
3971
|
+
// REAL bytes (Auto's StreamUnifiedChat). Written once; inspect then build.
|
|
3972
|
+
if (/StreamUnifiedChat/.test(full)) {
|
|
3973
|
+
await handleStreamChat(req, res, body, log);
|
|
3974
|
+
return;
|
|
3975
|
+
}
|
|
3976
|
+
// Telegram/"Failed to send": GetGrokBotSendStatus empty proto is
|
|
3977
|
+
// UNSPECIFIED. ACCEPTED=2. WatchGrokBotUserComputerRequests is a
|
|
3978
|
+
// long-lived stream — empty-ok closes it and the channel looks offline.
|
|
3979
|
+
if (/GetGrokBotSendStatus/.test(full)) {
|
|
3980
|
+
if (sniffOn()) {
|
|
3981
|
+
const fields = decodeProtoFields(body);
|
|
3982
|
+
sniffDump({ kind: 'GetGrokBotSendStatus.req', fields });
|
|
3983
|
+
const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
|
|
3984
|
+
await passthroughToRealAnthropic(req, res, body, upstream, full, log);
|
|
3985
|
+
return;
|
|
3986
|
+
}
|
|
3987
|
+
const fields = decodeProtoFields(body);
|
|
3988
|
+
const echoId = String(fields[2] || lastSendEchoId);
|
|
3989
|
+
lastSendEchoId = echoId;
|
|
3990
|
+
const payload = encodeGetGrokBotSendStatus(echoId);
|
|
3991
|
+
const reqCt = String(req.headers['content-type'] || '');
|
|
3992
|
+
if (reqCt.includes('grpc-web')) {
|
|
3993
|
+
res.writeHead(200, {
|
|
3994
|
+
'content-type': reqCt.includes('text') ? 'application/grpc-web-text+proto' : 'application/grpc-web+proto',
|
|
3995
|
+
'grpc-status': '0', ...CORS,
|
|
3996
|
+
});
|
|
3997
|
+
res.end(Buffer.concat([envelope(payload), grpcWebTrailer()]));
|
|
3998
|
+
} else {
|
|
3999
|
+
res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
|
|
4000
|
+
res.end(payload);
|
|
4001
|
+
}
|
|
4002
|
+
log(`cursor-backend: -> GetGrokBotSendStatus ACCEPTED echo=${echoId}`);
|
|
4003
|
+
return;
|
|
4004
|
+
}
|
|
4005
|
+
if (/WatchGrokBotUserComputerRequests/.test(full)) {
|
|
4006
|
+
if (sniffOn()) {
|
|
4007
|
+
const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
|
|
4008
|
+
await passthroughPipe(req, res, body, upstream, full, log);
|
|
4009
|
+
return;
|
|
4010
|
+
}
|
|
4011
|
+
res.writeHead(200, {
|
|
4012
|
+
'content-type': 'application/connect+proto',
|
|
4013
|
+
'grpc-status': '0',
|
|
4014
|
+
...CORS,
|
|
4015
|
+
});
|
|
4016
|
+
req.on('close', () => { try { res.end(); } catch { /* */ } });
|
|
4017
|
+
log('cursor-backend: -> WatchGrokBotUserComputerRequests held');
|
|
4018
|
+
return;
|
|
4019
|
+
}
|
|
4020
|
+
// FULL PASSTHROUGH MODE — observe, do not stub.
|
|
4021
|
+
//
|
|
4022
|
+
// Stubbing unknown methods with empty protobufs BREAKS Grok Bot: it never
|
|
4023
|
+
// gets a sandbox from GrokBotService/EnsureSandBox, so it re-polls
|
|
4024
|
+
// WatchSandBoxMigration forever and never reaches the chat stage. That is
|
|
4025
|
+
// why an intercepted run showed 40 methods and ZERO StreamChat — absence
|
|
4026
|
+
// of evidence created by our own stub, not proof that chat lives
|
|
4027
|
+
// elsewhere.
|
|
4028
|
+
//
|
|
4029
|
+
// In passthrough the app runs NORMALLY against the real backend while
|
|
4030
|
+
// every method, size and status is logged here — which is what actually
|
|
4031
|
+
// reveals where inference goes.
|
|
4032
|
+
if (process.env.OPENZOO_PASSTHRU === '1') {
|
|
4033
|
+
await passthroughToRealAnthropic(req, res, body, host, full, log);
|
|
4034
|
+
return;
|
|
4035
|
+
}
|
|
4036
|
+
try {
|
|
4037
|
+
respond(req, res, method, models);
|
|
4038
|
+
const populated = ['GetPlanInfo', 'GetMe', 'GetDefaultModel', 'IsOnNewPricing'];
|
|
4039
|
+
const what = /stripe|membership|subscription/i.test(full) ? 'ENTITLED stripe profile'
|
|
4040
|
+
: method === 'AvailableModels' ? `AvailableModels (${models.length} models, gates open)`
|
|
4041
|
+
: populated.includes(method) ? `${method} (PRO/entitled)`
|
|
4042
|
+
: req.method === 'OPTIONS' ? 'CORS preflight 204'
|
|
4043
|
+
: 'empty-ok';
|
|
4044
|
+
log(`cursor-backend: -> ${what}`);
|
|
4045
|
+
} catch (e) {
|
|
4046
|
+
res.writeHead(200, { 'content-type': 'application/proto', 'grpc-status': '0' });
|
|
4047
|
+
res.end(Buffer.alloc(0));
|
|
4048
|
+
log(`cursor-backend: -> empty-ok (err ${e.message})`);
|
|
4049
|
+
}
|
|
4050
|
+
},
|
|
4051
|
+
);
|
|
4052
|
+
server.on('secureConnection', (sock) => { log(`cursor-tls: connected alpn=${sock.alpnProtocol || 'none'} sni=${sock.servername || '?'}`); });
|
|
4053
|
+
// NEVER swallow this — a rejected handshake is exactly the failure to see.
|
|
4054
|
+
server.on('tlsClientError', (e, sock) => {
|
|
4055
|
+
log(`cursor-tls: HANDSHAKE FAILED ${e.code || e.message} alpn=${sock?.alpnProtocol || '?'}`);
|
|
4056
|
+
});
|
|
4057
|
+
// BIND BOTH LOOPBACK FAMILIES. lib/hosts.js now pins each host to 127.0.0.1
|
|
4058
|
+
// AND ::1 (a v4-only entry let dual-stack clients take the AAAA route straight
|
|
4059
|
+
// to the real backend). Listening on v4 alone would turn that bypass into a
|
|
4060
|
+
// v6 blackhole — connection refused instead of a wrong answer. Binding the
|
|
4061
|
+
// unspecified address '::' with ipv6Only off accepts both on one socket; if
|
|
4062
|
+
// the platform refuses (v6 disabled), fall back to v4 so we still work.
|
|
4063
|
+
server.on('error', (e) => log(`cursor-backend: server error ${e.code || e.message}`));
|
|
4064
|
+
const onUp = (what) => log(`cursor-backend: listening on ${what}:${port} as ${CURSOR_HOSTS[0]}`);
|
|
4065
|
+
startHostMcps({ log }).catch((e) => log(`cursor-backend: mcp start ${e.message}`));
|
|
4066
|
+
try {
|
|
4067
|
+
server.listen({ port, host: '::', ipv6Only: false }, () => onUp('[::]+127.0.0.1'));
|
|
4068
|
+
} catch {
|
|
4069
|
+
server.listen(port, '127.0.0.1', () => onUp('127.0.0.1'));
|
|
4070
|
+
}
|
|
4071
|
+
return { server, port };
|
|
4072
|
+
}
|