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.
package/lib/launch.js CHANGED
@@ -559,7 +559,7 @@ export async function launchClaude(argv) {
559
559
  try {
560
560
  const { blockBackend, bindBackend443, unblockBackend, CLAUDE_HOSTS } = await import('./hosts.js');
561
561
  const { ensureCert } = await import('./cursorbackend.js');
562
- ensureCert(console.error);
562
+ await ensureCert(console.error);
563
563
  const r = blockBackend(CLAUDE_HOSTS);
564
564
  console.error(r.already ? 'openzoo: api.anthropic.com already routed to us'
565
565
  : `openzoo: routing ${CLAUDE_HOSTS.join(', ')} -> 127.0.0.1 (sudo)`);
package/lib/models.js CHANGED
@@ -192,7 +192,17 @@ export function unopenrouter(requested, ids) {
192
192
  const bare = s.slice(slash + 1);
193
193
  if (!bare || bare.includes('/')) return null;
194
194
  if (mode === '1') return bare;
195
- return Array.isArray(ids) && ids.includes(bare) ? bare : null;
195
+ if (!Array.isArray(ids) || !ids.length) return null;
196
+ if (ids.includes(s)) return null;
197
+ if (ids.includes(bare)) return bare;
198
+ // Telnyx advertises `zai-org/GLM-5.2`, not OpenRouter's `z-ai/glm-5.2`.
199
+ const want = bare.toLowerCase().replace(/[._]+/g, '-');
200
+ const hit = ids.find((id) => {
201
+ if (id === s) return false;
202
+ const suffix = String(id).slice(String(id).lastIndexOf('/') + 1).toLowerCase().replace(/[._]+/g, '-');
203
+ return suffix === want;
204
+ });
205
+ return hit || null;
196
206
  }
197
207
 
198
208
  export function resolveModel(requested, ids) {
@@ -0,0 +1,60 @@
1
+ // Install a PATH shim so `openzoo` works without a system Node.
2
+ //
3
+ // grokui-app already ran the sidecar with Electron's own binary
4
+ // (`ELECTRON_RUN_AS_NODE=1`). That only helps the GUI. A packaged OpenZoo Bot
5
+ // also drops `~/.local/bin/openzoo` (Windows: %LOCALAPPDATA%\openzoo\bin)
6
+ // pointing at the same Electron + bundled bin/openzoo.js, so the CLI
7
+ // (`openzoo claude`, `openzoo balance`, …) works from a Finder/Dock launch
8
+ // that has no ~/.zshrc PATH and no npx.
9
+
10
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ export function shimDir(home = homedir(), platform = process.platform) {
15
+ if (platform === 'win32') return join(home, 'AppData', 'Local', 'openzoo', 'bin');
16
+ return join(home, '.local', 'bin');
17
+ }
18
+
19
+ export function shimPath(home = homedir(), platform = process.platform) {
20
+ const name = platform === 'win32' ? 'openzoo.cmd' : 'openzoo';
21
+ return join(shimDir(home, platform), name);
22
+ }
23
+
24
+ export function unixShimBody({ execPath, openzooJs }) {
25
+ return `#!/bin/sh
26
+ # Written by OpenZoo Bot. Electron-as-node — no system Node required.
27
+ export ELECTRON_RUN_AS_NODE=1
28
+ exec ${shellQuote(execPath)} ${shellQuote(openzooJs)} "$@"
29
+ `;
30
+ }
31
+
32
+ export function winShimBody({ execPath, openzooJs }) {
33
+ return `@echo off
34
+ set ELECTRON_RUN_AS_NODE=1
35
+ "${execPath}" "${openzooJs}" %*
36
+ `;
37
+ }
38
+
39
+ function shellQuote(s) {
40
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
41
+ }
42
+
43
+ export function writeOpenzooShim({
44
+ execPath,
45
+ openzooJs,
46
+ home = homedir(),
47
+ platform = process.platform,
48
+ } = {}) {
49
+ if (!execPath || !openzooJs) throw new Error('writeOpenzooShim needs execPath + openzooJs');
50
+ if (!existsSync(openzooJs)) throw new Error(`openzoo.js missing: ${openzooJs}`);
51
+ const dir = shimDir(home, platform);
52
+ mkdirSync(dir, { recursive: true });
53
+ const dest = shimPath(home, platform);
54
+ const body = platform === 'win32'
55
+ ? winShimBody({ execPath, openzooJs })
56
+ : unixShimBody({ execPath, openzooJs });
57
+ writeFileSync(dest, body, { encoding: 'utf8' });
58
+ if (platform !== 'win32') chmodSync(dest, 0o755);
59
+ return dest;
60
+ }
@@ -237,6 +237,14 @@ function ozEnsureSpendCss() {
237
237
  '[data-oz-spend-hide]{display:none!important}',
238
238
  'button[aria-label="Start voice input"],button[aria-label*="voice input" i],',
239
239
  'button[aria-label*="steminvoer" i],button[aria-label^="Microphone"]{display:none!important}',
240
+ '#oz-share-hud{position:fixed;right:16px;bottom:148px;z-index:2147483646;max-width:min(28em,calc(100vw - 24px));',
241
+ 'pointer-events:auto;font:600 13px/1.3 system-ui,sans-serif}',
242
+ '#oz-share-hud[data-feature="oz-share-hud"]{display:block}',
243
+ '#oz-share-hud>button,#oz-share-hud>a{display:block;width:100%;text-align:left;cursor:pointer;',
244
+ 'background:#2563eb;color:#fff;border:0;border-radius:999px;padding:6px 12px;box-shadow:0 4px 18px rgba(0,0,0,.5);',
245
+ 'text-decoration:none}',
246
+ '#oz-share-hud .oz-share-url{display:block;margin-top:6px;padding:8px 10px;border-radius:10px;',
247
+ 'background:rgba(20,20,22,.94);color:#93c5fd;font:12px/1.4 ui-monospace,monospace;overflow-wrap:anywhere}',
240
248
  ].join('');
241
249
  }
242
250
 
@@ -560,6 +568,71 @@ function ozPollSpend() {
560
568
  })();
561
569
  }
562
570
 
571
+ function ozShareCopy(url) {
572
+ const u = String(url || '');
573
+ if (!u) return;
574
+ try { navigator.clipboard.writeText(u); } catch (e) {}
575
+ try { window.open(u, '_blank', 'noopener'); } catch (e) {}
576
+ }
577
+
578
+ function ozEnsureShareHud(state) {
579
+ const url = state && typeof state.url === 'string' ? state.url.trim() : '';
580
+ const rooms = (state && Array.isArray(state.rooms) ? state.rooms : []).filter((r) => r && r.url);
581
+ const show = !!url || !!(state && state.isGroup && rooms.length);
582
+ let el = document.getElementById('oz-share-hud');
583
+ if (!show) {
584
+ if (el) el.remove();
585
+ return;
586
+ }
587
+ ozEnsureSpendCss();
588
+ if (!el) {
589
+ el = document.createElement('div');
590
+ el.id = 'oz-share-hud';
591
+ el.setAttribute('data-feature', 'oz-share-hud');
592
+ el.setAttribute('data-testid', 'oz-share-hud');
593
+ document.body.appendChild(el);
594
+ }
595
+ const primary = url || (rooms[0] && rooms[0].url) || '';
596
+ const lines = url
597
+ ? [url]
598
+ : rooms.map((r) => (r.name ? r.name + ' ' : '') + r.url);
599
+ el.replaceChildren();
600
+ const btn = document.createElement('button');
601
+ btn.type = 'button';
602
+ btn.setAttribute('data-testid', 'oz-share-copy');
603
+ btn.textContent = url ? 'Share group' : 'Share grokroom';
604
+ btn.addEventListener('click', () => ozShareCopy(primary));
605
+ el.appendChild(btn);
606
+ const box = document.createElement('div');
607
+ box.className = 'oz-share-url';
608
+ box.setAttribute('data-testid', 'oz-share-url');
609
+ box.textContent = lines.join('\n');
610
+ el.appendChild(box);
611
+ if (primary) {
612
+ const hint = document.createElement('div');
613
+ hint.setAttribute('data-testid', 'oz-share-join-hint');
614
+ hint.style.cssText = 'margin-top:6px;font:12px/1.35 ui-sans-serif,system-ui,sans-serif;opacity:.85;word-break:break-all';
615
+ hint.textContent = 'Others in OpenZoo Bot: /join ' + primary + ' — then add their bots to the group.';
616
+ el.appendChild(hint);
617
+ }
618
+ }
619
+
620
+ function ozPollShare() {
621
+ const urls = ['/oz-share', '/api/ozShare', 'https://127.0.0.1:8443/api/ozShare'];
622
+ (async () => {
623
+ for (let i = 0; i < urls.length; i += 1) {
624
+ try {
625
+ const r = await fetch(urls[i], { signal: AbortSignal.timeout(1500) });
626
+ if (!r || !r.ok) continue;
627
+ const j = await r.json();
628
+ if (!j || j.ok === false) continue;
629
+ ozEnsureShareHud(j);
630
+ return;
631
+ } catch (e) {}
632
+ }
633
+ })();
634
+ }
635
+
563
636
  function ozWatchSpend() {
564
637
  let t = 0;
565
638
  const inComposer = () => ozInComposer(document.activeElement);
@@ -583,16 +656,17 @@ function ozWatchSpend() {
583
656
  else document.addEventListener('DOMContentLoaded', start);
584
657
  } catch (e) {}
585
658
  try { window.addEventListener('resize', debounced); } catch (e) {}
586
- try { setInterval(() => { run(); ozPollSpend(); }, 2000); } catch (e) {}
659
+ try { setInterval(() => { run(); ozPollSpend(); ozPollShare(); }, 2000); } catch (e) {}
587
660
  try { ozPollSpend(); } catch (e) {}
661
+ try { ozPollShare(); } catch (e) {}
588
662
  }
589
663
 
590
664
  export function spendChipSource() {
591
665
  return [
592
666
  '(function ozSpendChip(){',
593
667
  "'use strict';",
594
- 'if (window.__OZ_SPEND_CHIP__ === 16) return;',
595
- 'window.__OZ_SPEND_CHIP__ = 16;',
668
+ 'if (window.__OZ_SPEND_CHIP__ === 18) return;',
669
+ 'window.__OZ_SPEND_CHIP__ = 18;',
596
670
  chipUsd.toString(),
597
671
  labelFromSpendBody.toString(),
598
672
  spendLinesOnly.toString(),
@@ -615,6 +689,9 @@ export function spendChipSource() {
615
689
  ozDragFloat.toString(),
616
690
  ozLabelFromInfo.toString(),
617
691
  ozPollSpend.toString(),
692
+ ozShareCopy.toString(),
693
+ ozEnsureShareHud.toString(),
694
+ ozPollShare.toString(),
618
695
  ozWatchSpend.toString(),
619
696
  'ozWatchSpend();',
620
697
  '})();',
package/lib/pay.js CHANGED
@@ -63,16 +63,39 @@ export class UnderfundedError extends Error {
63
63
  * rest. A payment still decrements the cached figure so a burst cannot overdraw.
64
64
  */
65
65
  const BALANCE_TTL_MS = Number(process.env.OPENZOO_BALANCE_TTL_MS || 8_000);
66
+ /**
67
+ * HARD CEILING ON STALENESS — the SWR branch has no age bound of its own.
68
+ *
69
+ * It returns the cached figure and merely KICKS OFF a refresh, so a positive
70
+ * entry was served forever whenever the refresh kept losing: an RPC that keeps
71
+ * throwing leaves `refreshing` false and the old number in place, and several
72
+ * workers sharing one wallet never see each other's spends at all — each
73
+ * process only ever debits what IT paid.
74
+ *
75
+ * MEASURED 2026-09-03: deposit wallet 4QtgGwMt… holding 0.003603 USDC kept
76
+ * signing the 0.009204 USDC row. Every settle came back `Error: insufficient
77
+ * funds` (Tokenkeg custom program error 0x1) in a tight retry loop, while the
78
+ * TOKEN row it could pay (47,411 held vs 19.28 needed) sat next to it in the
79
+ * same accepts[]. Past this ceiling, block on a live read instead of guessing.
80
+ */
81
+ const BALANCE_STALE_MAX_MS = Number(process.env.OPENZOO_BALANCE_STALE_MAX_MS || 60_000);
66
82
  const balanceCache = new Map(); // key -> { raw, ui, at, refreshing }
67
83
 
68
84
  const balKey = (owner, mint) => `${owner}|${mint}`;
69
85
 
86
+ /** Drop a cached figure so the next read is live. Called when the gateway
87
+ * refuses a payment we already signed — the cache is what mispriced it. */
88
+ export function forgetCachedBalance(owner, mint) {
89
+ balanceCache.delete(balKey(owner?.toBase58 ? owner.toBase58() : String(owner), mint));
90
+ }
91
+
70
92
  async function cachedTokenBalance(connection, owner, mint, { force = false } = {}) {
71
93
  const key = balKey(owner.toBase58 ? owner.toBase58() : String(owner), mint);
72
94
  const hit = balanceCache.get(key);
73
95
  const zero = !!(hit && hit.raw === 0n);
74
- const fresh = hit && !force && !zero && (Date.now() - hit.at) < BALANCE_TTL_MS;
75
- if (hit && !force && !zero) {
96
+ const age = hit ? Date.now() - hit.at : Infinity;
97
+ const fresh = hit && !force && !zero && age < BALANCE_TTL_MS;
98
+ if (hit && !force && !zero && age < BALANCE_STALE_MAX_MS) {
76
99
  if (!fresh && !hit.refreshing) {
77
100
  hit.refreshing = true;
78
101
  tokenBalance(connection, owner, mint)
@@ -402,6 +425,11 @@ export class PayClient {
402
425
  // into a silent spend loop.
403
426
  if (response.status === 402 && !this._rebuilt) {
404
427
  this._rebuilt = true;
428
+ // The gateway just refused a payment we signed. If the reason was funds
429
+ // rather than a dead blockhash, the cached balance is exactly what got it
430
+ // wrong — drop it so the rebuild re-reads live and the candidate walk can
431
+ // fall through to a rail the wallet can actually pay.
432
+ try { forgetCachedBalance(this.keypair?.publicKey, accept?.asset); } catch { /* advisory */ }
405
433
  try {
406
434
  // TAKE THE GATEWAY'S ADVICE BEFORE RE-QUOTING BLIND. On "payer balance
407
435
  // insufficient" the 402 now carries advice.retryWithMaxTokens — the
package/lib/podagent.mjs CHANGED
@@ -196,7 +196,9 @@ function execFrame(command, cwd = '/tmp') {
196
196
  approvalId: randomUUID(),
197
197
  // agent.v1.ExecServerMessage as protobuf-es JSON (camelCase). The daemon
198
198
  // assigns `id`; we only supply the shell variant.
199
- serverMessage: { shellArgs: { command: guardFindCwd(command, cwd), workingDirectory: cwd, timeout: 120 } },
199
+ // `timeout` is MILLISECONDS (the daemon reads it via resolveShellTimeoutMs).
200
+ // This said 120, which aborted every command after 120ms — exit 0, no output.
201
+ serverMessage: { shellArgs: { command: guardFindCwd(command, cwd), workingDirectory: cwd, timeout: 120_000 } },
200
202
  };
201
203
  }
202
204
 
package/lib/proxy.js CHANGED
@@ -40,9 +40,10 @@ import { attachX402Proof } from './spendProof.js';
40
40
 
41
41
  /** Kill whatever is LISTEN on this port except this process.
42
42
  * lsof first (macOS), then fuser, then /proc (Omarchy/Arch with neither).
43
+ * Windows: netstat -ano + taskkill — lsof/kill do not exist (measured 2026-09-03).
43
44
  * Every openzoo subcommand that binds :8402 goes through this. Hopping to
44
45
  * 8403 was the second-burner bug. */
45
- export function killListen(port, run = execSync) {
46
+ export function killListen(port, run = execSync, platform = process.platform) {
46
47
  const n = Number(port);
47
48
  const seen = new Set();
48
49
  const addFrom = (out) => {
@@ -56,9 +57,23 @@ export function killListen(port, run = execSync) {
56
57
  return run(cmd, { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], ...opts });
57
58
  } catch { return ''; }
58
59
  };
60
+ if (platform === 'win32') {
61
+ const ns = tryRun('netstat -ano -p tcp');
62
+ for (const line of String(ns).split(/\r?\n/)) {
63
+ if (!/LISTENING/i.test(line)) continue;
64
+ const m = line.match(new RegExp(`[:\\]]${n}\\s+\\S+\\s+LISTENING\\s+(\\d+)\\s*$`, 'i'));
65
+ if (!m) continue;
66
+ const pid = Number(m[1]);
67
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) seen.add(pid);
68
+ }
69
+ for (const pid of seen) {
70
+ try { run(`taskkill /PID ${pid} /F`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
71
+ }
72
+ return [...seen];
73
+ }
59
74
  addFrom(tryRun(`lsof -nP -iTCP:${n} -sTCP:LISTEN -t`));
60
75
  addFrom(tryRun(`fuser -n tcp ${n}`));
61
- if (process.platform === 'linux' && seen.size === 0) {
76
+ if (platform === 'linux' && seen.size === 0) {
62
77
  addFrom(tryRun(`python3 - ${n}`, {
63
78
  timeout: 2500,
64
79
  input: `import os, glob, sys
package/lib/setup.js CHANGED
@@ -478,7 +478,7 @@ export async function setupEditor(which, target) {
478
478
  const backModels = models.map((m) => ({ name: m, label: m }));
479
479
  // Cert is minted as the USER (root can still read it); models handed to the
480
480
  // privileged listener via a temp file.
481
- ensureCert(console.log);
481
+ await ensureCert(console.log);
482
482
  const modelsFile = path.join(os.tmpdir(), 'openzoo-cursor-models.json');
483
483
  fs.writeFileSync(modelsFile, JSON.stringify(backModels));
484
484
  const backendLog = path.join(os.homedir(), '.openzoo', 'cursor-backend.log');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.97",
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",