openzoo 0.50.97 → 0.50.99

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.
@@ -33,20 +33,24 @@ import os from 'node:os';
33
33
  import path from 'node:path';
34
34
  import zlib from 'node:zlib';
35
35
  import { execFileSync, execFile } from 'node:child_process';
36
+ import { createRequire } from 'node:module';
36
37
  import { promisify } from 'node:util';
37
38
  import tls from 'node:tls';
38
- import { randomUUID } from 'node:crypto';
39
- import { encodeAvailableModels, encodeForMethod, encodeEnsureSandBox, encodeGetGrokBotSendStatus, decodeProtoFields, unwrapConnect } from './cursorapi.js';
39
+ import { randomUUID, generateKeyPairSync, createPrivateKey, sign as cryptoSign } from 'node:crypto';
40
+ import { encodeAvailableModels, encodeForMethod, encodeEnsureSandBox, encodeGetGrokBotSendStatus, decodeProtoFields, unwrapConnect, fakeCursorAuthReply } from './cursorapi.js';
40
41
  import {
41
42
  accountPodPath, accountAgentsPath, rosterForAccount, rosterForEvent,
42
43
  readHouseRoster, houseAgentsPath, shapeAgent, agentBrief, briefFromName,
43
44
  preferNamedAgent, looksLikeAgentId,
45
+ grokroomAgentId, grokroomMemberId, grokroomShareUrl, grokroomFromHandle, grokroomAddrFromShare,
46
+ agentShareUrl, groupShareState, attachOnchainRoom, bindOnchainRoom,
47
+ isGrokRoomAgent, isGrokRoomMember,
44
48
  readWakeups, writeWakeups, shapeWakeup, parseWakeupEvery, wantsWakeupCron,
45
49
  DEFAULT_WAKEUP_PROMPT, addDeletedIds, filterDeleted,
46
50
  } from './grokbotAccount.js';
47
51
  import { spendChipLabel, formatSpendFooter, mergeTurnProof } from './spendProof.js';
48
52
  import { spendLinesOnly, writeSpendHud } from './ozSpendChip.js';
49
- import { zooModelIds, zooModelRow, mediaKindOf } from './models.js';
53
+ import { zooModelIds, zooModelRow, mediaKindOf, resolveModel } from './models.js';
50
54
  import { prefixVisitorRichText } from './grokbotweb.js';
51
55
  import {
52
56
  ingestUpload, lookupUpload, readUploadChunk, readUploadImage, readUploadText,
@@ -66,11 +70,36 @@ const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'rep
66
70
  // handshake before ALPN — logged as `HANDSHAKE FAILED ECONNRESET alpn=?`, with
67
71
  // no request ever reaching us. Same cert, one more pair of names.
68
72
  const ANTHROPIC_TLS_HOSTS = ['api.anthropic.com', 'api-staging.anthropic.com'];
69
- const TLS_HOSTS = [...CURSOR_HOSTS, ...ANTHROPIC_TLS_HOSTS];
73
+ const TLS_DNS = [...CURSOR_HOSTS, ...ANTHROPIC_TLS_HOSTS, 'localhost'];
74
+ const TLS_IPS = ['127.0.0.1'];
75
+ const TLS_HOSTS = [...TLS_DNS, ...TLS_IPS];
70
76
 
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 = () => {}) {
77
+ /** Node-only self-signed PEM. Windows has no openssl in PATH (measured 2026-09-03).
78
+ * selfsigned@5 `generate` is async. */
79
+ export async function mintTlsPems() {
80
+ const selfsigned = createRequire(import.meta.url)('selfsigned');
81
+ const pems = await selfsigned.generate(
82
+ [{ name: 'commonName', value: 'api2.cursor.sh' }],
83
+ {
84
+ keySize: 2048,
85
+ days: 3650,
86
+ algorithm: 'sha256',
87
+ extensions: [{
88
+ name: 'subjectAltName',
89
+ altNames: [
90
+ ...TLS_DNS.map((value) => ({ type: 2, value })),
91
+ ...TLS_IPS.map((ip) => ({ type: 7, ip })),
92
+ ],
93
+ }],
94
+ },
95
+ );
96
+ if (!pems?.cert || !pems?.private) throw new Error('selfsigned returned empty pem');
97
+ return { cert: pems.cert, key: pems.private };
98
+ }
99
+
100
+ /** Generate (once) a self-signed cert covering every host we impersonate.
101
+ * Mint with the `selfsigned` package (no openssl). openssl is a fallback only. */
102
+ export async function ensureCert(log = () => {}) {
74
103
  const cert = path.join(TLS_DIR, 'cert.pem');
75
104
  const key = path.join(TLS_DIR, 'key.pem');
76
105
  // STALE-CERT INVALIDATION. A plain existence check meant anyone who had ever
@@ -85,12 +114,19 @@ export function ensureCert(log = () => {}) {
85
114
  log('cursor-tls: cert predates the current host list — re-minting');
86
115
  } catch { /* make it */ }
87
116
  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' });
117
+ try {
118
+ const pems = await mintTlsPems();
119
+ fs.writeFileSync(key, pems.key);
120
+ fs.writeFileSync(cert, pems.cert);
121
+ } catch (e) {
122
+ const san = `subjectAltName=${TLS_DNS.map((h) => `DNS:${h}`).join(',')},${TLS_IPS.map((h) => `IP:${h}`).join(',')}`;
123
+ execFileSync('openssl', [
124
+ 'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
125
+ '-keyout', key, '-out', cert, '-days', '3650',
126
+ '-subj', '/CN=api2.cursor.sh', '-addext', san,
127
+ ], { stdio: 'ignore' });
128
+ log(`cursor-tls: openssl fallback (${e.message})`);
129
+ }
94
130
  fs.writeFileSync(stamp, want);
95
131
  log(`cursor-tls: self-signed cert minted at ${TLS_DIR} covering ${TLS_HOSTS.length} hosts (no CA, no trust prompt)`);
96
132
  return { cert, key };
@@ -942,7 +978,7 @@ function sortAgentsByActivity(list) {
942
978
  return ay - ax;
943
979
  });
944
980
  }
945
- function bumpAgent(id, { preview = '', notify = true } = {}) {
981
+ function bumpAgent(id, { preview = '', notify = true, select = true } = {}) {
946
982
  const now = Date.now();
947
983
  const a = agentActivity.get(id) || { unreadCount: 0 };
948
984
  a.updatedAt = now;
@@ -960,7 +996,7 @@ function bumpAgent(id, { preview = '', notify = true } = {}) {
960
996
  if (idx >= 0) list[idx] = agent;
961
997
  else list.unshift(agent);
962
998
  saveAgents(sortAgentsByActivity(list.map(shapeAgent)));
963
- pushCreatedAgent(agent);
999
+ pushCreatedAgent(agent, { select });
964
1000
  }
965
1001
 
966
1002
  /** asar ingestAgentsEvent does Ae.agents.map — `{action:"created"}` has no
@@ -987,6 +1023,54 @@ function localExecPush(frame) {
987
1023
  try { c.write(chunk); } catch { localExecSse.delete(c); }
988
1024
  }
989
1025
  }
1026
+ /** Helper builds disagree about where the shell output lives: top-level
1027
+ * `stdout` on some, `message: {stdout, exitCode}` or `clientMessage` on
1028
+ * others. Dig it out as TEXT — an object that reaches the canvas paints as
1029
+ * [object Object] (measured: `→ exec "ls" [object Object]` on Linux). */
1030
+ export function normalizeExecFrame(frame) {
1031
+ const out = { stdout: '', stderr: '', exitCode: undefined, message: '', error: '' };
1032
+ const seen = new Set();
1033
+ const pick = (node, keys) => {
1034
+ for (const k of keys) if (typeof node[k] === 'string' && node[k]) return node[k];
1035
+ return '';
1036
+ };
1037
+ const visit = (node, depth) => {
1038
+ if (!node || typeof node !== 'object' || depth > 4 || seen.has(node)) return;
1039
+ seen.add(node);
1040
+ if (!out.stdout) out.stdout = pick(node, ['stdout', 'stdOut', 'output']);
1041
+ if (!out.stderr) out.stderr = pick(node, ['stderr', 'stdErr']);
1042
+ if (!out.message) out.message = pick(node, ['message', 'text', 'content']);
1043
+ if (!out.error) out.error = pick(node, ['error', 'errorMessage']);
1044
+ if (out.exitCode == null) {
1045
+ for (const k of ['exitCode', 'exit_code', 'code']) {
1046
+ if (typeof node[k] === 'number') { out.exitCode = node[k]; break; }
1047
+ }
1048
+ }
1049
+ for (const k of ['message', 'clientMessage', 'serverMessage', 'result', 'data', 'payload', 'body', 'throw']) {
1050
+ visit(node[k], depth + 1);
1051
+ }
1052
+ };
1053
+ visit(frame, 0);
1054
+ if (out.error) out.error = `local-exec: ${out.error}`;
1055
+ if (!out.stdout && !out.stderr && !out.message && !out.error) out.message = toolResultText(frame);
1056
+ return out;
1057
+ }
1058
+
1059
+ /** The daemon streams `output`/`stdout` chunks and only then closes with
1060
+ * `result`/`exit`, and those streaming frames do not always echo requestId.
1061
+ * Keying purely on requestId dropped the body on the floor and waited out the
1062
+ * 45s timeout, so fall back to the sole in-flight request. */
1063
+ function localExecWaiterFor(f) {
1064
+ if (f.requestId && localExecWaiters.has(f.requestId)) {
1065
+ return { id: f.requestId, w: localExecWaiters.get(f.requestId) };
1066
+ }
1067
+ if (!f.requestId && localExecWaiters.size === 1) {
1068
+ const [id, w] = localExecWaiters.entries().next().value;
1069
+ return { id, w };
1070
+ }
1071
+ return null;
1072
+ }
1073
+
990
1074
  function handleLocalExecFrames(frames, log) {
991
1075
  for (const f of frames || []) {
992
1076
  if (!f || typeof f !== 'object') continue;
@@ -994,24 +1078,64 @@ function handleLocalExecFrames(frames, log) {
994
1078
  localExecHello = f;
995
1079
  log(`cursor-backend: local-exec hello computer=${f.computerId || f.label || '?'} root=${f.localRoot || '?'}`);
996
1080
  }
997
- const w = f.requestId && localExecWaiters.get(f.requestId);
998
- if (!w) continue;
1081
+ const hit = localExecWaiterFor(f);
1082
+ if (!hit) continue;
1083
+ const { id, w } = hit;
999
1084
  if (f.kind === 'file') {
1000
1085
  const buf = Buffer.from(f.bytesBase64 || '', 'base64');
1001
1086
  w.resolve({ kind: 'file', bytes: buf, text: buf.toString('utf8') });
1002
- localExecWaiters.delete(f.requestId);
1087
+ localExecWaiters.delete(id);
1003
1088
  } else if (f.kind === 'file-error' || f.kind === 'messages-error') {
1004
1089
  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') {
1090
+ localExecWaiters.delete(id);
1091
+ } else if (shellStreamOf(f)) {
1092
+ // The real Grok Bot lane: stdout/stderr arrive as many `kind:"client"`
1093
+ // frames and the turn is only over on `exit`. Resolving on the first one
1094
+ // (what the old client/control branch did) truncated every command to its
1095
+ // first chunk.
1096
+ const ss = shellStreamOf(f);
1097
+ // ShellStreamStdout/Stderr carry exactly one field, `data`.
1098
+ const so = oneofCase(ss, 'stdout');
1099
+ const se = oneofCase(ss, 'stderr');
1100
+ if (typeof so?.data === 'string') w.chunks += so.data;
1101
+ if (typeof se?.data === 'string') w.stderr += se.data;
1102
+ const exit = oneofCase(ss, 'exit');
1103
+ if (exit) {
1104
+ w.resolve({
1105
+ kind: 'exit',
1106
+ message: w.chunks || w.stderr,
1107
+ stdout: w.chunks,
1108
+ stderr: w.stderr,
1109
+ exitCode: Number(exit.code || 0),
1110
+ // The daemon spools a command's output to a terminal .txt under the
1111
+ // hello frame's terminalsFolder and only names it here. Big or
1112
+ // fast-finishing commands stream NO chunks at all, which is how
1113
+ // `echo hi; pwd` came back as a bare `(exit 0)`.
1114
+ outputPath: exitOutputPath(exit),
1115
+ });
1116
+ localExecWaiters.delete(id);
1117
+ }
1118
+ } else if (f.kind === 'control' && f.message && f.message.heartbeat) {
1119
+ // 3s keepalive while a long command runs. It is a `control` frame like
1120
+ // any other, so the generic branch below used to resolve the call with an
1121
+ // empty body the moment the daemon first breathed.
1122
+ continue;
1123
+ } else if (f.kind === 'client' || f.kind === 'control') {
1124
+ // The daemon's response schema is exactly: hello, client, control, file,
1125
+ // file-error, messages-result, messages-error, ping. `result`,
1126
+ // `exec-result`, `output`, `exit` were never wire kinds — reverse-
1127
+ // engineered from dist/local-exec-daemon/main.cjs, 0.36.0.
1128
+ const norm = normalizeExecFrame(f);
1129
+ const stdout = w.chunks + (norm.stdout || '');
1130
+ const stderr = w.stderr + (norm.stderr || '');
1007
1131
  w.resolve({
1008
1132
  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,
1133
+ message: norm.error || norm.message || stdout || toolResultText(f),
1134
+ stdout,
1135
+ stderr: norm.error && !stderr ? norm.error : stderr,
1136
+ exitCode: norm.exitCode,
1013
1137
  });
1014
- localExecWaiters.delete(f.requestId);
1138
+ localExecWaiters.delete(id);
1015
1139
  }
1016
1140
  }
1017
1141
  }
@@ -1024,6 +1148,8 @@ function localExecAsk(frame, timeoutMs = 45000) {
1024
1148
  reject(new Error('local-exec timeout — is Grok Bot Helper connected?'));
1025
1149
  }, timeoutMs);
1026
1150
  localExecWaiters.set(requestId, {
1151
+ chunks: '',
1152
+ stderr: '',
1027
1153
  resolve: (v) => { clearTimeout(t); resolve(v); },
1028
1154
  reject: (e) => { clearTimeout(t); reject(e); },
1029
1155
  });
@@ -1069,9 +1195,25 @@ function extractLocalPaths(text) {
1069
1195
  }
1070
1196
  return [...new Set(out)];
1071
1197
  }
1198
+ /**
1199
+ * `~` is a path, not just a prefix.
1200
+ *
1201
+ * This matched `~/` ONLY, so a BARE `~` fell through untouched and every
1202
+ * caller then hit the literal directory named "~". MEASURED 2026-09-03 from a
1203
+ * user typing `ls -lha ~`: the model called `list_dir "~"` and the canvas
1204
+ * answered `"~": No such file or directory (os error 2)`, twice, before
1205
+ * giving up and running `ls -lha` in /Users instead — so the user asked for
1206
+ * their home directory and was shown someone else's.
1207
+ *
1208
+ * Handles `~`, `~/x`, and (on Windows, where the model still emits unix-ish
1209
+ * paths) `~\x`. A `~user` form is deliberately NOT expanded: guessing another
1210
+ * account's home is worse than leaving the path alone.
1211
+ */
1072
1212
  function expandUserPath(p) {
1073
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
1074
- return p;
1213
+ const s = String(p ?? '');
1214
+ if (s === '~') return os.homedir();
1215
+ if (s.startsWith('~/') || s.startsWith('~\\')) return path.join(os.homedir(), s.slice(2));
1216
+ return s;
1075
1217
  }
1076
1218
 
1077
1219
  /** Per-agent transcript. sendPrompt is async: accept immediately, zooComplete
@@ -1156,6 +1298,7 @@ function appendLine(agentId, role, text, extra = {}) {
1156
1298
  requestId,
1157
1299
  ...(extra.author && typeof extra.author === 'object' ? { author: extra.author } : {}),
1158
1300
  ...(extra.ephemeral ? { ephemeral: true } : {}),
1301
+ ...(extra.sig ? { sig: String(extra.sig) } : {}),
1159
1302
  };
1160
1303
  }
1161
1304
  t.entries.push(e);
@@ -1324,14 +1467,35 @@ export function restoreAgentWakeups(log = () => {}) {
1324
1467
  return ids.length;
1325
1468
  }
1326
1469
 
1470
+ /**
1471
+ * What to call the machine the bot is driving.
1472
+ *
1473
+ * Both progress strings said "your Mac" unconditionally, so a Windows or Linux
1474
+ * user watched the canvas narrate work on a Mac they do not own. The bot runs
1475
+ * wherever node runs; the noun has to follow `process.platform`.
1476
+ */
1477
+ export function hostNoun(platform = process.platform) {
1478
+ if (platform === 'darwin') return 'Mac';
1479
+ if (platform === 'win32') return 'PC';
1480
+ return 'machine';
1481
+ }
1482
+
1327
1483
  export function formatZooProgress({ step, maxSteps, names, command } = {}) {
1328
1484
  const tools = Array.isArray(names) ? names.filter(Boolean).join(', ') : String(names || 'tools');
1329
1485
  const cmd = command ? ` ${JSON.stringify(String(command).slice(0, 80))}` : '';
1330
- return `Working on your Mac (step ${Number(step) + 1}/${maxSteps || '?'}): ${tools}${cmd}`;
1486
+ return `Working on your ${hostNoun()} (step ${Number(step) + 1}/${maxSteps || '?'}): ${tools}${cmd}`;
1331
1487
  }
1332
1488
 
1333
1489
  /** One canvas line per tool. Asar ingest is append-only — mutating a working
1334
1490
  * bubble is still silence. Keep it short; the model history skips ephemeral. */
1491
+ export function toolResultText(result) {
1492
+ if (result == null) return '';
1493
+ if (typeof result === 'string') return result;
1494
+ if (typeof result === 'number' || typeof result === 'boolean') return String(result);
1495
+ if (Buffer.isBuffer(result)) return result.toString('utf8');
1496
+ try { return JSON.stringify(result); } catch { return String(result); }
1497
+ }
1498
+
1335
1499
  export function formatZooToolLine({ name, args = {}, result } = {}) {
1336
1500
  const detail = args.command || args.path || args.name || args.window
1337
1501
  || args.query || args.url || args.key || args.app || args.every
@@ -1340,7 +1504,7 @@ export function formatZooToolLine({ name, args = {}, result } = {}) {
1340
1504
  const head = detail
1341
1505
  ? `${name} ${JSON.stringify(String(detail).slice(0, 90))}`
1342
1506
  : String(name || 'tool');
1343
- let tail = String(result || '').replace(/\s+/g, ' ').trim();
1507
+ let tail = toolResultText(result).replace(/\s+/g, ' ').trim();
1344
1508
  if (tail.startsWith('{')) {
1345
1509
  try {
1346
1510
  const o = JSON.parse(String(result));
@@ -1452,6 +1616,7 @@ function kickBriefedAgent(agent, log = () => {}) {
1452
1616
 
1453
1617
  function seedBriefOnCanvas(agent, log) {
1454
1618
  if (!agent?.id || !String(agent.brief || '').trim()) return false;
1619
+ if (isGrokRoomAgent(agent) || isGrokRoomMember(agent)) return false;
1455
1620
  const t = agentTranscript(agent.id);
1456
1621
  if (!(t.entries || []).length) {
1457
1622
  const nonce = `oz-brief-${agent.id}`;
@@ -1472,6 +1637,7 @@ let payNudged = false;
1472
1637
  /** The terminal banner, on the canvas too: a new user reads the chat, not the shell. */
1473
1638
  async function seedPayNudge(list, activeId, log = () => {}) {
1474
1639
  if (payNudged || !activeId) return false;
1640
+ if (isGrokRoomAgent({ id: activeId }) || /^roombot-/.test(String(activeId))) return false;
1475
1641
  payNudged = true;
1476
1642
  try {
1477
1643
  const t = agentTranscript(activeId);
@@ -1494,6 +1660,7 @@ async function seedPayNudge(list, activeId, log = () => {}) {
1494
1660
  let shipNudged = false;
1495
1661
  function seedShipNudge(list, activeId) {
1496
1662
  if (shipNudged || !activeId) return false;
1663
+ if (isGrokRoomAgent({ id: activeId }) || /^roombot-/.test(String(activeId))) return false;
1497
1664
  const roster = Array.isArray(list) ? list : [];
1498
1665
  if (roster.some((a) => /^firstmate$/i.test(String(a?.name || '')))) { shipNudged = true; return false; }
1499
1666
  const t = agentTranscript(activeId);
@@ -1514,6 +1681,25 @@ function groupMemberRecords(agentId) {
1514
1681
  return groupMemberIds(agentId).map((mid) => {
1515
1682
  const m = roster.find((x) => x.id === mid);
1516
1683
  return { id: mid, name: String(m?.name || mid) };
1684
+ }).filter((m) => !/^roombot-/.test(m.id) && !isGrokRoomMember({ id: m.id }));
1685
+ }
1686
+
1687
+ export function groupNormText(text) {
1688
+ return stripSpendFooter(String(text || ''))
1689
+ .replace(/^\s*[A-Za-z0-9 _.-]{1,40}:\s*/, '')
1690
+ .toLowerCase()
1691
+ .replace(/\s+/g, ' ')
1692
+ .trim();
1693
+ }
1694
+
1695
+ export function groupReplyIsRepeat(text, recent = []) {
1696
+ const n = groupNormText(text);
1697
+ if (n.length < 40) return false;
1698
+ const head = n.slice(0, 90);
1699
+ return (Array.isArray(recent) ? recent : []).some((prev) => {
1700
+ const p = groupNormText(prev);
1701
+ if (p.length < 40) return false;
1702
+ return p.slice(0, 90) === head || p.startsWith(head) || n.startsWith(p.slice(0, 90));
1517
1703
  });
1518
1704
  }
1519
1705
 
@@ -1522,24 +1708,29 @@ export function groupReplyIsPass(text) {
1522
1708
  if (!s) return true;
1523
1709
  if (/^PASS\b/i.test(s)) return true;
1524
1710
  if (s.length < 20) return true;
1711
+ if (/\binfinite\b.{0,48}\b(recursion|loop|chatter)\b/i.test(s)) return true;
1712
+ if (/\bbots? keep triggering\b/i.test(s)) return true;
1713
+ if (/\beach other'?s responses\b/i.test(s)) return true;
1714
+ if (/\bfeedback loop\b/i.test(s)) return true;
1525
1715
  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
1716
  }
1527
1717
 
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)));
1718
+ const GROUP_MAX_ROUNDS = Math.min(12, Math.max(1, Number(process.env.OZ_GROUP_MAX_ROUNDS || 2)));
1719
+ const GROUP_MAX_CALLS = Math.min(24, Math.max(2, Number(process.env.OZ_GROUP_MAX_CALLS || 6)));
1530
1720
 
1531
- /** Members speak, then keep peek/ponging until they PASS or hit the cap. */
1721
+ /** Members speak once, then at most a short peek. Repeats and loop-talk are PASS. */
1532
1722
  async function runGroupQueue({ agentId, humanPrompt, parsed, nonce, log }) {
1533
1723
  const named = groupMemberRecords(agentId);
1534
1724
  if (!named.length) return false;
1535
1725
  const names = named.map((m) => m.name);
1536
1726
  let calls = 0;
1727
+ const painted = [];
1537
1728
 
1538
1729
  const oneTurn = async (m, phase) => {
1539
1730
  const others = names.filter((n) => n !== m.name).join(', ') || 'the group';
1540
1731
  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}`;
1732
+ ? `You are ${m.name} in a group with ${others}. If you have nothing new, or would repeat another member, reply with exactly PASS. Do not discuss loops, recursion, or that bots are talking to each other. One short in-character line or PASS.`
1733
+ : `You are ${m.name} in a group with ${others}. Answer the human as yourself in a few sentences. Do not speak as the human. Do not prefix with visitor shortnames. Do not discuss infinite loops or recursion unless the human asked about that.\n\nThe human said: ${humanPrompt}`;
1543
1734
  let bit = '';
1544
1735
  try {
1545
1736
  bit = (await zooComplete(persona, log, agentId, parsed)).text;
@@ -1548,7 +1739,7 @@ async function runGroupQueue({ agentId, humanPrompt, parsed, nonce, log }) {
1548
1739
  log(`cursor-backend: group ${phase} ${m.id} failed: ${e.message}`);
1549
1740
  }
1550
1741
  calls += 1;
1551
- const pass = groupReplyIsPass(bit);
1742
+ const pass = groupReplyIsPass(bit) || groupReplyIsRepeat(bit, painted);
1552
1743
  if (!pass) {
1553
1744
  const line = fanoutLine(agentId, 'assistant', bit, {
1554
1745
  clientNonce: nonce,
@@ -1557,6 +1748,11 @@ async function runGroupQueue({ agentId, humanPrompt, parsed, nonce, log }) {
1557
1748
  });
1558
1749
  ssePush('transcript', { ...gatewayEntry(line), agentId });
1559
1750
  bumpAgent(agentId, { preview: bit, notify: true });
1751
+ painted.push(bit);
1752
+ {
1753
+ const live = (cachedAgentList() || []).find((a) => a.id === agentId);
1754
+ if (live?.room?.addr) grokroomSend(live, bit, log, { from: m.name });
1755
+ }
1560
1756
  log(`cursor-backend: group ${phase} ${m.name} seq=${line.seq}`);
1561
1757
  } else {
1562
1758
  log(`cursor-backend: group ${phase} ${m.name} PASS`);
@@ -1569,19 +1765,28 @@ async function runGroupQueue({ agentId, humanPrompt, parsed, nonce, log }) {
1569
1765
  await oneTurn(m, 'speak');
1570
1766
  }
1571
1767
  if (named.length < 2) return true;
1768
+ if (painted.length >= 2 && groupReplyIsRepeat(painted[painted.length - 1], painted.slice(0, -1))) {
1769
+ log(`cursor-backend: group stopped after speak (repeat) calls=${calls}`);
1770
+ return true;
1771
+ }
1572
1772
 
1573
1773
  let round = 0;
1574
1774
  while (round < GROUP_MAX_ROUNDS && calls < GROUP_MAX_CALLS) {
1575
1775
  round += 1;
1576
1776
  let passes = 0;
1777
+ const before = painted.length;
1577
1778
  for (const m of named) {
1578
1779
  if (calls >= GROUP_MAX_CALLS) break;
1579
1780
  if (await oneTurn(m, 'peek')) passes += 1;
1580
1781
  }
1581
- if (passes >= named.length) {
1782
+ if (passes >= named.length || painted.length === before) {
1582
1783
  log(`cursor-backend: group concluded round=${round} calls=${calls}`);
1583
1784
  break;
1584
1785
  }
1786
+ if (painted.length >= 2 && groupReplyIsRepeat(painted[painted.length - 1], painted.slice(0, -1))) {
1787
+ log(`cursor-backend: group stopped peek (repeat) round=${round}`);
1788
+ break;
1789
+ }
1585
1790
  }
1586
1791
  log(`cursor-backend: group queue done rounds=${round} calls=${calls}`);
1587
1792
  return true;
@@ -1603,7 +1808,7 @@ function mergeAgentLists(remote) {
1603
1808
  return sortAgentsByActivity(filterDeleted(out, HOME));
1604
1809
  }
1605
1810
  function gatewayEntry(e) {
1606
- const { seq, pulledRemote, promptRaw, ephemeral, ...rest } = e;
1811
+ const { seq, pulledRemote, promptRaw, ephemeral, sig, ...rest } = e;
1607
1812
  return rest;
1608
1813
  }
1609
1814
 
@@ -1935,6 +2140,9 @@ const MODEL_ALIASES = {
1935
2140
  'x-ai/grok-4.6': 'grok-4.6',
1936
2141
  glm: 'zai-org/glm-5.3-flash',
1937
2142
  'glm-5': 'zai-org/glm-5.3-flash',
2143
+ 'glm-5.2': 'zai-org/GLM-5.2',
2144
+ 'z-ai/glm-5.2': 'zai-org/GLM-5.2',
2145
+ 'zai-org/glm-5.2': 'zai-org/GLM-5.2',
1938
2146
  'glm-5.3': 'zai-org/glm-5.3-flash',
1939
2147
  'glm-5.3-flash': 'zai-org/glm-5.3-flash',
1940
2148
  flash: 'zai-org/glm-5.3-flash',
@@ -1968,6 +2176,11 @@ async function resolveModelId(raw) {
1968
2176
  if (!s) return null;
1969
2177
  const lower = s.toLowerCase().replace(/^\/+/, '');
1970
2178
  if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
2179
+ try {
2180
+ const ids = await zooModelIds();
2181
+ const mapped = resolveModel(s, ids);
2182
+ if (mapped) return mapped;
2183
+ } catch { /* catalog miss: keep explicit slash ids */ }
1971
2184
  if (s.includes('/')) return s;
1972
2185
  // No vendor prefix and no alias: the LIVE catalog decides, not this file.
1973
2186
  // The zoo serves ids with no slash at all (abliterated-model*), and a
@@ -2062,33 +2275,131 @@ async function writeLocalBytes(abs, bytes, log) {
2062
2275
  fs.writeFileSync(abs, buf);
2063
2276
  return `wrote ${abs} (${buf.length} bytes)`;
2064
2277
  }
2278
+ /** `serverMessage` is `agent.v1.ExecServerMessage` as protobuf-es JSON — a oneof,
2279
+ * so the key IS the case name. Two wrong shapes before this one:
2280
+ * `{command, cwd}` flat -> "No handler found for server message of type undefined"
2281
+ * `{shellArgs: {...}}` -> "No handler found for server message of type shellArgs"
2282
+ * `shellArgs` is a real case on the wire (field 2), but `buildLocalExecManager`
2283
+ * in Grok Bot 0.36.0's dist/local-exec-daemon/main.cjs registers exactly FOUR
2284
+ * handlers: shellStreamArgs, backgroundShellSpawnArgs, readArgs, lsArgs. The
2285
+ * one-shot shell case is simply not wired into the local-exec lane — the
2286
+ * streaming one is, and both carry the same `agent.v1.ShellArgs` payload.
2287
+ * `authorizedBy*` are read as `=== true` by the daemon's approval gate; without
2288
+ * them an unattended exec has nothing to approve it.
2289
+ *
2290
+ * `timeout` is MILLISECONDS. The daemon reads it through a function named
2291
+ * `resolveShellTimeoutMs` and uses the value verbatim, so the plausible-looking
2292
+ * `timeout: 120` (seconds, as lib/podagent.mjs had it) aborts the shell after
2293
+ * 120 ms: `echo hi; pwd` came back exit 0 with empty stdout and `ls -lha ~`
2294
+ * died mid-stream with "Cannot read properties of undefined (reading '3')".
2295
+ * 0 means "use the daemon's default", not "no timeout". */
2296
+ export function localExecFrame(command, cwd, timeoutMs = 120_000) {
2297
+ return {
2298
+ kind: 'exec',
2299
+ requestId: randomUUID(),
2300
+ approvalId: randomUUID(),
2301
+ authorizedByStanding: true,
2302
+ authorizedByApproval: true,
2303
+ serverMessage: {
2304
+ shellStreamArgs: { command: String(command), workingDirectory: cwd, timeout: timeoutMs },
2305
+ },
2306
+ };
2307
+ }
2308
+
2309
+ /** `agent.v1.ShellStream` off an ExecClientMessage: `{stdout:{data}}`,
2310
+ * `{stderr:{data}}`, `{exit:{code, cwd}}`. `data` is a proto string (T=9), not
2311
+ * bytes — no base64 decode. */
2312
+ /** protobuf-es serializes a oneof either as its field name (`{stdout:{…}}`,
2313
+ * what `toJson` emits) or as the runtime pair (`{case:"stdout",value:{…}}`)
2314
+ * when the object is passed straight to JSON.stringify. The daemon declares
2315
+ * `message` as an opaque any, so accept both at either level. */
2316
+ function oneofCase(node, name) {
2317
+ if (!node || typeof node !== 'object') return null;
2318
+ if (node[name] && typeof node[name] === 'object') return node[name];
2319
+ if (node.case === name && node.value && typeof node.value === 'object') return node.value;
2320
+ return null;
2321
+ }
2322
+
2323
+ export function shellStreamOf(frame) {
2324
+ return oneofCase(frame && frame.message, 'shellStream');
2325
+ }
2326
+
2327
+ /** `agent.v1.OutputLocation { file_path, size_bytes, line_count }`. protobuf-es
2328
+ * JSON is camelCase, but snake_case survives a raw passthrough. */
2329
+ export function exitOutputPath(exit) {
2330
+ const loc = exit && (exit.outputLocation || exit.output_location);
2331
+ const p = loc && (loc.filePath || loc.file_path);
2332
+ return typeof p === 'string' && p ? p : '';
2333
+ }
2334
+
2335
+ /** exec used to hardcode `/bin/zsh -lc`: on Linux that is usually ENOENT and on
2336
+ * Windows there is no POSIX shell at all, so every exec came back as a spawn
2337
+ * error instead of output. */
2338
+ export function execShell(command, platform = process.platform) {
2339
+ if (platform === 'win32') {
2340
+ return { file: process.env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', command] };
2341
+ }
2342
+ if (platform === 'darwin') return { file: '/bin/zsh', args: ['-lc', command] };
2343
+ for (const sh of ['/bin/bash', '/usr/bin/bash', '/bin/zsh']) {
2344
+ try { if (fs.existsSync(sh)) return { file: sh, args: ['-lc', command] }; } catch { /* keep looking */ }
2345
+ }
2346
+ return { file: '/bin/sh', args: ['-c', command] };
2347
+ }
2348
+
2349
+ /** Pull a command's spooled output back over the same SSE. The file sits under
2350
+ * the daemon's own terminalsFolder, so it is inside the local-exec root and
2351
+ * the download is not refused; a failure here is never fatal — the caller
2352
+ * still has the exit code. */
2353
+ async function localExecSpooledOutput(filePath, log = () => {}) {
2354
+ try {
2355
+ const got = await localExecAsk({ kind: 'download', path: filePath }, 20000);
2356
+ return toolResultText(got.text || got.message || '');
2357
+ } catch (e) {
2358
+ log(`cursor-backend: local-exec output spool ${filePath}: ${e.message}`);
2359
+ return '';
2360
+ }
2361
+ }
2362
+
2065
2363
  async function execLocal(command, cwd, log) {
2066
2364
  let dir = cwd ? expandUserPath(cwd) : os.homedir();
2067
2365
  try {
2068
2366
  if (!fs.statSync(dir).isDirectory()) dir = os.homedir();
2069
2367
  } catch {
2070
- // Node reports spawn /bin/zsh ENOENT when cwd is missing — not a missing shell.
2368
+ // Node reports spawn <shell> ENOENT when cwd is missing — not a missing shell.
2071
2369
  try { fs.mkdirSync(dir, { recursive: true }); } catch { dir = os.homedir(); }
2072
2370
  }
2073
2371
  if (localExecSse.size > 0) {
2074
2372
  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}` : '';
2373
+ // Daemon-side budget stays under our own wait so a slow command reports the
2374
+ // shell's error, not our generic "is Grok Bot Helper connected?".
2375
+ const got = await localExecAsk(localExecFrame(command, dir, 55_000), 60000);
2376
+ let out = toolResultText(got.stdout || got.message || '');
2377
+ if (!out && got.outputPath) out = await localExecSpooledOutput(got.outputPath, log);
2378
+ const errText = toolResultText(got.stderr);
2379
+ const err = errText ? `\nstderr:\n${errText}` : '';
2081
2380
  return `${out}${err}`.trim() || `(exit ${got.exitCode ?? 0})`;
2082
2381
  }
2083
- log(`cursor-backend: exec cwd=${dir} ${JSON.stringify(String(command).slice(0, 80))}`);
2382
+ const sh = execShell(command);
2383
+ log(`cursor-backend: exec cwd=${dir} sh=${sh.file} ${JSON.stringify(String(command).slice(0, 80))}`);
2084
2384
  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)';
2385
+ let stdout = '';
2386
+ let stderr = '';
2387
+ try {
2388
+ ({ stdout, stderr } = await execFileAsync(sh.file, sh.args, {
2389
+ cwd: dir,
2390
+ timeout: execMs,
2391
+ maxBuffer: 2 * 1024 * 1024,
2392
+ env: process.env,
2393
+ windowsHide: true,
2394
+ }));
2395
+ } catch (e) {
2396
+ // A nonzero exit (grep with no match, a failing build) still has output the
2397
+ // model needs — bubbling the Error loses it and paints only "ERROR".
2398
+ stdout = toolResultText(e.stdout);
2399
+ stderr = toolResultText(e.stderr) || e.message;
2400
+ if (!stdout && !stderr) throw e;
2401
+ }
2402
+ return `${toolResultText(stdout)}${stderr ? `\nstderr:\n${toolResultText(stderr)}` : ''}`.trim() || '(no output)';
2092
2403
  }
2093
2404
 
2094
2405
  const LOCAL_TOOLS = [
@@ -2600,13 +2911,19 @@ async function runLocalTool(name, args, log, ctx = {}) {
2600
2911
  }
2601
2912
  if (name === 'list_dir') {
2602
2913
  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 || '';
2914
+ try {
2915
+ if (localExecSse.size > 0) {
2916
+ // Route through execLocal so a listing that the daemon spooled to a
2917
+ // terminal file is fetched too, instead of painting an empty frame.
2918
+ const text = String(await execLocal(`ls -la ${JSON.stringify(abs)}`, os.homedir(), log)).trim();
2919
+ if (text && !/^\(exit \d+\)$/.test(text)) return text;
2920
+ }
2921
+ const rows = fs.readdirSync(abs, { withFileTypes: true })
2922
+ .map((e) => `${e.isDirectory() ? 'd' : '-'} ${e.name}`);
2923
+ return rows.join('\n') || '(empty)';
2924
+ } catch (e) {
2925
+ return `list_dir failed ${abs}: ${e.message}`;
2606
2926
  }
2607
- return fs.readdirSync(abs, { withFileTypes: true })
2608
- .map((e) => `${e.isDirectory() ? 'd' : '-'} ${e.name}`)
2609
- .join('\n');
2610
2927
  }
2611
2928
  if (name === 'exec') {
2612
2929
  return await execLocal(String(args.command || ''), args.cwd, log);
@@ -2906,7 +3223,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2906
3223
  if (mediaKind) return await mediaTurn({ model, kind: mediaKind, prompt: spoken, log, onProgress: opts.onProgress, signal: opts.signal });
2907
3224
  }
2908
3225
  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…');
3226
+ if (typeof opts.onProgress === 'function') opts.onProgress(`Working on your ${hostNoun()}…`);
2910
3227
 
2911
3228
  const images = [];
2912
3229
  const textFiles = [];
@@ -3159,7 +3476,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
3159
3476
  name,
3160
3477
  path: args.path,
3161
3478
  command: args.command ? String(args.command).slice(0, 120) : undefined,
3162
- note: String(result).slice(0, 200),
3479
+ note: toolResultText(result).slice(0, 200),
3163
3480
  });
3164
3481
  if (typeof opts.onProgress === 'function') {
3165
3482
  opts.onProgress(formatZooToolLine({ name, args, result }));
@@ -3167,7 +3484,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
3167
3484
  messages.push({
3168
3485
  role: 'tool',
3169
3486
  tool_call_id: c.id,
3170
- content: String(result).slice(0, 120000),
3487
+ content: toolResultText(result).slice(0, 120000),
3171
3488
  });
3172
3489
  }
3173
3490
  if (pendingVision.length) {
@@ -3323,12 +3640,20 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3323
3640
  jsonSend(res, { ok: true, ...state });
3324
3641
  return true;
3325
3642
  }
3643
+ if (name === 'ozShare') {
3644
+ const list = cachedAgentList() || [];
3645
+ const id = String(focusedAgentId || '');
3646
+ const agent = list.find((a) => a.id === id) || null;
3647
+ const share = groupShareState({ agent, rooms: grokroomSync._rooms });
3648
+ jsonSend(res, { ok: true, agentId: id, ...share });
3649
+ return true;
3650
+ }
3326
3651
  if (name === 'ozRevive') {
3327
3652
  let parsed = {};
3328
3653
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
3329
3654
  const every = String(parsed.every || '5m');
3330
3655
  const wakes = readWakeups(HOME);
3331
- const list = (cachedAgentList() || []).filter((a) => a?.id && !a.isGroup);
3656
+ const list = (cachedAgentList() || []).filter((a) => a?.id && !a.isGroup && !isGrokRoomAgent(a) && !isGrokRoomMember(a));
3332
3657
  const armed = [];
3333
3658
  const already = [];
3334
3659
  for (const a of list) {
@@ -3345,10 +3670,48 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3345
3670
  return true;
3346
3671
  }
3347
3672
  if (name === 'getHostStatus') {
3348
- jsonSend(res, { status: 'ready', ready: true, state: 'ready', hostStatus: 'ready' });
3673
+ const host = {
3674
+ hostVersion: '0.36.0',
3675
+ latestHostVersion: '0.36.0',
3676
+ hostUpdateAvailable: false,
3677
+ isBusy: false,
3678
+ capabilities: [],
3679
+ };
3680
+ jsonSend(res, { status: 'ok', value: host, ...host });
3349
3681
  log('cursor-backend: -> getHostStatus ready');
3350
3682
  return true;
3351
3683
  }
3684
+ if (name === 'resolveAgentCreation') {
3685
+ const route = { kind: 'box' };
3686
+ jsonSend(res, { status: 'ok', value: route, ...route });
3687
+ log('cursor-backend: -> resolveAgentCreation box');
3688
+ return true;
3689
+ }
3690
+ if (name === 'getForeverBoxStatus' || name === 'ensureForeverBox') {
3691
+ let parsed = {};
3692
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
3693
+ const box = {
3694
+ agentId: String(parsed.id || parsed.agentId || 'openzoo'),
3695
+ state: 'running',
3696
+ };
3697
+ jsonSend(res, { status: 'ok', value: box, ...box });
3698
+ log(`cursor-backend: -> ${name} agentId=${box.agentId} state=${box.state}`);
3699
+ return true;
3700
+ }
3701
+ if (name === 'getBoxSecretsStatus') {
3702
+ const secrets = { keys: [], isApplied: false, lastAppliedAtMs: null };
3703
+ jsonSend(res, { status: 'ok', value: secrets, ...secrets });
3704
+ return true;
3705
+ }
3706
+ if (name === 'isEgressTunnelAvailable') {
3707
+ jsonSend(res, { status: 'ok', value: false });
3708
+ return true;
3709
+ }
3710
+ if (name === 'getAgentChannels') {
3711
+ const channels = { manifests: [], connections: [] };
3712
+ jsonSend(res, { status: 'ok', value: channels, ...channels });
3713
+ return true;
3714
+ }
3352
3715
  if (name === 'uploadAttachment' || name === 'readAttachmentImage'
3353
3716
  || name === 'readAttachmentText' || name === 'readAttachmentChunk') {
3354
3717
  let parsed = {};
@@ -3412,7 +3775,8 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3412
3775
  parsed = { ...src, id: undefined, name: `${src.name || 'chat'} copy` };
3413
3776
  }
3414
3777
  const agent = mintLocalAgent(parsed);
3415
- jsonSend(res, { agent, id: agent.id, ...agent });
3778
+ const created = { agent, id: agent.id, ...agent };
3779
+ jsonSend(res, { status: 'ok', value: created, ...created });
3416
3780
  pushCreatedAgent(agent);
3417
3781
  log(`cursor-backend: createAgent local id=${agent.id} name=${JSON.stringify(agent.name)}`);
3418
3782
  return true;
@@ -3430,7 +3794,8 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3430
3794
  let parsed = {};
3431
3795
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
3432
3796
  const id = String(parsed.id || parsed.agentId || '');
3433
- jsonSend(res, { id, isIntroductionInFlight: false });
3797
+ const kick = { id, isIntroductionInFlight: false };
3798
+ jsonSend(res, { status: 'ok', value: kick, ...kick });
3434
3799
  log(`cursor-backend: kickstartAgent id=${id || '?'}`);
3435
3800
  return true;
3436
3801
  }
@@ -3438,15 +3803,18 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3438
3803
  let parsed = {};
3439
3804
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
3440
3805
  const memberIds = [].concat(parsed.memberAgentIds || parsed.memberIds || []).map(String).filter(Boolean);
3441
- const agent = mintLocalAgent({
3806
+ const raw = mintLocalAgent({
3442
3807
  ...parsed,
3443
3808
  isGroup: true,
3444
3809
  memberIds,
3445
3810
  name: parsed.name || parsed.title || 'group',
3446
3811
  });
3447
- jsonSend(res, { agent, id: agent.id, ...agent });
3812
+ const agent = mintLocalAgent(attachOnchainRoom(raw, HOME));
3813
+ const created = { agent, id: agent.id, ...agent };
3814
+ jsonSend(res, { status: 'ok', value: created, ...created });
3448
3815
  pushCreatedAgent(agent);
3449
- log(`cursor-backend: createGroup local id=${agent.id} members=${memberIds.length} name=${JSON.stringify(agent.name)}`);
3816
+ log(`cursor-backend: createGroup onchain id=${agent.id} share=${agent.room?.web || ''} members=${memberIds.length} name=${JSON.stringify(agent.name)}`);
3817
+ void paintGroupShareHint(agent, log);
3450
3818
  return true;
3451
3819
  }
3452
3820
  if (name === 'setGroupMembers') {
@@ -3521,7 +3889,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3521
3889
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
3522
3890
  const ids = new Set([].concat(parsed.ids || parsed.id || []).map(String));
3523
3891
  const next = (cachedAgentList() || []).filter((a) => !ids.has(a.id));
3524
- addDeletedIds(HOME, [...ids]);
3892
+ addDeletedIds(HOME, [...ids].filter((id) => !/^(room-|roombot-)/.test(id)));
3525
3893
  saveAgents(next);
3526
3894
  for (const id of ids) {
3527
3895
  transcripts.delete(id);
@@ -3544,7 +3912,15 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3544
3912
  return true;
3545
3913
  }
3546
3914
  if (name === 'listAgents' || name === 'countAgents' || name === 'searchAgents') {
3547
- const list = rosterForEvent(mergeAgentLists([]), agentActivity);
3915
+ let raw = mergeAgentLists([]);
3916
+ let minted = false;
3917
+ raw = raw.map((a) => {
3918
+ if (!a?.isGroup || a.hidden || /^room-/.test(String(a.id || '')) || agentShareUrl(a)) return a;
3919
+ minted = true;
3920
+ return shapeAgent(attachOnchainRoom(a, HOME));
3921
+ });
3922
+ if (minted) saveAgents(raw.map(shapeAgent));
3923
+ const list = rosterForEvent(raw, agentActivity);
3548
3924
  if (name === 'countAgents') {
3549
3925
  jsonSend(res, list.length);
3550
3926
  log(`cursor-backend: countAgents local n=${list.length} account=${activeAccountId || 'none'}`);
@@ -3594,6 +3970,20 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3594
3970
  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
3971
  lastSendEchoId = String(nonce);
3596
3972
  jsonSend(res, { accepted: true });
3973
+ const roomAgent = grokroomLookup(agentId);
3974
+ if (roomAgent && /^room-/.test(agentId)) {
3975
+ const userLine = fanoutLine(agentId, 'user', uiText, {
3976
+ clientNonce: nonce,
3977
+ requestId: nonce,
3978
+ richText: visitor ? prefixVisitorRichText(parsed.richText, visitor.shortname, uiText) : parsed.richText,
3979
+ promptRaw: prompt,
3980
+ });
3981
+ noteFocus(agentId);
3982
+ bumpAgent(agentId, { preview: prompt, notify: false, select: false });
3983
+ ssePush('transcript', { ...gatewayEntry(userLine), agentId });
3984
+ grokroomSend(roomAgent, prompt, log);
3985
+ return true;
3986
+ }
3597
3987
  if (!visitor && wantsWakeupCron(prompt)) {
3598
3988
  const cron = scheduleAgentWakeup(agentId, { every: '5m' });
3599
3989
  log(`cursor-backend: never-stop cron agent=${agentId} every=${cron.everySec}s`);
@@ -3608,6 +3998,12 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3608
3998
  noteFocus(agentId);
3609
3999
  bumpAgent(agentId, { preview: prompt, notify: false });
3610
4000
  ssePush('transcript', { ...gatewayEntry(userLine), agentId });
4001
+ {
4002
+ const live = (cachedAgentList() || []).find((a) => a.id === agentId);
4003
+ if (live?.room?.addr) grokroomSend(live, prompt, log);
4004
+ }
4005
+ const joinAddr = /^\s*\/join\b/i.test(prompt) ? grokroomAddrFromShare(prompt)
4006
+ : (/^\s*https?:\/\/openzoo\.fun\/r\//i.test(prompt) ? grokroomAddrFromShare(prompt) : '');
3611
4007
  const modelCmd = /^\s*\/model(?:\s+(\S+))?\s*$/i.exec(prompt || '');
3612
4008
  setImmediate(async () => {
3613
4009
  let text = '';
@@ -3629,11 +4025,27 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3629
4025
  text = `model set to ${id}`;
3630
4026
  }
3631
4027
  }
4028
+ } else if (joinAddr) {
4029
+ grouped = true;
4030
+ const joined = joinSharedGroup(joinAddr);
4031
+ if (!joined) {
4032
+ text = `could not join — not an openzoo.fun/r/<addr> share`;
4033
+ } else {
4034
+ noteFocus(joined.id);
4035
+ ssePush('agent-upserted', { agent: joined, activeAgentId: joined.id });
4036
+ ssePush('agents', { agents: rosterForEvent(cachedAgentList() || [], agentActivity), activeAgentId: joined.id });
4037
+ void paintGroupShareHint(joined, log);
4038
+ text = `joined ${joined.name}\n${joined.room?.web || ''}\nIn your OpenZoo Bot: add your bots to this group. They speak here; remote nicks only show up as people, they are not local bots.\nAnyone else: /join ${joined.room?.web || joinAddr}`;
4039
+ }
3632
4040
  } else {
3633
- const members = groupMemberIds(agentId);
4041
+ const live = (cachedAgentList() || []).find((a) => a.id === agentId);
4042
+ const members = groupMemberRecords(agentId);
4043
+ const isSharedRoom = !!(live?.room?.addr || isGrokRoomAgent(live || { id: agentId }));
3634
4044
  if (members.length) {
3635
4045
  grouped = true;
3636
4046
  await runGroupQueue({ agentId, humanPrompt: prompt, parsed, nonce, log });
4047
+ } else if (isSharedRoom) {
4048
+ grouped = true;
3637
4049
  } else {
3638
4050
  const z = await zooComplete(prompt, log, agentId, parsed, {
3639
4051
  signal: turn.signal,
@@ -3664,6 +4076,10 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3664
4076
  const line = fanoutLine(agentId, 'assistant', text, { clientNonce: nonce, requestId: nonce });
3665
4077
  ssePush('transcript', { ...gatewayEntry(line), agentId });
3666
4078
  bumpAgent(agentId, { preview: text, notify: true });
4079
+ {
4080
+ const live = (cachedAgentList() || []).find((a) => a.id === agentId);
4081
+ if (live?.room?.addr && text) grokroomSend(live, text, log);
4082
+ }
3667
4083
  log(`cursor-backend: sendPrompt done agent=${agentId} seq=${line.seq} text=${JSON.stringify(text.slice(0, 220))}`);
3668
4084
  } else {
3669
4085
  log(`cursor-backend: sendPrompt group done agent=${agentId}`);
@@ -3679,6 +4095,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3679
4095
  noteFocus(id);
3680
4096
  tailedAgents.add(id);
3681
4097
  const t = agentTranscript(id);
4098
+ if (isGrokRoomAgent({ id }) || /^roombot-/.test(id)) t.pulledRemote = true;
3682
4099
  if (!t.pulledRemote && realPod?.agent && !useHouseRoster()) {
3683
4100
  const remote = await podJson('/api/getAgentTranscriptTail', {
3684
4101
  id, agentId: id, limit: 200, beforeSeq: parsed.beforeSeq,
@@ -3718,7 +4135,10 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3718
4135
  }
3719
4136
 
3720
4137
  const stubs = {
3721
- getHostStatus: { status: 'ready', ready: true, state: 'ready', hostStatus: 'ready' },
4138
+ getHostStatus: {
4139
+ hostVersion: '0.36.0', latestHostVersion: '0.36.0',
4140
+ hostUpdateAvailable: false, isBusy: false, capabilities: [],
4141
+ },
3722
4142
  getHostSettings: { settings: {} },
3723
4143
  setHostSettings: { ok: true },
3724
4144
  setBoxSecrets: { ok: true },
@@ -3729,12 +4149,16 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3729
4149
  getAgentTranscriptTail: { tail: '', lines: [], dropped: false, ok: true },
3730
4150
  getTeachRecordingStatus: { recording: false },
3731
4151
  getTrays: [],
3732
- isGlobalSearchEnabled: { enabled: false },
3733
- isEgressTunnelAvailable: { available: false },
4152
+ isGlobalSearchEnabled: false,
4153
+ isEgressTunnelAvailable: false,
3734
4154
  getSharingState: { sharing: false },
3735
4155
  getBotTemplateExportPolicy: { allowed: true },
3736
4156
  getAgentAutomations: { automations: [] },
3737
- getForeverBoxStatus: { enabled: false },
4157
+ getForeverBoxStatus: { agentId: 'openzoo', state: 'running' },
4158
+ ensureForeverBox: { agentId: 'openzoo', state: 'running' },
4159
+ getBoxSecretsStatus: { keys: [], isApplied: false, lastAppliedAtMs: null },
4160
+ getAgentChannels: { manifests: [], connections: [] },
4161
+ resolveAgentCreation: { kind: 'box' },
3738
4162
  getSubagents: { subagents: [] },
3739
4163
  getAsyncTasks: { tasks: [] },
3740
4164
  getAgentWorkflows: { workflows: [] },
@@ -3799,14 +4223,377 @@ function respond(req, res, method, models) {
3799
4223
  res.end(bodyProto);
3800
4224
  }
3801
4225
 
4226
+ // ===== GROKROOM-BRIDGE v1 (overlay) =====
4227
+ // Rooms from 127.0.0.1:4799 as native Grok Bot group threads.
4228
+ // One room = one sidebar row (# main / # bots / # random / # lobby).
4229
+ // Each remote sender is a hidden bubble-member (no sidebar row). Sends POST a
4230
+ // signed ed25519 envelope to /submit — the chain is the log. Additive: any
4231
+ // failure here must never break zoo chat. Interval starts from startCursorBackend
4232
+ // so importing this module in tests does not poll.
4233
+ const GROKROOM_API = process.env.OZ_GROKROOM_URL || 'http://127.0.0.1:4799';
4234
+ const GROKROOM_PAYMASTER = (process.env.OZ_GROKROOM_PAYMASTER || 'https://openzoo.fun').replace(/\/$/, '');
4235
+ const GROKROOM_ID_FILE = path.join(HOME, '.openzoo', 'grokroom-identity.json');
4236
+ const GROKROOM_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
4237
+ const GROKROOM_HANDLE = (process.env.OZ_ROOM_HANDLE || process.env.USER || 'anon')
4238
+ .replace(/[^A-Za-z0-9 _.'-]/g, '').trim().slice(0, 24) || 'anon';
4239
+ const GROKROOM_B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
4240
+
4241
+ function grokroomB58(buf) {
4242
+ const digits = [0];
4243
+ for (const byte of buf) {
4244
+ let carry = byte;
4245
+ for (let i = 0; i < digits.length; i++) { carry += digits[i] << 8; digits[i] = carry % 58; carry = (carry / 58) | 0; }
4246
+ while (carry) { digits.push(carry % 58); carry = (carry / 58) | 0; }
4247
+ }
4248
+ let out = '';
4249
+ for (const b of buf) { if (b === 0) out += '1'; else break; }
4250
+ return out + digits.reverse().map((d) => GROKROOM_B58[d]).join('');
4251
+ }
4252
+
4253
+ let grokroomIdCache = null;
4254
+ function grokroomIdentity() {
4255
+ if (grokroomIdCache) return grokroomIdCache;
4256
+ let id = null;
4257
+ try {
4258
+ const j = JSON.parse(fs.readFileSync(GROKROOM_ID_FILE, 'utf8'));
4259
+ if (j.pub && j.seed) id = j;
4260
+ } catch { /* first run */ }
4261
+ if (!id) {
4262
+ const pair = generateKeyPairSync('ed25519');
4263
+ const rawPub = pair.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32);
4264
+ const rawSeed = pair.privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(-32);
4265
+ id = { pub: grokroomB58(Buffer.from(rawPub)), seed: Buffer.from(rawSeed).toString('base64') };
4266
+ try {
4267
+ fs.mkdirSync(path.dirname(GROKROOM_ID_FILE), { recursive: true });
4268
+ fs.writeFileSync(GROKROOM_ID_FILE, JSON.stringify(id, null, 2));
4269
+ } catch { /* in-memory is fine */ }
4270
+ }
4271
+ grokroomIdCache = id;
4272
+ return id;
4273
+ }
4274
+
4275
+ async function grokroomPost(roomId, body, { from: fromRaw } = {}) {
4276
+ const id = grokroomIdentity();
4277
+ const ts = Date.now();
4278
+ const from = grokroomFromHandle(fromRaw || GROKROOM_HANDLE, GROKROOM_HANDLE);
4279
+ const pub = id.pub;
4280
+ const room = String(roomId || '');
4281
+ const canonical = JSON.stringify({ body, from, pub, room, ts });
4282
+ const key = createPrivateKey({ key: Buffer.concat([GROKROOM_PKCS8_PREFIX, Buffer.from(id.seed, 'base64')]), format: 'der', type: 'pkcs8' });
4283
+ const sig = grokroomB58(cryptoSign(null, Buffer.from(canonical, 'utf8'), key));
4284
+ const payload = JSON.stringify({ room, from, pub, body, ts, sig });
4285
+ const endpoints = [];
4286
+ if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(room)) {
4287
+ endpoints.push(GROKROOM_PAYMASTER + '/api/room/submit');
4288
+ }
4289
+ endpoints.push(GROKROOM_API + '/submit');
4290
+ let last = new Error('no submit endpoint');
4291
+ for (const url of endpoints) {
4292
+ try {
4293
+ const r = await fetch(url, {
4294
+ method: 'POST', headers: { 'content-type': 'application/json' },
4295
+ body: payload,
4296
+ signal: AbortSignal.timeout(30000),
4297
+ });
4298
+ const j = await r.json().catch(() => ({}));
4299
+ if (r.ok && j.ok !== false) return j;
4300
+ last = new Error(j.error || ('submit HTTP ' + r.status));
4301
+ } catch (e) {
4302
+ last = e;
4303
+ }
4304
+ }
4305
+ throw last;
4306
+ }
4307
+
4308
+ function grokroomLookup(agentId) {
4309
+ const list = cachedAgentList() || [];
4310
+ return list.find((a) => a.id === agentId && isGrokRoomAgent(a)) || null;
4311
+ }
4312
+
4313
+ function grokroomUpsertAgent(parsed, { select = false } = {}) {
4314
+ const id = String(parsed.id);
4315
+ const prev = (cachedAgentList() || []).find((a) => a.id === id) || {};
4316
+ const agent = shapeAgent({
4317
+ ...prev,
4318
+ ...parsed,
4319
+ id,
4320
+ createdAt: prev.createdAt || parsed.createdAt || Date.now(),
4321
+ updatedAt: Date.now(),
4322
+ });
4323
+ const list = cachedAgentList() || [];
4324
+ const i = list.findIndex((a) => a.id === id);
4325
+ if (i < 0) list.unshift(agent);
4326
+ else list[i] = { ...list[i], ...agent };
4327
+ saveAgents(list.map(shapeAgent));
4328
+ agentTranscript(id);
4329
+ if (agent.hidden) {
4330
+ const visible = rosterForEvent(list, agentActivity);
4331
+ ssePush('agents', { agents: visible, activeAgentId: focusedAgentId || visible[0]?.id || id });
4332
+ } else {
4333
+ pushCreatedAgent(agent, { select });
4334
+ }
4335
+ return agent;
4336
+ }
4337
+
4338
+ function grokroomMemberEnsure(group, name, { speak = false } = {}) {
4339
+ const mid = grokroomMemberId(name);
4340
+ const list = cachedAgentList() || [];
4341
+ let member = list.find((a) => a.id === mid);
4342
+ if (!member) {
4343
+ member = grokroomUpsertAgent({
4344
+ id: mid,
4345
+ name: String(name || 'anon').slice(0, 80),
4346
+ hidden: true,
4347
+ hiddenFromSidebar: true,
4348
+ brief: '',
4349
+ description: '',
4350
+ });
4351
+ }
4352
+ if (!speak) return group;
4353
+ const have = group.memberIds || [];
4354
+ if (have.includes(mid)) return group;
4355
+ return grokroomUpsertAgent({
4356
+ id: group.id,
4357
+ name: group.name,
4358
+ isGroup: true,
4359
+ memberIds: [...have, mid],
4360
+ brief: '',
4361
+ description: '',
4362
+ room: group.room,
4363
+ });
4364
+ }
4365
+
4366
+ function joinSharedGroup(addr, { name } = {}) {
4367
+ const web = grokroomShareUrl(addr);
4368
+ if (!web) return null;
4369
+ const list = cachedAgentList() || [];
4370
+ const existing = list.find((a) => a?.room?.addr === addr);
4371
+ if (existing) return existing;
4372
+ const raw = mintLocalAgent({
4373
+ isGroup: true,
4374
+ name: String(name || `group ${addr.slice(0, 6)}`).slice(0, 80),
4375
+ memberIds: [],
4376
+ room: { id: addr, addr, web },
4377
+ });
4378
+ return mintLocalAgent(bindOnchainRoom(raw, addr, HOME));
4379
+ }
4380
+
4381
+ function paintShareIfMissing(agent, log = () => {}) {
4382
+ const url = agentShareUrl(agent);
4383
+ if (!agent?.id || !url) return false;
4384
+ const t = agentTranscript(agent.id);
4385
+ const has = (t.entries || []).some((e) => /openzoo\.fun\/r\//.test(String(e?.content || '')));
4386
+ if (has) return false;
4387
+ const nonce = `oz-share-${agent.id}`;
4388
+ const line = fanoutLine(agent.id, 'assistant', 'Share this room: ' + url, {
4389
+ clientNonce: nonce,
4390
+ requestId: nonce,
4391
+ });
4392
+ ssePush('transcript', { ...gatewayEntry(line), agentId: agent.id });
4393
+ log(`cursor-backend: group share painted agent=${agent.id}`);
4394
+ return true;
4395
+ }
4396
+
4397
+ async function paintGroupShareHint(agent, log = () => {}) {
4398
+ if (!agent?.id) return;
4399
+ let rooms = grokroomSync._rooms;
4400
+ if (!Array.isArray(rooms)) {
4401
+ try {
4402
+ const st = await fetch(GROKROOM_API + '/state', { signal: AbortSignal.timeout(4000) }).then((r) => r.json());
4403
+ rooms = Array.isArray(st.rooms) ? st.rooms : [];
4404
+ grokroomSync._rooms = rooms;
4405
+ } catch { rooms = []; }
4406
+ }
4407
+ const share = groupShareState({ agent, rooms });
4408
+ let text = '';
4409
+ if (share.url) {
4410
+ text = 'Share this room on openzoo.fun: ' + share.url;
4411
+ } else if (share.rooms.length) {
4412
+ text = 'This group is local to this house. Public grokrooms — send this URL:\n'
4413
+ + share.rooms.map((r) => `${r.name} ${r.url}`).join('\n');
4414
+ } else {
4415
+ text = 'This group is local to this house. To share on openzoo.fun, use a grokroom (# main / # bots / # random / # lobby). Start ~/grokroom on :4799 if those rows are missing.';
4416
+ }
4417
+ const line = fanoutLine(agent.id, 'assistant', text, {});
4418
+ ssePush('transcript', { ...gatewayEntry(line), agentId: agent.id });
4419
+ log(`cursor-backend: group share hint agent=${agent.id}`);
4420
+ }
4421
+
4422
+ function grokroomEnsureGroup(roomId, name, addr) {
4423
+ const id = grokroomAgentId(roomId);
4424
+ if (!id) return null;
4425
+ const list = cachedAgentList() || [];
4426
+ const prev = list.find((a) => a.id === id || a.room?.id === roomId);
4427
+ const nextAddr = addr || prev?.room?.addr || '';
4428
+ const web = grokroomShareUrl(nextAddr);
4429
+ if (prev) {
4430
+ const need = (nextAddr && prev.room && prev.room.addr !== nextAddr)
4431
+ || (web && prev.room && prev.room.web !== web);
4432
+ const agent = need
4433
+ ? grokroomUpsertAgent({ ...prev, room: { id: roomId, addr: nextAddr, web } }, { select: false })
4434
+ : prev;
4435
+ paintShareIfMissing(agent);
4436
+ return agent;
4437
+ }
4438
+ const agent = grokroomUpsertAgent({
4439
+ id,
4440
+ name: name || ('# ' + roomId),
4441
+ isGroup: true,
4442
+ memberIds: [],
4443
+ brief: '',
4444
+ description: '',
4445
+ room: { id: roomId, addr: nextAddr, web },
4446
+ }, { select: false });
4447
+ const nonce = `oz-room-${id}`;
4448
+ grokroomMemberEnsure(agent, 'room');
4449
+ const text = 'Linked to ' + agent.name + ' on Solana testnet. Every message here is a signed memo on-chain — the chain is the log.'
4450
+ + (web ? ' Share: ' + web : '')
4451
+ + (nextAddr ? ' Explorer: https://solscan.io/account/' + nextAddr + '?cluster=testnet' : '');
4452
+ const line = fanoutLine(id, 'assistant', text, {
4453
+ clientNonce: nonce,
4454
+ requestId: nonce,
4455
+ author: { kind: 'agent', id: grokroomMemberId('room'), name: 'room' },
4456
+ });
4457
+ ssePush('transcript', { ...gatewayEntry(line), agentId: id });
4458
+ return agent;
4459
+ }
4460
+
4461
+ function grokroomSend(agent, body, log = () => {}, { from } = {}) {
4462
+ const text = stripSpendFooter(String(body || '')).trim().slice(0, 400);
4463
+ const roomKey = agent?.room?.addr || agent?.room?.id;
4464
+ if (!text || !roomKey) return;
4465
+ if (/^PASS\b/i.test(text)) return;
4466
+ grokroomPost(roomKey, text, { from }).then((j) => {
4467
+ log(`cursor-backend: grokroom posted room=${String(roomKey).slice(0, 8)}… sig=${String(j?.sig || '').slice(0, 12)} from=${from || GROKROOM_HANDLE}`);
4468
+ }).catch((e) => {
4469
+ grokroomMemberEnsure(agent, 'room');
4470
+ const line = fanoutLine(agent.id, 'assistant', '⚠ could not reach the room: ' + e.message, {
4471
+ author: { kind: 'agent', id: grokroomMemberId('room'), name: 'room' },
4472
+ });
4473
+ ssePush('transcript', { ...gatewayEntry(line), agentId: agent.id });
4474
+ log(`cursor-backend: grokroom send fail ${roomKey} ${e.message}`);
4475
+ });
4476
+ }
4477
+
4478
+ function grokroomIngestMsgs(group, msgs, log = () => {}) {
4479
+ if (!group?.id || !Array.isArray(msgs) || !msgs.length) return 0;
4480
+ let agent = group;
4481
+ const names = new Set();
4482
+ for (const m of msgs) {
4483
+ const from = String(m.from || m.node || '').slice(0, 24);
4484
+ if (from) names.add(from);
4485
+ }
4486
+ for (const from of names) agent = grokroomMemberEnsure(agent, from);
4487
+ const t = agentTranscript(agent.id);
4488
+ const seen = new Set((t.entries || []).filter((h) => h.sig).map((h) => h.sig));
4489
+ const recent = (t.entries || []).slice(-80).map((h) => String(h.content || ''));
4490
+ let dirty = 0;
4491
+ for (const m of msgs) {
4492
+ const msig = m.sig || m.txSig || m.envsig || (m.pub && m.ts ? m.pub + ':' + m.ts : null);
4493
+ if (!msig || seen.has(msig)) continue;
4494
+ seen.add(msig);
4495
+ const from = String(m.from || m.node || 'anon').slice(0, 24);
4496
+ const body = String(m.body || m.text || '');
4497
+ if (!body) continue;
4498
+ if (recent.includes(body)) continue;
4499
+ agent = grokroomMemberEnsure(agent, from);
4500
+ const mid = grokroomMemberId(from);
4501
+ const line = fanoutLine(agent.id, 'assistant', body, {
4502
+ author: { kind: 'agent', id: mid, name: from },
4503
+ sig: msig,
4504
+ });
4505
+ ssePush('transcript', { ...gatewayEntry(line), agentId: agent.id });
4506
+ dirty += 1;
4507
+ }
4508
+ if (dirty) {
4509
+ bumpAgent(agent.id, { preview: msgs[msgs.length - 1]?.body || '', notify: true, select: false });
4510
+ log(`cursor-backend: grokroom ${agent.room?.id || agent.id} +${dirty} bubbles`);
4511
+ }
4512
+ return dirty;
4513
+ }
4514
+
4515
+ async function grokroomSyncPaymaster(log = () => {}) {
4516
+ const list = cachedAgentList() || [];
4517
+ const seen = new Set();
4518
+ for (const a of list) {
4519
+ const addr = a?.room?.addr;
4520
+ if (!grokroomShareUrl(addr) || seen.has(addr)) continue;
4521
+ seen.add(addr);
4522
+ try {
4523
+ const r = await fetch(`${GROKROOM_PAYMASTER}/api/room/history?addr=${encodeURIComponent(addr)}`, {
4524
+ signal: AbortSignal.timeout(8000),
4525
+ });
4526
+ if (!r.ok) continue;
4527
+ const j = await r.json().catch(() => ({}));
4528
+ const msgs = Array.isArray(j.msgs) ? j.msgs : [];
4529
+ grokroomIngestMsgs(a, msgs, log);
4530
+ } catch (e) {
4531
+ log(`cursor-backend: grokroom history ${String(addr).slice(0, 8)}… ${e.message}`);
4532
+ }
4533
+ }
4534
+ }
4535
+
4536
+ let grokroomBusy = false;
4537
+ async function grokroomSync(log = () => {}) {
4538
+ if (grokroomBusy) return;
4539
+ grokroomBusy = true;
4540
+ try {
4541
+ try {
4542
+ const stRes = await fetch(GROKROOM_API + '/state', { signal: AbortSignal.timeout(4000) });
4543
+ if (stRes.ok) {
4544
+ const st = await stRes.json();
4545
+ const rooms = Array.isArray(st.rooms) ? st.rooms : [];
4546
+ grokroomSync._rooms = rooms;
4547
+ if (!grokroomSync._logged) {
4548
+ grokroomSync._logged = true;
4549
+ log(`cursor-backend: grokroom sync tick: rooms=${rooms.map((r) => r.id).join(',')}`);
4550
+ }
4551
+ for (const r of rooms) {
4552
+ const roomId = r.id || r.name;
4553
+ if (!roomId) continue;
4554
+ const group = grokroomEnsureGroup(roomId, r.name, r.addr || r.address);
4555
+ if (!group) continue;
4556
+ const feedRes = await fetch(GROKROOM_API + '/feed?room=' + encodeURIComponent(roomId), { signal: AbortSignal.timeout(4000) });
4557
+ if (!feedRes.ok) continue;
4558
+ const feed = await feedRes.json();
4559
+ grokroomIngestMsgs(group, Array.isArray(feed.messages) ? feed.messages : [], log);
4560
+ }
4561
+ }
4562
+ } catch (e) {
4563
+ if (!grokroomSync._localMiss) {
4564
+ grokroomSync._localMiss = true;
4565
+ log(`cursor-backend: grokroom local :4799 down (${e.message}); paymaster only`);
4566
+ }
4567
+ }
4568
+ await grokroomSyncPaymaster(log);
4569
+ } catch (e) {
4570
+ log(`cursor-backend: grokroom sync: ${e.message}`);
4571
+ } finally {
4572
+ grokroomBusy = false;
4573
+ }
4574
+ }
4575
+
4576
+ function startGrokroomBridge(log = () => {}) {
4577
+ if (startGrokroomBridge._on) return;
4578
+ if (process.env.OZ_GROKROOM === '0') {
4579
+ log('cursor-backend: grokroom bridge off (OZ_GROKROOM=0)');
4580
+ return;
4581
+ }
4582
+ startGrokroomBridge._on = true;
4583
+ const tick = () => grokroomSync(log);
4584
+ setTimeout(tick, 800);
4585
+ setInterval(tick, 4000);
4586
+ }
4587
+
3802
4588
  /**
3803
4589
  * Start the impersonation server. Binds `port` (default 8443 — a privileged
3804
4590
  * 443->port redirect is installed alongside the /etc/hosts entry, so this stays
3805
4591
  * unprivileged). Returns { server, port }.
3806
4592
  */
3807
- export function startCursorBackend({ port = 8443, models, log = () => {} } = {}) {
4593
+ export async function startCursorBackend({ port = 8443, models, log = () => {} } = {}) {
3808
4594
  restoreAgentWakeups(log);
3809
- const { cert, key } = ensureCert(log);
4595
+ startGrokroomBridge(log);
4596
+ const { cert, key } = await ensureCert(log);
3810
4597
  const certPem = fs.readFileSync(cert);
3811
4598
  const keyPem = fs.readFileSync(key);
3812
4599
  const secureContext = tls.createSecureContext({ cert: certPem, key: keyPem });
@@ -3895,6 +4682,21 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
3895
4682
  return;
3896
4683
  }
3897
4684
  log(`cursor-backend: #${conns} ${req.method} ${full} ct=${ct} body=${body.length}b`);
4685
+ // HIJACK LOGIN. Measured Linux 2026-09-03: GET /auth/poll -> empty-ok
4686
+ // (`{}`) so SandBackendLoginManager threw bad_200 and the UI stayed on
4687
+ // "Log in with Cursor". Poll must return accessToken+refreshToken JWT.
4688
+ // /oauth/token refresh is the same session — do NOT passthrough fake
4689
+ // tokens to real api2. Sniff/passthru keeps the real issuer.
4690
+ {
4691
+ const fakeAuth = fakeCursorAuthReply(full, { passthrough: sniffOn() || process.env.OPENZOO_PASSTHRU === '1' });
4692
+ if (fakeAuth) {
4693
+ if (req.method === 'OPTIONS') { res.writeHead(204, CORS); res.end(); return; }
4694
+ res.writeHead(200, { 'content-type': fakeAuth.contentType, ...CORS });
4695
+ res.end(fakeAuth.body);
4696
+ log(`cursor-backend: -> FAKE ${fakeAuth.kind} logged-in (no Cursor account)`);
4697
+ return;
4698
+ }
4699
+ }
3898
4700
  // OAUTH MUST HIT THE REAL ISSUER. empty-ok on /oauth/token is the
3899
4701
  // "Reconnecting to your computer" / edge/handler-failed loop: the app
3900
4702
  // retries the same 509b body forever and never reaches AvailableModels