openzoo 0.50.98 → 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.
@@ -1088,28 +1088,30 @@ function handleLocalExecFrames(frames, log) {
1088
1088
  } else if (f.kind === 'file-error' || f.kind === 'messages-error') {
1089
1089
  w.reject(new Error(f.error || 'local-exec error'));
1090
1090
  localExecWaiters.delete(id);
1091
- } else if (f.kind === 'output' || f.kind === 'stdout' || f.kind === 'stderr') {
1092
- const norm = normalizeExecFrame(f);
1093
- const chunk = norm.stdout || norm.message
1094
- || (typeof f.data === 'string' ? f.data : '')
1095
- || (typeof f.chunk === 'string' ? f.chunk : '');
1096
- if (f.kind === 'stderr') w.stderr += toolResultText(chunk);
1097
- else w.chunks += toolResultText(chunk);
1098
1091
  } else if (shellStreamOf(f)) {
1099
1092
  // The real Grok Bot lane: stdout/stderr arrive as many `kind:"client"`
1100
1093
  // frames and the turn is only over on `exit`. Resolving on the first one
1101
1094
  // (what the old client/control branch did) truncated every command to its
1102
1095
  // first chunk.
1103
1096
  const ss = shellStreamOf(f);
1104
- if (typeof ss.stdout?.data === 'string') w.chunks += ss.stdout.data;
1105
- if (typeof ss.stderr?.data === 'string') w.stderr += ss.stderr.data;
1106
- if (ss.exit) {
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) {
1107
1104
  w.resolve({
1108
1105
  kind: 'exit',
1109
1106
  message: w.chunks || w.stderr,
1110
1107
  stdout: w.chunks,
1111
1108
  stderr: w.stderr,
1112
- exitCode: Number(ss.exit.code || 0),
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),
1113
1115
  });
1114
1116
  localExecWaiters.delete(id);
1115
1117
  }
@@ -1118,8 +1120,11 @@ function handleLocalExecFrames(frames, log) {
1118
1120
  // any other, so the generic branch below used to resolve the call with an
1119
1121
  // empty body the moment the daemon first breathed.
1120
1122
  continue;
1121
- } else if (f.kind === 'client' || f.kind === 'control' || f.kind === 'result'
1122
- || f.kind === 'exec-result' || f.kind === 'exit') {
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.
1123
1128
  const norm = normalizeExecFrame(f);
1124
1129
  const stdout = w.chunks + (norm.stdout || '');
1125
1130
  const stderr = w.stderr + (norm.stderr || '');
@@ -1190,9 +1195,25 @@ function extractLocalPaths(text) {
1190
1195
  }
1191
1196
  return [...new Set(out)];
1192
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
+ */
1193
1212
  function expandUserPath(p) {
1194
- if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
1195
- 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;
1196
1217
  }
1197
1218
 
1198
1219
  /** Per-agent transcript. sendPrompt is async: accept immediately, zooComplete
@@ -1446,10 +1467,23 @@ export function restoreAgentWakeups(log = () => {}) {
1446
1467
  return ids.length;
1447
1468
  }
1448
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
+
1449
1483
  export function formatZooProgress({ step, maxSteps, names, command } = {}) {
1450
1484
  const tools = Array.isArray(names) ? names.filter(Boolean).join(', ') : String(names || 'tools');
1451
1485
  const cmd = command ? ` ${JSON.stringify(String(command).slice(0, 80))}` : '';
1452
- 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}`;
1453
1487
  }
1454
1488
 
1455
1489
  /** One canvas line per tool. Asar ingest is append-only — mutating a working
@@ -2275,11 +2309,27 @@ export function localExecFrame(command, cwd, timeoutMs = 120_000) {
2275
2309
  /** `agent.v1.ShellStream` off an ExecClientMessage: `{stdout:{data}}`,
2276
2310
  * `{stderr:{data}}`, `{exit:{code, cwd}}`. `data` is a proto string (T=9), not
2277
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
+
2278
2323
  export function shellStreamOf(frame) {
2279
- const m = frame && frame.message;
2280
- if (!m || typeof m !== 'object') return null;
2281
- const ss = m.shellStream;
2282
- return ss && typeof ss === 'object' ? ss : null;
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 : '';
2283
2333
  }
2284
2334
 
2285
2335
  /** exec used to hardcode `/bin/zsh -lc`: on Linux that is usually ENOENT and on
@@ -2296,6 +2346,20 @@ export function execShell(command, platform = process.platform) {
2296
2346
  return { file: '/bin/sh', args: ['-c', command] };
2297
2347
  }
2298
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
+
2299
2363
  async function execLocal(command, cwd, log) {
2300
2364
  let dir = cwd ? expandUserPath(cwd) : os.homedir();
2301
2365
  try {
@@ -2309,7 +2373,8 @@ async function execLocal(command, cwd, log) {
2309
2373
  // Daemon-side budget stays under our own wait so a slow command reports the
2310
2374
  // shell's error, not our generic "is Grok Bot Helper connected?".
2311
2375
  const got = await localExecAsk(localExecFrame(command, dir, 55_000), 60000);
2312
- const out = toolResultText(got.stdout || got.message || '');
2376
+ let out = toolResultText(got.stdout || got.message || '');
2377
+ if (!out && got.outputPath) out = await localExecSpooledOutput(got.outputPath, log);
2313
2378
  const errText = toolResultText(got.stderr);
2314
2379
  const err = errText ? `\nstderr:\n${errText}` : '';
2315
2380
  return `${out}${err}`.trim() || `(exit ${got.exitCode ?? 0})`;
@@ -2848,12 +2913,10 @@ async function runLocalTool(name, args, log, ctx = {}) {
2848
2913
  const abs = expandUserPath(args.path || os.homedir());
2849
2914
  try {
2850
2915
  if (localExecSse.size > 0) {
2851
- const got = await localExecAsk(localExecFrame(`ls -la ${JSON.stringify(abs)}`, os.homedir(), 40_000));
2852
- // `|| got` used to dump the whole resolved frame onto the canvas as
2853
- // JSON ({"kind":"exit","stdout":"", …}) whenever the listing came back
2854
- // empty. Fall back to the local read instead — it is the same answer.
2855
- const text = toolResultText(got.stdout || got.stderr || got.message).trim();
2856
- if (text) return text;
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;
2857
2920
  }
2858
2921
  const rows = fs.readdirSync(abs, { withFileTypes: true })
2859
2922
  .map((e) => `${e.isDirectory() ? 'd' : '-'} ${e.name}`);
@@ -3160,7 +3223,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
3160
3223
  if (mediaKind) return await mediaTurn({ model, kind: mediaKind, prompt: spoken, log, onProgress: opts.onProgress, signal: opts.signal });
3161
3224
  }
3162
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))}`);
3163
- if (typeof opts.onProgress === 'function') opts.onProgress('Working on your Mac…');
3226
+ if (typeof opts.onProgress === 'function') opts.onProgress(`Working on your ${hostNoun()}…`);
3164
3227
 
3165
3228
  const images = [];
3166
3229
  const textFiles = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.98",
3
+ "version": "0.50.99",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",