openzoo 0.50.37 → 0.50.39

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.
@@ -38,8 +38,14 @@ import tls from 'node:tls';
38
38
  import { randomUUID } from 'node:crypto';
39
39
  import { encodeAvailableModels, encodeForMethod, encodeEnsureSandBox, encodeGetGrokBotSendStatus, decodeProtoFields, unwrapConnect } from './cursorapi.js';
40
40
  import {
41
- accountPodPath, accountAgentsPath, rosterForAccount,
41
+ accountPodPath, accountAgentsPath, rosterForAccount, rosterForEvent,
42
+ readHouseRoster, houseAgentsPath, shapeAgent,
42
43
  } from './grokbotAccount.js';
44
+ import { formatSpendFooter, mergeTurnProof } from './spendProof.js';
45
+ import { prefixVisitorRichText } from './grokbotweb.js';
46
+ import {
47
+ ingestUpload, lookupUpload, readUploadChunk, readUploadImage, readUploadText,
48
+ } from './grokbotUploads.js';
43
49
 
44
50
  const TLS_DIR = path.join(os.homedir(), '.openzoo', 'cursor-tls');
45
51
  const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
@@ -343,31 +349,30 @@ activeAccountId = realPod?.accountId || null;
343
349
  if (activeAccountId) migrateLegacyPod(activeAccountId);
344
350
 
345
351
  function agentsPath() {
346
- if (!activeAccountId) return null;
347
- return accountAgentsPath(HOME, activeAccountId) || AGENTS_FILE;
352
+ if (!activeAccountId) return houseAgentsPath(HOME);
353
+ return accountAgentsPath(HOME, activeAccountId) || houseAgentsPath(HOME);
354
+ }
355
+ /** Cafe/web hijack: one house tray for every visitor. Electron sniff still
356
+ * talks to the live 1340 so a second Cursor login stays isolated. */
357
+ function useHouseRoster() {
358
+ return Boolean(process.env.OZ_HIJACK_POD) && !sniffOn();
348
359
  }
349
360
  function loadAgents() {
350
- // No live account → no tray. Serving the machine-global file here is how a
351
- // second household login saw the wrong chats (or none of theirs).
352
- if (!activeAccountId) return null;
353
- const scoped = readJsonFile(accountAgentsPath(HOME, activeAccountId));
354
- if (Array.isArray(scoped)) return scoped;
355
- const legacyPod = readJsonFile(POD_FILE);
356
- if (legacyPod?.accountId === activeAccountId) {
357
- const a = readJsonFile(AGENTS_FILE);
358
- return Array.isArray(a) ? a : null;
359
- }
360
- return null;
361
+ const house = readHouseRoster(HOME, activeAccountId);
362
+ return house.length ? house : [];
361
363
  }
362
364
  function saveAgents(a) {
363
- if (!Array.isArray(a) || !a.length || !activeAccountId) return;
364
- const p = agentsPath();
365
- if (p) writeJsonFile(p, a);
365
+ if (!Array.isArray(a)) return;
366
+ // Persist [] too — otherwise delete-all is a no-op and the 65 land back.
367
+ writeJsonFile(houseAgentsPath(HOME), a);
368
+ if (activeAccountId) {
369
+ const p = accountAgentsPath(HOME, activeAccountId);
370
+ if (p) writeJsonFile(p, a);
371
+ }
366
372
  }
367
373
  function cachedAgentList() {
368
374
  const a = loadAgents();
369
- if (a?.length) return a;
370
- return null;
375
+ return a.length ? a : null;
371
376
  }
372
377
 
373
378
  function sniffOn() { return process.env.OPENZOO_SNIFF === '1'; }
@@ -621,10 +626,10 @@ async function fetchEnsureSandBox(req, body, log) {
621
626
  try {
622
627
  const remote = await podJson('/api/listAgents', {}, log);
623
628
  if (Array.isArray(remote) && remote.length) {
624
- const merged = mergeAgentLists(remote);
629
+ const merged = rosterForEvent(mergeAgentLists(remote), agentActivity);
625
630
  saveAgents(merged);
626
631
  const active = focusedAgentId || merged[0]?.id;
627
- ssePush('agents', { agents: merged.slice(0, 80).map(stampActivity), activeAgentId: active });
632
+ ssePush('agents', { agents: merged, activeAgentId: active });
628
633
  log(`cursor-backend: discovered roster n=${merged.length} account=${realPod.accountId}`);
629
634
  }
630
635
  } catch (e) {
@@ -781,23 +786,32 @@ async function proxyPodHttp(req, res, full, body, log) {
781
786
  podStale = true;
782
787
  log(`cursor-backend: 1340 ${cap.status} ${path0} — pod stale, local`);
783
788
  if (path0 === '/api/listAgents') {
789
+ if (useHouseRoster()) {
790
+ jsonSend(res, rosterForEvent(loadAgents(), agentActivity));
791
+ return true;
792
+ }
784
793
  const cached = rosterForAccount({
785
794
  liveAccountId: activeAccountId,
786
795
  cachedAccountId: activeAccountId,
787
796
  cached: loadAgents(),
797
+ fallback: loadAgents(),
788
798
  });
789
799
  if (cached.length) {
790
- jsonSend(res, mergeAgentLists(cached));
800
+ jsonSend(res, rosterForEvent(mergeAgentLists(cached), agentActivity));
791
801
  return true;
792
802
  }
793
803
  }
794
804
  return false;
795
805
  }
796
806
  if (path0 === '/api/listAgents' && cap.status === 200) {
807
+ if (useHouseRoster()) {
808
+ jsonSend(res, rosterForEvent(loadAgents(), agentActivity));
809
+ return true;
810
+ }
797
811
  try {
798
812
  const parsed = JSON.parse(String(inflateBody(cap.buf, cap.respHeaders)));
799
813
  if (Array.isArray(parsed)) {
800
- const merged = mergeAgentLists(parsed);
814
+ const merged = rosterForEvent(mergeAgentLists(parsed), agentActivity);
801
815
  saveAgents(merged);
802
816
  jsonSend(res, merged);
803
817
  log(`cursor-backend: listAgents 200 merged n=${merged.length} account=${activeAccountId || '?'}`);
@@ -810,9 +824,10 @@ async function proxyPodHttp(req, res, full, body, log) {
810
824
  liveAccountId: activeAccountId,
811
825
  cachedAccountId: activeAccountId,
812
826
  cached: loadAgents(),
827
+ fallback: loadAgents(),
813
828
  });
814
- if (cached.length) {
815
- jsonSend(res, mergeAgentLists(cached));
829
+ if (cached.length || useHouseRoster()) {
830
+ jsonSend(res, rosterForEvent(mergeAgentLists(cached), agentActivity));
816
831
  log(`cursor-backend: listAgents ${cap.status} — cached ${cached.length} named agents account=${activeAccountId}`);
817
832
  return true;
818
833
  }
@@ -877,6 +892,13 @@ function noteFocus(id) {
877
892
  a.unreadCount = 0;
878
893
  agentActivity.set(id, a);
879
894
  }
895
+ function topConversationId(list) {
896
+ const arr = Array.isArray(list) ? list : [];
897
+ return focusedAgentId && arr.some((a) => a.id === focusedAgentId)
898
+ ? focusedAgentId
899
+ : (arr[0]?.id || null);
900
+ }
901
+
880
902
  function stampActivity(agent) {
881
903
  const a = agentActivity.get(agent.id) || {};
882
904
  const updatedAt = a.updatedAt || agent.updatedAt || agent.createdAt || 0;
@@ -908,13 +930,23 @@ function bumpAgent(id, { preview = '', notify = true } = {}) {
908
930
  const list = cachedAgentList() || [];
909
931
  const idx = list.findIndex((x) => x.id === id);
910
932
  const base = idx >= 0 ? list[idx] : { id, name: id };
911
- const agent = stampActivity({ ...base, updatedAt: now });
933
+ const agent = shapeAgent(stampActivity({ ...base, updatedAt: now }));
912
934
  if (idx >= 0) list[idx] = agent;
913
935
  else list.unshift(agent);
914
- saveAgents(sortAgentsByActivity(list));
915
- // asar handleGatewaySseEvent: channel agent-upserted → {agent, activeAgentId}
916
- ssePush('agent-upserted', { agent, activeAgentId: focusedAgentId || id });
917
- ssePush('agents', { agents: sortAgentsByActivity(cachedAgentList() || []).slice(0, 80).map(stampActivity), activeAgentId: focusedAgentId || id });
936
+ saveAgents(sortAgentsByActivity(list.map(shapeAgent)));
937
+ pushCreatedAgent(agent);
938
+ }
939
+
940
+ /** asar ingestAgentsEvent does Ae.agents.map — `{action:"created"}` has no
941
+ * agents[] so the picker/tray throws and looks empty. ingestAgentUpserted
942
+ * needs {agent, activeAgentId}. Same payload bumpAgent already sends. */
943
+ function pushCreatedAgent(agent, { select = true } = {}) {
944
+ if (!agent || !agent.id) return;
945
+ if (select) focusedAgentId = agent.id;
946
+ const active = focusedAgentId || agent.id;
947
+ const list = rosterForEvent(cachedAgentList() || [], agentActivity);
948
+ ssePush('agent-upserted', { agent, activeAgentId: active });
949
+ ssePush('agents', { agents: list, activeAgentId: active });
918
950
  }
919
951
 
920
952
  /** Grok Bot Helper daemon: GET /local-exec/requests is SSE, POST /local-exec/responses
@@ -1086,6 +1118,7 @@ function appendLine(agentId, role, text, extra = {}) {
1086
1118
  timestampMs: ts,
1087
1119
  ...(nonce ? { clientNonce: nonce } : {}),
1088
1120
  requestId,
1121
+ ...(extra.promptRaw != null ? { promptRaw: String(extra.promptRaw) } : {}),
1089
1122
  };
1090
1123
  } else {
1091
1124
  e = {
@@ -1095,6 +1128,7 @@ function appendLine(agentId, role, text, extra = {}) {
1095
1128
  message: { type: 'text', content: String(text || '') },
1096
1129
  timestampMs: ts,
1097
1130
  requestId,
1131
+ ...(extra.author && typeof extra.author === 'object' ? { author: extra.author } : {}),
1098
1132
  };
1099
1133
  }
1100
1134
  t.entries.push(e);
@@ -1108,29 +1142,106 @@ function fanoutLine(primaryId, role, text, extra = {}) {
1108
1142
  }
1109
1143
  function mintLocalAgent(parsed = {}) {
1110
1144
  const id = String(parsed.id || randomUUID());
1111
- const name = String(parsed.name || parsed.title || 'new chat');
1112
- const agent = {
1145
+ const prev = (cachedAgentList() || []).find((a) => a.id === id) || {};
1146
+ const agent = shapeAgent({
1147
+ ...prev,
1148
+ ...parsed,
1113
1149
  id,
1114
- name,
1115
- description: String(parsed.description || ''),
1116
- title: String(parsed.title || name),
1117
- origin: parsed.origin || 'user',
1118
- createdAt: Date.now(),
1150
+ createdAt: prev.createdAt || parsed.createdAt || Date.now(),
1119
1151
  updatedAt: Date.now(),
1120
- avatarShape: parsed.avatarShape || null,
1121
- avatarColor: parsed.avatarColor || null,
1122
- path: `/local/${id}`,
1123
- };
1152
+ });
1124
1153
  const list = cachedAgentList() || [];
1125
1154
  if (!list.some((a) => a.id === id)) list.unshift(agent);
1126
1155
  else {
1127
1156
  const i = list.findIndex((a) => a.id === id);
1128
1157
  list[i] = { ...list[i], ...agent };
1129
1158
  }
1130
- saveAgents(list);
1159
+ saveAgents(list.map(shapeAgent));
1131
1160
  agentTranscript(id);
1132
1161
  return agent;
1133
1162
  }
1163
+ function groupMemberIds(agentId) {
1164
+ const a = (cachedAgentList() || []).find((x) => x.id === agentId);
1165
+ if (!a?.isGroup) return [];
1166
+ return (a.memberIds || []).map(String).filter(Boolean);
1167
+ }
1168
+ function groupMemberRecords(agentId) {
1169
+ const roster = cachedAgentList() || [];
1170
+ return groupMemberIds(agentId).map((mid) => {
1171
+ const m = roster.find((x) => x.id === mid);
1172
+ return { id: mid, name: String(m?.name || mid) };
1173
+ });
1174
+ }
1175
+
1176
+ export function groupReplyIsPass(text) {
1177
+ const s = stripSpendFooter(String(text || '')).replace(/^\s*[A-Za-z0-9 _.-]{1,40}:\s*/, '').trim();
1178
+ if (!s) return true;
1179
+ if (/^PASS\b/i.test(s)) return true;
1180
+ if (s.length < 20) return true;
1181
+ 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);
1182
+ }
1183
+
1184
+ const GROUP_MAX_ROUNDS = Math.min(12, Math.max(2, Number(process.env.OZ_GROUP_MAX_ROUNDS || 8)));
1185
+ const GROUP_MAX_CALLS = Math.min(24, Math.max(4, Number(process.env.OZ_GROUP_MAX_CALLS || 16)));
1186
+
1187
+ /** Members speak, then keep peek/ponging until they PASS or hit the cap. */
1188
+ async function runGroupQueue({ agentId, humanPrompt, parsed, nonce, log }) {
1189
+ const named = groupMemberRecords(agentId);
1190
+ if (!named.length) return false;
1191
+ const names = named.map((m) => m.name);
1192
+ let calls = 0;
1193
+
1194
+ const oneTurn = async (m, phase) => {
1195
+ const others = names.filter((n) => n !== m.name).join(', ') || 'the group';
1196
+ const persona = phase === 'peek'
1197
+ ? `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.`
1198
+ : `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}`;
1199
+ let bit = '';
1200
+ try {
1201
+ bit = (await zooComplete(persona, log, agentId, parsed)).text;
1202
+ } catch (e) {
1203
+ bit = `openzoo error (${m.name}): ${e.message}`;
1204
+ log(`cursor-backend: group ${phase} ${m.id} failed: ${e.message}`);
1205
+ }
1206
+ calls += 1;
1207
+ const pass = groupReplyIsPass(bit);
1208
+ if (!pass) {
1209
+ const line = fanoutLine(agentId, 'assistant', bit, {
1210
+ clientNonce: nonce,
1211
+ requestId: nonce,
1212
+ author: { kind: 'agent', id: m.id, name: m.name },
1213
+ });
1214
+ ssePush('transcript', { ...gatewayEntry(line), agentId });
1215
+ bumpAgent(agentId, { preview: bit, notify: true });
1216
+ log(`cursor-backend: group ${phase} ${m.name} seq=${line.seq}`);
1217
+ } else {
1218
+ log(`cursor-backend: group ${phase} ${m.name} PASS`);
1219
+ }
1220
+ return pass;
1221
+ };
1222
+
1223
+ for (const m of named) {
1224
+ if (calls >= GROUP_MAX_CALLS) break;
1225
+ await oneTurn(m, 'speak');
1226
+ }
1227
+ if (named.length < 2) return true;
1228
+
1229
+ let round = 0;
1230
+ while (round < GROUP_MAX_ROUNDS && calls < GROUP_MAX_CALLS) {
1231
+ round += 1;
1232
+ let passes = 0;
1233
+ for (const m of named) {
1234
+ if (calls >= GROUP_MAX_CALLS) break;
1235
+ if (await oneTurn(m, 'peek')) passes += 1;
1236
+ }
1237
+ if (passes >= named.length) {
1238
+ log(`cursor-backend: group concluded round=${round} calls=${calls}`);
1239
+ break;
1240
+ }
1241
+ }
1242
+ log(`cursor-backend: group queue done rounds=${round} calls=${calls}`);
1243
+ return true;
1244
+ }
1134
1245
  function mergeAgentLists(remote) {
1135
1246
  const local = cachedAgentList() || [];
1136
1247
  const seen = new Set();
@@ -1143,12 +1254,15 @@ function mergeAgentLists(remote) {
1143
1254
  return sortAgentsByActivity(out);
1144
1255
  }
1145
1256
  function gatewayEntry(e) {
1146
- const { seq, pulledRemote, ...rest } = e;
1257
+ const { seq, pulledRemote, promptRaw, ...rest } = e;
1147
1258
  return rest;
1148
1259
  }
1149
1260
 
1150
1261
  function stripSpendFooter(s) {
1151
- return String(s || '').replace(/\n{2,}this call \$[\d.]+[\s\S]*$/i, '').trimEnd();
1262
+ return String(s || '')
1263
+ .replace(/\n{2,}::oz-spend::[\s\S]*$/i, '')
1264
+ .replace(/\n{2,}this call \$[\d.]+[\s\S]*$/i, '')
1265
+ .trimEnd();
1152
1266
  }
1153
1267
 
1154
1268
  function entryPlainText(v) {
@@ -1176,16 +1290,26 @@ function historyMessages(agentId, currentPrompt) {
1176
1290
  text = entryPlainText(e.message ?? e.content ?? e.text);
1177
1291
  role = 'assistant';
1178
1292
  text = stripSpendFooter(text);
1293
+ const who = e.author && e.author.name ? String(e.author.name).trim() : '';
1294
+ if (who && text && !text.toLowerCase().startsWith(`${who.toLowerCase()}:`)) {
1295
+ text = `${who}: ${text}`;
1296
+ }
1179
1297
  } else if (e.kind === 'message' || e.role === 'user' || e.kind === 'user') {
1298
+ // Keep `shortname: ` on the turn the model sees -- stripping it made every
1299
+ // visitor look like one anonymous "you" (rex asked "am I still rex").
1180
1300
  text = entryPlainText(e.content ?? e.message ?? e.text ?? e.prompt);
1301
+ if (!String(text || '').trim() && e.promptRaw != null) text = String(e.promptRaw);
1181
1302
  role = 'user';
1182
1303
  }
1183
1304
  text = String(text || '').trim();
1184
1305
  if (!role || !text) continue;
1185
1306
  out.push({ role, content: text });
1186
1307
  }
1187
- while (out.length && out[out.length - 1].role === 'user' && String(out[out.length - 1].content).trim() === cur) {
1188
- out.pop();
1308
+ const curBare = stripVisitorLabel(cur).trim();
1309
+ while (out.length && out[out.length - 1].role === 'user') {
1310
+ const last = String(out[out.length - 1].content).trim();
1311
+ if (last === cur || stripVisitorLabel(last).trim() === curBare) out.pop();
1312
+ else break;
1189
1313
  }
1190
1314
  let chars = 0;
1191
1315
  const kept = [];
@@ -1268,6 +1392,33 @@ function ingestRemoteEntries(agentId, entries) {
1268
1392
  return incoming.length;
1269
1393
  }
1270
1394
 
1395
+ function visitorFromSend(parsed) {
1396
+ const v = parsed && parsed.visitor;
1397
+ if (!v || typeof v !== 'object') return null;
1398
+ const id = String(v.id || '').trim();
1399
+ const shortname = String(v.shortname || v.name || '').trim().toLowerCase();
1400
+ const color = String(v.color || '').trim();
1401
+ if (!id || !/^[a-z][a-z0-9]{1,15}$/.test(shortname)) return null;
1402
+ return { id, shortname, color };
1403
+ }
1404
+
1405
+ /** UI stores `shortname: prompt`; zooComplete / history must see the raw line. */
1406
+ function stripVisitorLabel(text) {
1407
+ const s = String(text || '');
1408
+ const m = s.match(/^([a-z][a-z0-9]{1,15}):\s+/);
1409
+ if (!m) return s;
1410
+ if (/^(https?|ftp|mailto|file|data)$/i.test(m[1])) return s;
1411
+ return s.slice(m[0].length);
1412
+ }
1413
+
1414
+ function labeledVisitorPrompt(visitor, prompt) {
1415
+ const p = String(prompt || '');
1416
+ if (!visitor?.shortname) return p;
1417
+ const prefix = `${visitor.shortname}: `;
1418
+ if (p.startsWith(prefix)) return p;
1419
+ return prefix + p;
1420
+ }
1421
+
1271
1422
  function promptFromSendBody(raw) {
1272
1423
  let obj = raw;
1273
1424
  if (Buffer.isBuffer(raw) || typeof raw === 'string') {
@@ -1292,7 +1443,7 @@ function promptFromSendBody(raw) {
1292
1443
 
1293
1444
  let walletUsdCache = { usd: null, at: 0 };
1294
1445
  async function walletUsdCached() {
1295
- if (walletUsdCache.usd != null && Date.now() - walletUsdCache.at < 60_000) return walletUsdCache.usd;
1446
+ if (walletUsdCache.usd != null && Date.now() - walletUsdCache.at < 8_000) return walletUsdCache.usd;
1296
1447
  try {
1297
1448
  const { affordableUsd } = await import('./info.js');
1298
1449
  const n = await Promise.race([
@@ -1327,13 +1478,16 @@ async function zooSpendOverlay(data) {
1327
1478
  const bal = Number.isFinite(wallet) && wallet > 0.004
1328
1479
  ? wallet
1329
1480
  : (Number.isFinite(credit) && credit > 0.004 ? credit : null);
1330
- const lines = ['', ''];
1331
- if (x.billedUsd != null) {
1332
- lines.push(`this call $${Number(x.billedUsd).toFixed(6)} · OpenRouter $${Number(x.directUsd || 0).toFixed(6)}`);
1333
- }
1334
- const balTxt = bal != null ? ` · balance $${bal.toFixed(2)}` : '';
1335
- lines.push(`spent $${spent.toFixed(4)}${balTxt} · OpenRouter would $${would.toFixed(4)} · saved $${saved.toFixed(4)} (${pct.toFixed(0)}%)`);
1336
- return lines.join('\n');
1481
+ return formatSpendFooter({
1482
+ billedUsd: x.billedUsd,
1483
+ directUsd: x.directUsd,
1484
+ spent,
1485
+ would,
1486
+ saved,
1487
+ pct,
1488
+ balance: bal,
1489
+ x402: x,
1490
+ });
1337
1491
  }
1338
1492
 
1339
1493
  const MODELS_PATH = path.join(os.homedir(), '.openzoo', 'grokbot-models.json');
@@ -1567,12 +1721,34 @@ function zooTextFromMessage(msg, data) {
1567
1721
  async function zooComplete(prompt, log, agentId, parsed = {}) {
1568
1722
  const model = currentModel(agentId);
1569
1723
  const helper = localExecSse.size > 0;
1724
+ const visitor = visitorFromSend(parsed);
1725
+ const chatOnly = Boolean(visitor);
1726
+ const spoken = visitor && !/^You are /.test(String(prompt || ''))
1727
+ ? labeledVisitorPrompt(visitor, prompt)
1728
+ : prompt;
1570
1729
  await ensureTranscriptHydrated(agentId, log);
1571
- log(`cursor-backend: zoo POST :8402 model=${model} helper=${helper ? localExecSse.size : 0} hist=${historyMessages(agentId, prompt).length} ${JSON.stringify((prompt || '').slice(0, 60))}`);
1730
+ 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))}`);
1572
1731
 
1573
1732
  const images = [];
1574
1733
  const textFiles = [];
1575
1734
  for (const { raw, name } of attachmentList(parsed, prompt)) {
1735
+ const uploaded = lookupUpload(raw);
1736
+ if (uploaded) {
1737
+ if (uploaded.mime && uploaded.mime.startsWith('image/')) {
1738
+ images.push({
1739
+ path: raw,
1740
+ mime: uploaded.mime,
1741
+ dataUrl: `data:${uploaded.mime};base64,${uploaded.buf.toString('base64')}`,
1742
+ });
1743
+ log(`cursor-backend: attached image ${raw} ${uploaded.mime} ${uploaded.bytes}b`);
1744
+ } else {
1745
+ textFiles.push({ path: raw, abs: uploaded.abs, text: uploaded.buf.toString('utf8') });
1746
+ }
1747
+ continue;
1748
+ }
1749
+ // Public visitors must not trigger local file reads on this Mac.
1750
+ // Uploads above are bytes they posted to us, not a host path.
1751
+ if (chatOnly) continue;
1576
1752
  const abs = expandUserPath(raw);
1577
1753
  try {
1578
1754
  const buf = await readLocalBytes(abs, log);
@@ -1595,20 +1771,29 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1595
1771
  const messages = [
1596
1772
  {
1597
1773
  role: 'system',
1598
- content: [
1599
- `You are ${model} served through openzoo inside Grok Bot.`,
1600
- `You HAVE local tools on the user's computer via ${via}.`,
1601
- 'Tools: read_file, write_file, exec, list_dir. USE THEM. Write files to disk instead of pasting giant HTML into chat.',
1602
- 'Pasted images arrive as attachments — you can see them when present. Do not claim you cannot see images if they are in this turn.',
1603
- 'Never claim you lack filesystem access or local-exec. If a tool errors, report the error.',
1604
- '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.',
1605
- '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.',
1606
- 'A spend footer is appended after your reply by the host — ignore it.',
1607
- '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.',
1608
- ].join(' '),
1774
+ content: chatOnly
1775
+ ? [
1776
+ `You are ${model} served through openzoo inside Grok Bot.`,
1777
+ 'This is a public visitor chat. You do not have filesystem, shell, or local-exec access on the host Mac.',
1778
+ 'Reply in chat only. Do not claim you will write files, run commands, or use local tools.',
1779
+ '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.',
1780
+ 'A spend footer is appended after your reply by the host -- ignore it.',
1781
+ '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.',
1782
+ ].join(' ')
1783
+ : [
1784
+ `You are ${model} served through openzoo inside Grok Bot.`,
1785
+ `You HAVE local tools on the user's computer via ${via}.`,
1786
+ 'Tools: read_file, write_file, exec, list_dir. USE THEM. Write files to disk instead of pasting giant HTML into chat.',
1787
+ 'Pasted images arrive as attachments — you can see them when present. Do not claim you cannot see images if they are in this turn.',
1788
+ 'Never claim you lack filesystem access or local-exec. If a tool errors, report the error.',
1789
+ '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.',
1790
+ '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.',
1791
+ 'A spend footer is appended after your reply by the host — ignore it.',
1792
+ '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.',
1793
+ ].join(' '),
1609
1794
  },
1610
1795
  ];
1611
- for (const m of historyMessages(agentId, prompt)) messages.push(m);
1796
+ for (const m of historyMessages(agentId, spoken)) messages.push(m);
1612
1797
  if (textFiles.length) {
1613
1798
  const bits = textFiles.map((a) => (
1614
1799
  a.error
@@ -1622,7 +1807,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1622
1807
  for (const img of images) {
1623
1808
  userContent.push({ type: 'image_url', image_url: { url: img.dataUrl } });
1624
1809
  }
1625
- userContent.push({ type: 'text', text: prompt || (images.length ? '(see attached image)' : 'hello') });
1810
+ userContent.push({ type: 'text', text: spoken || (images.length ? '(see attached image)' : 'hello') });
1626
1811
  messages.push({
1627
1812
  role: 'user',
1628
1813
  content: userContent.length === 1 && userContent[0].type === 'text'
@@ -1633,6 +1818,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1633
1818
  const maxTok = Number(process.env.OPENZOO_ASK_MAX_TOKENS || 8192);
1634
1819
  const maxSteps = Math.min(64, Math.max(8, Number(process.env.OPENZOO_ASK_TOOL_STEPS || 32)));
1635
1820
  let lastData = {};
1821
+ let turnX402 = {};
1636
1822
  let text = '';
1637
1823
  const usedTools = [];
1638
1824
  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.';
@@ -1687,17 +1873,29 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1687
1873
  };
1688
1874
  let keepGoingNudge = 0;
1689
1875
  for (let step = 0; step < maxSteps; step++) {
1690
- const { r, data } = await zooPost({
1691
- model,
1692
- messages,
1693
- tools: LOCAL_TOOLS,
1694
- tool_choice: 'auto',
1695
- max_tokens: maxTok,
1696
- });
1876
+ const payload = { model, messages, max_tokens: maxTok };
1877
+ if (!chatOnly) {
1878
+ payload.tools = LOCAL_TOOLS;
1879
+ payload.tool_choice = 'auto';
1880
+ }
1881
+ const { r, data } = await zooPost(payload);
1697
1882
  lastData = data;
1883
+ turnX402 = mergeTurnProof(turnX402, data);
1698
1884
  const msg = data.choices?.[0]?.message || {};
1699
1885
  const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
1700
1886
  if (calls.length) {
1887
+ if (chatOnly) {
1888
+ log(`cursor-backend: visitor chat-only ignored ${calls.length} tool calls`);
1889
+ messages.push(msg);
1890
+ for (const c of calls) {
1891
+ messages.push({
1892
+ role: 'tool',
1893
+ tool_call_id: c.id,
1894
+ content: 'local tools are not available to web visitors',
1895
+ });
1896
+ }
1897
+ continue;
1898
+ }
1701
1899
  log(`cursor-backend: zoo tools step=${step} n=${calls.length} ${calls.map((c) => c.function?.name || c.name).join(',')}`);
1702
1900
  messages.push(msg);
1703
1901
  for (const c of calls) {
@@ -1724,7 +1922,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1724
1922
  log(`cursor-backend: << zoo ${r.status} ${text.length}c model=${data.model || model} finish=${finish}`);
1725
1923
  // Model likes to park after research ("Stopped on research", "say go again")
1726
1924
  // instead of writing files. Measured 2026-08-30 on volume track00r.
1727
- if ((finish === 'length' || looksStoppedReply(text)) && keepGoingNudge < 6) {
1925
+ if (!chatOnly && (finish === 'length' || looksStoppedReply(text)) && keepGoingNudge < 6) {
1728
1926
  keepGoingNudge += 1;
1729
1927
  log(`cursor-backend: keep-going nudge=${keepGoingNudge} finish=${finish}`);
1730
1928
  messages.push(msg);
@@ -1747,6 +1945,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1747
1945
  max_tokens: Math.max(800, Math.min(maxTok, 2048)),
1748
1946
  });
1749
1947
  lastData = data;
1948
+ turnX402 = mergeTurnProof(turnX402, data);
1750
1949
  text = zooTextFromMessage(data.choices?.[0]?.message, data);
1751
1950
  log(`cursor-backend: << zoo summary ${r.status} ${text.length}c`);
1752
1951
  }
@@ -1763,6 +1962,9 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1763
1962
  text = '(empty zoo reply)';
1764
1963
  }
1765
1964
  }
1965
+ if (lastData && typeof lastData === 'object') {
1966
+ lastData.x402 = mergeTurnProof(turnX402, lastData);
1967
+ }
1766
1968
  try { text += await zooSpendOverlay(lastData); } catch { /* overlay must never eat the reply */ }
1767
1969
  return { text, data: lastData };
1768
1970
  }
@@ -1796,6 +1998,20 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1796
1998
  });
1797
1999
  res.write('data: {"channel":"ping","payload":{}}\n\n');
1798
2000
  sseClients.add(res);
2001
+ try {
2002
+ const list = rosterForEvent(loadAgents() || [], agentActivity);
2003
+ const active = topConversationId(list);
2004
+ if (active) {
2005
+ focusedAgentId = focusedAgentId || active;
2006
+ res.write(`data: ${JSON.stringify({
2007
+ channel: 'agents',
2008
+ payload: {
2009
+ agents: list.map((a) => ({ ...a, isActive: a.id === active })),
2010
+ activeAgentId: active,
2011
+ },
2012
+ })}\n\n`);
2013
+ }
2014
+ } catch { /* first paint must not break SSE */ }
1799
2015
  const iv = setInterval(() => {
1800
2016
  try { res.write('data: {"channel":"ping","payload":{}}\n\n'); } catch { clearInterval(iv); sseClients.delete(res); }
1801
2017
  }, 15000);
@@ -1816,6 +2032,46 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1816
2032
  log('cursor-backend: -> getHostStatus ready');
1817
2033
  return true;
1818
2034
  }
2035
+ if (name === 'uploadAttachment' || name === 'readAttachmentImage'
2036
+ || name === 'readAttachmentText' || name === 'readAttachmentChunk') {
2037
+ let parsed = {};
2038
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
2039
+ if (name === 'uploadAttachment') {
2040
+ const got = ingestUpload({
2041
+ filename: parsed.filename,
2042
+ bytesBase64: parsed.bytesBase64,
2043
+ });
2044
+ if (!got.ok) {
2045
+ jsonSend(res, { status: 'ok', value: null, ok: false, reason: got.reason });
2046
+ log(`cursor-backend: uploadAttachment ${got.reason || 'failed'}`);
2047
+ return true;
2048
+ }
2049
+ // Dual shape: sendPrompt-style raw `.path` AND CVr `{status,value}`.
2050
+ jsonSend(res, { status: 'ok', value: { path: got.path }, path: got.path, ok: true });
2051
+ log(`cursor-backend: uploadAttachment ${got.path} ${got.bytes}b ${got.mime}`);
2052
+ return true;
2053
+ }
2054
+ if (name === 'readAttachmentChunk') {
2055
+ const chunk = readUploadChunk({
2056
+ path: parsed.path,
2057
+ offset: parsed.offset,
2058
+ length: parsed.length,
2059
+ });
2060
+ jsonSend(res, chunk
2061
+ ? { status: 'ok', value: chunk, ...chunk }
2062
+ : { status: 'ok', value: null });
2063
+ log(`cursor-backend: readAttachmentChunk ${parsed.path || ''} ${chunk ? chunk.totalSize : 'miss'}b`);
2064
+ return true;
2065
+ }
2066
+ if (name === 'readAttachmentImage') {
2067
+ const img = readUploadImage(parsed.path);
2068
+ jsonSend(res, img ? { status: 'ok', value: img, ...img } : { status: 'ok', value: null });
2069
+ return true;
2070
+ }
2071
+ const text = readUploadText(parsed.path);
2072
+ jsonSend(res, text ? { status: 'ok', value: text, ...text } : { status: 'ok', value: null });
2073
+ return true;
2074
+ }
1819
2075
  // Roster/settings come from the REAL 1340 gateway (names, avatars, trays).
1820
2076
  // Chat stays local so inference is zoo. Discovered on EnsureSandBox rewrite.
1821
2077
  const roster = new Set([
@@ -1825,8 +2081,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1825
2081
  'getBotTemplateExportPolicy', 'getTeachRecordingStatus', 'isGlobalSearchEnabled',
1826
2082
  'isEgressTunnelAvailable', 'listBoxMcpServers',
1827
2083
  'setWindowFocused', 'getAgentAutomations',
1828
- 'createGroup', 'setGroupMembers',
1829
- 'interruptAgentRun', 'requestDiskSaverAudit', 'broadcastToAgents',
2084
+ 'interruptAgentRun', 'requestDiskSaverAudit',
1830
2085
  'setAgentUnread', 'setAgentHiddenFromSidebar', 'setAgentNotificationsEnabled',
1831
2086
  'setAgentNotifyOnUpdates', 'setAgentAvatarBytes', 'getAgentAvatar',
1832
2087
  ]);
@@ -1841,15 +2096,82 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1841
2096
  }
1842
2097
  const agent = mintLocalAgent(parsed);
1843
2098
  jsonSend(res, { agent, id: agent.id, ...agent });
1844
- ssePush('agents', { action: 'created', agent });
2099
+ pushCreatedAgent(agent);
1845
2100
  log(`cursor-backend: createAgent local id=${agent.id} name=${JSON.stringify(agent.name)}`);
1846
2101
  return true;
1847
2102
  }
2103
+ if (name === 'kickstartAgent') {
2104
+ let parsed = {};
2105
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
2106
+ const id = String(parsed.id || parsed.agentId || '');
2107
+ jsonSend(res, { id, isIntroductionInFlight: false });
2108
+ log(`cursor-backend: kickstartAgent id=${id || '?'}`);
2109
+ return true;
2110
+ }
2111
+ if (name === 'createGroup') {
2112
+ let parsed = {};
2113
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
2114
+ const memberIds = [].concat(parsed.memberAgentIds || parsed.memberIds || []).map(String).filter(Boolean);
2115
+ const agent = mintLocalAgent({
2116
+ ...parsed,
2117
+ isGroup: true,
2118
+ memberIds,
2119
+ name: parsed.name || parsed.title || 'group',
2120
+ });
2121
+ jsonSend(res, { agent, id: agent.id, ...agent });
2122
+ pushCreatedAgent(agent);
2123
+ log(`cursor-backend: createGroup local id=${agent.id} members=${memberIds.length} name=${JSON.stringify(agent.name)}`);
2124
+ return true;
2125
+ }
2126
+ if (name === 'setGroupMembers') {
2127
+ let parsed = {};
2128
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
2129
+ const id = String(parsed.id || parsed.agentId || '');
2130
+ const memberIds = [].concat(parsed.memberAgentIds || parsed.memberIds || []).map(String).filter(Boolean);
2131
+ const list = cachedAgentList() || [];
2132
+ const i = list.findIndex((a) => a.id === id);
2133
+ if (!id || i < 0) {
2134
+ jsonSend(res, null);
2135
+ log(`cursor-backend: setGroupMembers miss id=${id || '?'}`);
2136
+ return true;
2137
+ }
2138
+ const agent = shapeAgent({ ...list[i], isGroup: true, memberIds, updatedAt: Date.now() });
2139
+ list[i] = agent;
2140
+ saveAgents(list.map(shapeAgent));
2141
+ jsonSend(res, { agent, ...agent });
2142
+ ssePush('agent-upserted', { agent, activeAgentId: focusedAgentId || id });
2143
+ ssePush('agents', { agents: rosterForEvent(list, agentActivity), activeAgentId: focusedAgentId || id });
2144
+ log(`cursor-backend: setGroupMembers id=${id} n=${memberIds.length}`);
2145
+ return true;
2146
+ }
2147
+ if (name === 'broadcastToAgents') {
2148
+ let parsed = {};
2149
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
2150
+ const ids = [].concat(parsed.ids || parsed.agentIds || []).map(String).filter(Boolean);
2151
+ const prompt = String(parsed.prompt || parsed.text || parsed.message || '').trim();
2152
+ jsonSend(res, { ok: true, accepted: true, ids });
2153
+ const target = ids[0] && groupMemberIds(ids[0]).length ? ids[0] : (focusedAgentId || ids[0]);
2154
+ if (target && prompt) {
2155
+ const nonce = parsed.clientNonce || `oz-bc-${Date.now()}`;
2156
+ setImmediate(() => {
2157
+ runGroupQueue({
2158
+ agentId: target,
2159
+ humanPrompt: prompt,
2160
+ parsed,
2161
+ nonce,
2162
+ log,
2163
+ }).catch((e) => log(`cursor-backend: broadcast queue ${e.message}`));
2164
+ });
2165
+ }
2166
+ log(`cursor-backend: broadcastToAgents ids=${ids.length} target=${target || 'none'}`);
2167
+ return true;
2168
+ }
1848
2169
  if (name === 'updateAgent') {
1849
2170
  let parsed = {};
1850
2171
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
1851
2172
  const agent = mintLocalAgent(parsed);
1852
2173
  jsonSend(res, { agent, ...agent });
2174
+ pushCreatedAgent(agent, { select: false });
1853
2175
  log(`cursor-backend: updateAgent local id=${agent.id}`);
1854
2176
  return true;
1855
2177
  }
@@ -1880,17 +2202,43 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1880
2202
  return true;
1881
2203
  }
1882
2204
  if (name === 'listAgents' || name === 'getTrays' || name === 'countAgents' || name === 'searchAgents') {
1883
- if (!sniffOn()) {
2205
+ if (!sniffOn() && !useHouseRoster()) {
1884
2206
  const pod = await waitForAccountPod(log);
1885
2207
  if (pod?.agent) {
1886
2208
  const proxied = await proxyPodHttp(req, res, full, body, log);
1887
2209
  if (proxied) return true;
1888
2210
  }
1889
2211
  }
1890
- if (name === 'listAgents') {
1891
- const list = mergeAgentLists([]);
1892
- jsonSend(res, list);
1893
- log(`cursor-backend: listAgents local n=${list.length} account=${activeAccountId || 'none'}`);
2212
+ if (name === 'getTrays') {
2213
+ jsonSend(res, []);
2214
+ return true;
2215
+ }
2216
+ if (name === 'listAgents' || name === 'countAgents' || name === 'searchAgents') {
2217
+ const list = rosterForEvent(mergeAgentLists([]), agentActivity);
2218
+ if (name === 'countAgents') {
2219
+ jsonSend(res, list.length);
2220
+ log(`cursor-backend: countAgents local n=${list.length} account=${activeAccountId || 'none'}`);
2221
+ return true;
2222
+ }
2223
+ if (name === 'searchAgents') {
2224
+ let parsed = {};
2225
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
2226
+ const q = String(parsed.query || parsed.q || '').toLowerCase();
2227
+ const out = !q ? list : list.filter((a) => String(a?.name || a?.title || a?.id || '').toLowerCase().includes(q));
2228
+ jsonSend(res, out);
2229
+ log(`cursor-backend: searchAgents local n=${out.length}/${list.length} account=${activeAccountId || 'none'}`);
2230
+ return true;
2231
+ }
2232
+ const active = topConversationId(list);
2233
+ if (active && !focusedAgentId) focusedAgentId = active;
2234
+ jsonSend(res, list.map((a) => ({ ...a, isActive: !!(active && a.id === active) })));
2235
+ if (active) {
2236
+ ssePush('agents', {
2237
+ agents: list.map((a) => ({ ...a, isActive: a.id === active })),
2238
+ activeAgentId: active,
2239
+ });
2240
+ }
2241
+ log(`cursor-backend: listAgents local n=${list.length} account=${activeAccountId || 'none'} active=${active || 'none'}`);
1894
2242
  return true;
1895
2243
  }
1896
2244
  }
@@ -1905,17 +2253,20 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1905
2253
  if (name === 'sendPrompt') {
1906
2254
  let parsed = {};
1907
2255
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
1908
- const prompt = promptFromSendBody(parsed);
2256
+ const prompt = stripVisitorLabel(promptFromSendBody(parsed));
2257
+ const visitor = visitorFromSend(parsed);
1909
2258
  const agentId = String(parsed.agentId || parsed.id || 'openzoo');
1910
2259
  const nonce = parsed.clientNonce || `oz-${Date.now()}`;
1911
2260
  const attN = Array.isArray(parsed.attachmentPaths) ? parsed.attachmentPaths.length : 0;
1912
- log(`cursor-backend: >> sendPrompt agent=${agentId} keys=${Object.keys(parsed).join(',')} attachments=${attN} prompt=${JSON.stringify((prompt || '').slice(0, 80))}`);
2261
+ const uiText = visitor ? labeledVisitorPrompt(visitor, prompt) : prompt;
2262
+ log(`cursor-backend: >> sendPrompt agent=${agentId} keys=${Object.keys(parsed).join(',')} attachments=${attN}${visitor ? ` visitor=${visitor.shortname}` : ''} prompt=${JSON.stringify((prompt || '').slice(0, 80))}`);
1913
2263
  lastSendEchoId = String(nonce);
1914
2264
  jsonSend(res, { accepted: true });
1915
- const userLine = fanoutLine(agentId, 'user', prompt, {
2265
+ const userLine = fanoutLine(agentId, 'user', uiText, {
1916
2266
  clientNonce: nonce,
1917
2267
  requestId: nonce,
1918
- richText: parsed.richText,
2268
+ richText: visitor ? prefixVisitorRichText(parsed.richText, visitor.shortname, uiText) : parsed.richText,
2269
+ promptRaw: prompt,
1919
2270
  });
1920
2271
  noteFocus(agentId);
1921
2272
  bumpAgent(agentId, { preview: prompt, notify: false });
@@ -1923,6 +2274,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1923
2274
  const modelCmd = /^\s*\/model(?:\s+(\S+))?\s*$/i.exec(prompt || '');
1924
2275
  setImmediate(async () => {
1925
2276
  let text = '';
2277
+ let grouped = false;
1926
2278
  try {
1927
2279
  if (modelCmd) {
1928
2280
  const want = modelCmd[1];
@@ -1941,17 +2293,28 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1941
2293
  }
1942
2294
  try { text += await zooSpendOverlay({}); } catch { /* */ }
1943
2295
  } else {
1944
- const z = await zooComplete(prompt, log, agentId, parsed);
1945
- text = z.text;
2296
+ const members = groupMemberIds(agentId);
2297
+ if (members.length) {
2298
+ grouped = true;
2299
+ await runGroupQueue({ agentId, humanPrompt: prompt, parsed, nonce, log });
2300
+ } else {
2301
+ const z = await zooComplete(prompt, log, agentId, parsed);
2302
+ text = z.text;
2303
+ }
1946
2304
  }
1947
2305
  } catch (e) {
2306
+ grouped = false;
1948
2307
  text = `openzoo error: ${e.message}`;
1949
2308
  log(`cursor-backend: sendPrompt zoo failed: ${e.message}`);
1950
2309
  }
1951
- const line = fanoutLine(agentId, 'assistant', text, { clientNonce: nonce, requestId: nonce });
1952
- ssePush('transcript', { ...gatewayEntry(line), agentId });
1953
- bumpAgent(agentId, { preview: text, notify: true });
1954
- log(`cursor-backend: sendPrompt done agent=${agentId} seq=${line.seq} text=${JSON.stringify(text.slice(0, 220))}`);
2310
+ if (!grouped) {
2311
+ const line = fanoutLine(agentId, 'assistant', text, { clientNonce: nonce, requestId: nonce });
2312
+ ssePush('transcript', { ...gatewayEntry(line), agentId });
2313
+ bumpAgent(agentId, { preview: text, notify: true });
2314
+ log(`cursor-backend: sendPrompt done agent=${agentId} seq=${line.seq} text=${JSON.stringify(text.slice(0, 220))}`);
2315
+ } else {
2316
+ log(`cursor-backend: sendPrompt group done agent=${agentId}`);
2317
+ }
1955
2318
  });
1956
2319
  return true;
1957
2320
  }
@@ -1962,7 +2325,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1962
2325
  noteFocus(id);
1963
2326
  tailedAgents.add(id);
1964
2327
  const t = agentTranscript(id);
1965
- if (!t.pulledRemote && realPod?.agent) {
2328
+ if (!t.pulledRemote && realPod?.agent && !useHouseRoster()) {
1966
2329
  const remote = await podJson('/api/getAgentTranscriptTail', {
1967
2330
  id, agentId: id, limit: 200, beforeSeq: parsed.beforeSeq,
1968
2331
  }, log);
@@ -2005,11 +2368,13 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2005
2368
  getHostSettings: { settings: {} },
2006
2369
  setHostSettings: { ok: true },
2007
2370
  setBoxSecrets: { ok: true },
2008
- listAgents: cachedAgentList()
2009
- || [...new Set([...transcripts.keys(), ...tailedAgents])].map((id) => ({ id, name: id, status: 'ready' })),
2371
+ listAgents: rosterForEvent(cachedAgentList()
2372
+ || [...new Set([...transcripts.keys(), ...tailedAgents])].map((id) => ({ id, name: id, status: 'ready' })), agentActivity),
2373
+ countAgents: (cachedAgentList() || [...new Set([...transcripts.keys(), ...tailedAgents])]).length,
2374
+ searchAgents: rosterForEvent(cachedAgentList() || [], agentActivity),
2010
2375
  getAgentTranscriptTail: { tail: '', lines: [], dropped: false, ok: true },
2011
2376
  getTeachRecordingStatus: { recording: false },
2012
- getTrays: { trays: [] },
2377
+ getTrays: [],
2013
2378
  isGlobalSearchEnabled: { enabled: false },
2014
2379
  isEgressTunnelAvailable: { available: false },
2015
2380
  getSharingState: { sharing: false },
@@ -2023,7 +2388,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
2023
2388
  };
2024
2389
  const payload = stubs[name] !== undefined ? stubs[name] : { ok: true };
2025
2390
  // Live 1340 listAgents is a RAW array, not CVr (measured 104674b 2026-08-29).
2026
- if (name === 'listAgents') jsonSend(res, payload);
2391
+ if (name === 'listAgents' || name === 'countAgents' || name === 'searchAgents') jsonSend(res, payload);
2027
2392
  else jsonApi(res, payload);
2028
2393
  log(`cursor-backend: -> pod /api/${name}`);
2029
2394
  return true;