openzoo 0.50.93 → 0.50.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/claude-zoo.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * the local openzoo proxy on :8402 (x402 per-call payment, no Anthropic
7
7
  * subscription, no login):
8
8
  *
9
- * - starts the proxy if it is not already THIS version on :8402 (steals stale)
9
+ * - starts the proxy if it is not already listening
10
10
  * - applies claudeZooEnv(): ANTHROPIC_BASE_URL=localhost:PORT/v1,
11
11
  * AUTH_TOKEN=sk-openzoo, ANTHROPIC_API_KEY deleted, compaction disabled
12
12
  * (the proxy binds the prefix), 1M-token ceiling restored
@@ -16,8 +16,7 @@
16
16
  * `claude-code-cli` (same directory, symlinked to occ).
17
17
  */
18
18
  import { spawn } from 'node:child_process';
19
- import { existsSync, readdirSync as fsReaddir, readFileSync } from 'node:fs';
20
- import { execSync } from 'node:child_process';
19
+ import { existsSync, readdirSync as fsReaddir } from 'node:fs';
21
20
  import { homedir, platform } from 'node:os';
22
21
  import { dirname, join, sep } from 'node:path';
23
22
  import { fileURLToPath } from 'node:url';
@@ -51,34 +50,10 @@ if (!occ) {
51
50
  const PROXY_PORT = Number(process.env.OPENZOO_PROXY_PORT || 8402);
52
51
  const PROXY_URL = `http://localhost:${PROXY_PORT}/v1`;
53
52
 
54
- function mineVersion() {
55
- try {
56
- return JSON.parse(readFileSync(join(shimRoot, 'package.json'), 'utf8')).version;
57
- } catch { return ''; }
58
- }
59
-
60
- function stealPort(port) {
61
- const n = Number(port);
62
- for (const cmd of [
63
- `lsof -t -iTCP:${n} -sTCP:LISTEN | xargs kill -9`,
64
- `fuser -k ${n}/tcp`,
65
- ]) {
66
- try { execSync(cmd, { stdio: 'ignore', timeout: 2000, shell: true }); } catch { /* missing */ }
67
- }
68
- }
69
-
70
53
  async function proxyUp() {
71
54
  try {
72
55
  const r = await fetch(`http://localhost:${PROXY_PORT}/v1/info`, { signal: AbortSignal.timeout(1500) });
73
- if (!r.ok) return false;
74
- const j = await r.json().catch(() => ({}));
75
- const mine = mineVersion();
76
- if (mine && String(j.version || '') !== mine) {
77
- console.error(`claude: stale proxy v${j.version || '?'} on :${PROXY_PORT} — stealing for v${mine}`);
78
- stealPort(PROXY_PORT);
79
- return false;
80
- }
81
- return true;
56
+ return r.ok;
82
57
  } catch { return false; }
83
58
  }
84
59
 
package/bin/openzoo.js CHANGED
@@ -330,7 +330,7 @@ async function main() {
330
330
  }
331
331
  case 'ask': {
332
332
  const question = process.argv[3];
333
- if (!question) throw new Error('usage: openzoo ask "<question>" [--context <id>] [--model <id>] [--system <text>]');
333
+ if (!question) throw new Error('usage: openzoo ask "<question>" [--context <id>] [--model <id>] [--system <text>] [--web [--web-results N]]');
334
334
  const ci = process.argv.indexOf('--context');
335
335
  const mi = process.argv.indexOf('--model');
336
336
  // A BARE QUESTION IS A DIFFERENT PRODUCT FROM A BRIEFED ONE.
@@ -344,7 +344,20 @@ async function main() {
344
344
  // DHH, Hyprland and theming. Same gateway, same product, one had context.
345
345
  // A caller that knows where it is running can now say so.
346
346
  const si = process.argv.indexOf('--system');
347
- const system = si !== -1 ? process.argv[si + 1] : '';
347
+ let system = si !== -1 ? process.argv[si + 1] : '';
348
+ // --web: a keyless DuckDuckGo search, top results injected into THIS
349
+ // call's system prompt. The x402 rail strips OpenRouter's `plugins`
350
+ // field, so search-then-inject has to happen here, on the caller's
351
+ // side. EGRESS: the question text goes to duckduckgo.com. Also on with
352
+ // OPENZOO_ASK_WEB=1; --web-results N caps the count (default 5).
353
+ const wantWeb = process.argv.includes('--web') || process.env.OPENZOO_ASK_WEB === '1';
354
+ if (wantWeb) {
355
+ const wi = process.argv.indexOf('--web-results');
356
+ const n = wi !== -1 ? Number(process.argv[wi + 1]) || 5 : 5;
357
+ const { webSearch, formatWebResults } = await import('../lib/websearch.js');
358
+ const hits = await webSearch(question, n).catch((e) => { console.error(`web search failed: ${e.message}`); return []; });
359
+ if (hits.length) system = (system ? system + '\n\n' : '') + formatWebResults(question, hits);
360
+ }
348
361
  const { PayClient } = await import('../lib/pay.js');
349
362
  const { config } = await import('../lib/config.js');
350
363
  const client = new PayClient();
@@ -389,6 +402,17 @@ async function main() {
389
402
  case '-h':
390
403
  console.log(HELP);
391
404
  break;
405
+ case 'version':
406
+ case '--version':
407
+ case '-v':
408
+ case '-V': {
409
+ // Every installer, mise shim and shell script that probes a CLI asks
410
+ // this first; answering "unknown command" to it failed real installs.
411
+ const { readFileSync } = await import('fs');
412
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
413
+ console.log(pkg.version);
414
+ break;
415
+ }
392
416
  default:
393
417
  console.error(`unknown command: ${cmd}\n`);
394
418
  console.log(HELP);
package/lib/aoe.js CHANGED
@@ -287,15 +287,9 @@ export async function setupAoe(argv = []) {
287
287
 
288
288
  const base = `http://localhost:${config.port}/v1`;
289
289
  if (!flags.has('--no-proxy')) {
290
- const { oursOn, packageVersion, killListen } = await import('./proxy.js');
291
- if (await oursOn(config.port)) {
292
- console.error(`openzoo: proxy v${packageVersion()} already on ${base}`);
290
+ if (await proxyUp(base)) {
291
+ console.error(`openzoo: proxy already up on ${base}`);
293
292
  } else {
294
- if (await proxyUp(base)) {
295
- console.error(`openzoo: stale proxy on ${base} — stealing :${config.port}`);
296
- killListen(config.port);
297
- await new Promise((r) => setTimeout(r, 400));
298
- }
299
293
  const { pid, logPath } = startDetachedProxy({ tunnel: flags.has('--tunnel') });
300
294
  let up = false;
301
295
  for (let i = 0; i < 40 && !up; i++) {
@@ -3075,22 +3075,17 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
3075
3075
  if (r.status === 402) {
3076
3076
  const raw = zooTextFromMessage(data?.choices?.[0]?.message, data)
3077
3077
  || data?.error?.message
3078
- || 'openzoo payment required (HTTP 402).';
3079
- data.error = data.error || {};
3080
- data.error.message = raw;
3078
+ || 'openzoo wallet underfunded.';
3081
3079
  try {
3082
- const { withOnrampLink, isFundInstruction } = await import('./stripeOnramp.js');
3083
- // Settle/upstream 402s keep the real message. Whop copy-paste only
3084
- // when this is a genuine empty-wallet / fund-me instruction.
3085
- if (isFundInstruction(raw)) {
3086
- const { loadOrCreateWallet } = await import('./wallet.js');
3087
- const w = loadOrCreateWallet();
3088
- const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
3089
- data.error.message = await withOnrampLink(raw, {
3090
- solana: w.keypair.publicKey.toBase58(),
3091
- usd,
3092
- });
3093
- }
3080
+ const { withOnrampLink } = await import('./stripeOnramp.js');
3081
+ const { loadOrCreateWallet } = await import('./wallet.js');
3082
+ const w = loadOrCreateWallet();
3083
+ const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
3084
+ data.error = data.error || {};
3085
+ data.error.message = await withOnrampLink(raw, {
3086
+ solana: w.keypair.publicKey.toBase58(),
3087
+ usd,
3088
+ });
3094
3089
  } catch { /* keep proxy copy */ }
3095
3090
  }
3096
3091
  return { r, data };
package/lib/launch.js CHANGED
@@ -164,7 +164,30 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
164
164
  // Correct here for the same reason as everything else in this block: the
165
165
  // proxy binds the prefix and forwards a bounded tail, so the request that
166
166
  // leaves this machine stays small no matter how long the conversation runs.
167
- if (baseEnv.OPENZOO_KEEP_COMPACT !== '1') {
167
+ //
168
+ // OPT-IN NOW, BECAUSE THE PRECONDITION IS NOT REAL.
169
+ //
170
+ // Everything above is correct ONLY IF something bounds the body that leaves
171
+ // this machine. Two comments here named that something — `spillTranscript`,
172
+ // and the knobs `OPENZOO_TAIL_MAX_CHARS` / `OPENZOO_KEEP_TAIL_MSGS`. None of
173
+ // the three exists: `grep -rn spillTranscript` matches nothing but these
174
+ // comments, in this package and in the gateway. Checked 2026-09-03.
175
+ //
176
+ // So the shipped behaviour was: compaction off, ceiling raised to 1M, and
177
+ // NOTHING trimming the transcript — Claude Code accumulates without limit and
178
+ // re-sends the whole thing every turn. The gateway's leCore cannot rescue it
179
+ // either: an agent body is few-and-huge, so `msgs.length <= KEEP_TAIL` leaves
180
+ // nothing "older than the live window", and what bulk there is sits in the
181
+ // system block and first user turn, both deliberately never spilled.
182
+ //
183
+ // MEASURED on a user's session: 20 calls, $13.86, `spilled 0/20`, 1.0159x vs
184
+ // direct — paying almost exactly retail to send an ever-growing transcript.
185
+ //
186
+ // Until a real bound ships, default to Claude Code's own behaviour, which is
187
+ // bounded and known-good. OPENZOO_UNBOUNDED_CONTEXT=1 restores the old
188
+ // settings for anyone who wants them back.
189
+ const unboundedContext = baseEnv.OPENZOO_UNBOUNDED_CONTEXT === '1';
190
+ if (unboundedContext && baseEnv.OPENZOO_KEEP_COMPACT !== '1') {
168
191
  env.DISABLE_COMPACT = baseEnv.DISABLE_COMPACT || '1';
169
192
  env.DISABLE_AUTO_COMPACT = baseEnv.DISABLE_AUTO_COMPACT || '1';
170
193
  }
@@ -177,9 +200,9 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
177
200
  // sent. Disabling auto-compact WITHOUT raising this made it worse: it used to
178
201
  // compact and carry on, and instead it just stopped.
179
202
  //
180
- // Safe only because spillTranscript is real: OPENZOO_TAIL_MAX_CHARS bounds what
181
- // leaves this machine however long the conversation gets.
182
- if (baseEnv.OPENZOO_KEEP_COMPACT !== '1' && !baseEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS) {
203
+ // WAS justified by `spillTranscript` / OPENZOO_TAIL_MAX_CHARS neither of
204
+ // which exists. Now gated behind OPENZOO_UNBOUNDED_CONTEXT; see below.
205
+ if (unboundedContext && baseEnv.OPENZOO_KEEP_COMPACT !== '1' && !baseEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS) {
183
206
  env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = baseEnv.OPENZOO_CLAUDE_CONTEXT_TOKENS || '1000000';
184
207
  }
185
208
  return env;
@@ -190,21 +213,18 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
190
213
  * DEFAULT is the desktop app; `--terminal` (or `-t`) runs the Claude Code CLI.
191
214
  * Both get ANTHROPIC_BASE_URL so inference pays x402.
192
215
  */
193
-
194
-
195
216
  export async function launchClaude(argv) {
196
- // NEVER hop. :8402 is the product. startProxy steals a stale listener.
217
+ // let, not const: startProxy can heal onto a different port and every URL
218
+ // below must follow the port we actually bound.
197
219
  let base = `http://localhost:${config.port}/v1`;
198
- // AUTO-START THE PROXY. One command should just work — if nothing is listening
199
- // (or a leftover npx cache is), boot THIS version on :8402 in this process.
220
+ // AUTO-START THE PROXY. One command should just work — if nothing is listening,
221
+ // boot the proxy in THIS process (it stays alive because claude runs in the
222
+ // foreground below), rather than making the user run `npx openzoo` first.
200
223
  let up = false;
201
- const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
202
- const mine = packageVersion();
203
- if (await oursOn(config.port)) {
204
- up = true;
205
- } else {
206
- process.stderr.write(`openzoo: claiming :${config.port} for v${mine}\n`);
207
- }
224
+ // /info, not /models see the poll below. "Is a proxy already listening" must
225
+ // not be answered by an endpoint that needs the gateway, or a user whose
226
+ // upstream is flaky gets told to start a proxy that is already running.
227
+ try { up = (await fetch(`${base}/info`, { signal: AbortSignal.timeout(3000) })).ok; } catch { up = false; }
208
228
  if (!up) {
209
229
  // NEVER GO SILENT DURING STARTUP. silent:true routes the proxy's own lines
210
230
  // to ~/.openzoo/proxy.log so payment receipts cannot corrupt Claude Code's
@@ -221,6 +241,7 @@ export async function launchClaude(argv) {
221
241
  tick.unref?.();
222
242
  const done = (msg) => { clearInterval(tick); process.stderr.write(`\r\x1b[2Kopenzoo: ${msg}\n`); };
223
243
  try {
244
+ const { startProxy } = await import('./proxy.js');
224
245
  await startProxy({ silent: true, autoTunnel: true });
225
246
  } catch (err) {
226
247
  // An exception here used to surface as an eternal spinner. Say what broke.
@@ -229,6 +250,9 @@ export async function launchClaude(argv) {
229
250
  console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
230
251
  process.exit(1);
231
252
  }
253
+ // The proxy may have healed onto a different port (8402 busy). config.port
254
+ // is the one it ACTUALLY bound, so re-derive every URL from it — the old
255
+ // code kept polling the port it wished for and timed out on a live proxy.
232
256
  base = `http://localhost:${config.port}/v1`;
233
257
  // PROBE /v1/info, NOT /v1/models. `models` is PROXIED UPSTREAM, so on a
234
258
  // network with a bad path to the gateway the local proxy is listening and
@@ -360,7 +384,15 @@ export async function launchClaude(argv) {
360
384
  // side by side is the only way the markup is visible while it is happening.
361
385
  + 'const ac=j.actual||{};'
362
386
  + 'const real=(ac.calls>0)?(" \\u00b7 $"+Number(ac.upstreamUsd||0).toFixed(4)+" real"+(ac.markupX?(" ("+ac.markupX+"x)"):"")):"";'
363
- + 'process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo $"+(Number(j.spendUsd)||0).toFixed(4)+" "+(j.paidCalls||0)+" call"+((j.paidCalls||0)===1?"":"s")+save+real+spill+cr+" \\u00b7 x402")}'
387
+ // TWO NUMBERS, NEVER ONE. `spent` is cumulative across every session on
388
+ // this machine (restored from ~/.openzoo/session.json); `wallet` is what
389
+ // is left to pay with. Shown unlabelled, the first was read as the
390
+ // second — "$13.86" on screen while the wallet held $0.18 and every call
391
+ // 402'd underfunded. The words `spent` and `wallet` are the whole fix.
392
+ + 'const wu=Number(j.walletUsd);'
393
+ + 'const wcol=(!isFinite(wu)||wu<=0)?"\\x1b[31m":(wu<0.5?"\\x1b[33m":"\\x1b[90m");'
394
+ + 'const wal=isFinite(wu)?(" \\u00b7 "+wcol+"wallet $"+wu.toFixed(2)+"\\x1b[0m"):"";'
395
+ + 'process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo spent $"+(Number(j.spendUsd)||0).toFixed(4)+" all-time"+wal+" \\u00b7 "+(j.paidCalls||0)+" call"+((j.paidCalls||0)===1?"":"s")+save+real+spill+cr+" \\u00b7 x402")}'
364
396
  + 'catch{process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo \\u00b7 x402")}})\'\n');
365
397
  fs.chmodSync(scriptPath, 0o755);
366
398
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
@@ -579,12 +611,17 @@ export async function launchClaude(argv) {
579
611
  }
580
612
 
581
613
  export async function launchHarness(cmd, args) {
582
- let base = `http://localhost:${config.port}/v1`;
583
- const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
584
- if (!(await oursOn(config.port))) {
585
- process.stderr.write(`openzoo: claiming :${config.port} for v${packageVersion()}\n`);
586
- await startProxy({ silent: true, autoTunnel: true });
587
- base = `http://localhost:${config.port}/v1`;
614
+ const base = `http://localhost:${config.port}/v1`;
615
+ // Fail early with a clear message rather than letting the harness spew
616
+ // connection errors — the #1 support question would otherwise be "why won't
617
+ // claude connect" when the answer is "the proxy isn't up".
618
+ try {
619
+ const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(4000) });
620
+ if (!r.ok) throw new Error(String(r.status));
621
+ } catch {
622
+ console.error(`openzoo: no proxy reachable at ${base}`);
623
+ console.error('start it first in another terminal: npx openzoo');
624
+ process.exit(1);
588
625
  }
589
626
 
590
627
  const env = claudeZooEnv(process.env, { base });
package/lib/models.js CHANGED
@@ -168,9 +168,39 @@ const tokensOf = (id) => id.toLowerCase().split(/[^a-z0-9.]+/).filter((t) => t &
168
168
  * the id is already servable (no rewrite), otherwise the closest zoo id.
169
169
  * OPENZOO_DEFAULT_MODEL is an explicit user override, not a fallback tier.
170
170
  */
171
+ /**
172
+ * UNOPENROUTER. OpenRouter is not an upstream any more (gateway, 2026-09-02):
173
+ * every completion is bought from an x402 door, and doors publish BARE ids
174
+ * (`grok-4.3`, `claude-sonnet-5`, `gemini-2.5-flash`). A harness that still
175
+ * sends the OpenRouter spelling (`x-ai/grok-4.3`, `anthropic/claude-sonnet-5`)
176
+ * is rewritten to the bare id whenever the live catalog serves that bare id.
177
+ *
178
+ * OPENZOO_UNOPENROUTER=1 strip the vendor prefix ALWAYS, catalog or not
179
+ * OPENZOO_UNOPENROUTER=0 never strip
180
+ * unset strip when the bare id is in the catalog
181
+ *
182
+ * Router aliases and `openzoo-` twins are never touched: their slash is not a
183
+ * vendor.
184
+ */
185
+ export function unopenrouter(requested, ids) {
186
+ const mode = process.env.OPENZOO_UNOPENROUTER;
187
+ if (mode === '0') return null;
188
+ const s = String(requested || '');
189
+ if (!s || isAutoModel(s) || /^openzoo[-/]/i.test(s)) return null;
190
+ const slash = s.indexOf('/');
191
+ if (slash <= 0) return null;
192
+ const bare = s.slice(slash + 1);
193
+ if (!bare || bare.includes('/')) return null;
194
+ if (mode === '1') return bare;
195
+ return Array.isArray(ids) && ids.includes(bare) ? bare : null;
196
+ }
197
+
171
198
  export function resolveModel(requested, ids) {
172
199
  // Virtual router id — never family-match, never steal via OPENZOO_DEFAULT_MODEL.
173
200
  if (isAutoModel(requested)) return null;
201
+ // Vendor-prefixed OpenRouter spelling → the bare id the doors serve.
202
+ const bareVendor = unopenrouter(requested, ids);
203
+ if (bareVendor) return bareVendor;
174
204
  // Bare Anthropic / Claude Code ids are never live on Fly/OpenRouter
175
205
  // (`claude-opus-5` → 500 unknown model). Rewrite even on a catalog miss
176
206
  // or if a gateway row lists the bare name — the request must not leave
@@ -14,7 +14,7 @@ import { spendChipLabel } from './spendProof.js';
14
14
 
15
15
  export const GROKBOT_CDP_PORT = Number(process.env.OZ_GROKBOT_CDP_PORT || 9444);
16
16
 
17
- /** Session totals for the always-on HUD pill. Message chips still fold the footer. */
17
+ /** Session totals for a floating pill when the open canvas has no footer. */
18
18
  export function sessionSpendLabel(home = os.homedir()) {
19
19
  try {
20
20
  const s = JSON.parse(fs.readFileSync(path.join(home, '.openzoo', 'session.json'), 'utf8'));
@@ -159,9 +159,7 @@ function ozEnsureSpendCss() {
159
159
  }
160
160
  s.textContent = [
161
161
  '.oz-spend{margin:.55rem 0 0;font-size:12px;color:inherit;opacity:.82;max-width:36em}',
162
- '#oz-spend-hud{opacity:1}',
163
- '#oz-spend-hud>summary{cursor:grab;background:rgba(28,28,30,.94);color:#f4f4f5;',
164
- 'border-color:rgba(255,255,255,.28);box-shadow:0 1px 8px rgba(0,0,0,.35)}',
162
+ '#oz-spend-float>summary{cursor:grab}',
165
163
  '.oz-spend>summary{cursor:help;list-style:none;display:inline-flex;align-items:center;gap:.35rem;',
166
164
  'padding:3px 9px;border-radius:999px;border:1px solid rgba(255,255,255,.18);white-space:nowrap;',
167
165
  'max-width:100%;overflow:hidden;text-overflow:ellipsis}',
@@ -215,19 +213,11 @@ export function stripSpendFromText(s) {
215
213
  return t;
216
214
  }
217
215
 
218
- function ozInComposer(el) {
219
- if (!el) return false;
220
- if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' || el.isContentEditable) return true;
221
- return !!(el.closest && el.closest('textarea, input, [contenteditable="true"]'));
222
- }
223
-
224
216
  function ozBlankSpendIn(root) {
225
- if (ozInComposer(root)) return;
226
217
  const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
227
218
  while (w.nextNode()) {
228
219
  const tn = w.currentNode;
229
220
  if (tn.parentElement && tn.parentElement.closest && tn.parentElement.closest('.oz-spend')) continue;
230
- if (ozInComposer(tn.parentElement)) continue;
231
221
  const next = stripSpendFromText(tn.nodeValue || '');
232
222
  if (next !== tn.nodeValue) tn.nodeValue = next;
233
223
  }
@@ -238,7 +228,6 @@ function ozHideSpendLeftovers() {
238
228
  for (let i = 0; i < nodes.length; i += 1) {
239
229
  const el = nodes[i];
240
230
  if (el.closest && el.closest('.oz-spend')) continue;
241
- if (ozInComposer(el)) continue;
242
231
  if (el.querySelector && el.querySelector('.oz-spend')) continue;
243
232
  const vis = ozVisibleSpendText(el);
244
233
  if (!spendOnlyText(vis)) continue;
@@ -301,11 +290,10 @@ function ozCollapseSpend() {
301
290
  for (let i = 0; i < nodes.length; i += 1) {
302
291
  const el = nodes[i];
303
292
  if (el.closest && el.closest('.oz-spend')) continue;
304
- if (ozInComposer(el)) continue;
305
293
  const t = ozVisibleSpendText(el);
306
294
  if (!/::oz-spend::|this call \$|spent \$/i.test(t)) continue;
307
295
  const host = ozSpendHost(el);
308
- if (!host || seen.has(host) || ozInComposer(host)) continue;
296
+ if (!host || seen.has(host)) continue;
309
297
  seen.add(host);
310
298
  hosts.push(host);
311
299
  }
@@ -314,11 +302,10 @@ function ozCollapseSpend() {
314
302
  const vis = ozVisibleSpendText(host);
315
303
  const split = splitSpendText(vis);
316
304
  if (!split || !split.body || split.body.length < 12) continue;
317
- if (split.summary) window.__OZ_SPEND_LAST__ = split.summary;
318
305
  ozEnsureSpendCss();
319
306
  if (spendOnlyText(vis)) {
320
307
  const prev = ozPreviousMessageCard(host);
321
- if (prev && prev !== host && !ozInComposer(prev)) {
308
+ if (prev && prev !== host) {
322
309
  ozAttachSpendChip(prev, split);
323
310
  host.setAttribute('data-oz-spend-hide', '1');
324
311
  continue;
@@ -331,46 +318,18 @@ function ozCollapseSpend() {
331
318
  ozEnsureFloatSpend();
332
319
  }
333
320
 
334
- /** Live HUD label: latest folded chip, then a read-only canvas scan, then inject snapshot. */
335
- function ozFloatLabel() {
336
- const pills = document.querySelectorAll('.oz-spend:not(#oz-spend-hud):not(#oz-spend-float) summary');
337
- if (pills.length) {
338
- const lab = String(pills[pills.length - 1].textContent || '').replace(/^ⓘ\s*/, '').trim();
339
- if (lab) {
340
- window.__OZ_SPEND_LAST__ = lab;
341
- return lab;
342
- }
343
- }
344
- try {
345
- const nodes = document.querySelectorAll('[class*="sand-message"]');
346
- for (let i = nodes.length - 1; i >= 0; i -= 1) {
347
- if (ozInComposer(nodes[i])) continue;
348
- const vis = ozVisibleSpendText(nodes[i]);
349
- if (!/::oz-spend::|this call \$|spent \$/i.test(vis)) continue;
350
- const split = splitSpendText(vis);
351
- if (split && split.summary) {
352
- window.__OZ_SPEND_LAST__ = split.summary;
353
- return split.summary;
354
- }
355
- }
356
- } catch (e) {}
357
- if (window.__OZ_SPEND_LAST__) return String(window.__OZ_SPEND_LAST__);
358
- return String(window.__OZ_SESSION_SPEND__ || '').trim();
359
- }
360
-
361
321
  function ozEnsureFloatSpend() {
362
- const label = ozFloatLabel();
363
- const leftover = document.getElementById('oz-spend-float');
364
- if (leftover) leftover.remove();
365
- let el = document.getElementById('oz-spend-hud');
366
- if (!label) {
322
+ const label = String(window.__OZ_SESSION_SPEND__ || '').trim();
323
+ const msgPills = document.querySelectorAll('.oz-spend:not(#oz-spend-float)').length;
324
+ let el = document.getElementById('oz-spend-float');
325
+ if (!label || msgPills) {
367
326
  if (el) el.remove();
368
327
  return;
369
328
  }
370
329
  ozEnsureSpendCss();
371
330
  if (!el) {
372
331
  el = document.createElement('details');
373
- el.id = 'oz-spend-hud';
332
+ el.id = 'oz-spend-float';
374
333
  el.className = 'oz-spend';
375
334
  const sum = document.createElement('summary');
376
335
  const body = document.createElement('div');
@@ -398,7 +357,7 @@ function ozPlaceFloat(el) {
398
357
  if (!el) return;
399
358
  el.style.position = 'fixed';
400
359
  el.style.zIndex = '2147483646';
401
- el.style.opacity = '1';
360
+ el.style.opacity = '0.95';
402
361
  const saved = ozSavedFloatPos();
403
362
  if (saved) {
404
363
  el.style.left = Math.max(8, saved.left) + 'px';
@@ -457,36 +416,29 @@ function ozDragFloat(el) {
457
416
 
458
417
  function ozWatchSpend() {
459
418
  let t = 0;
460
- const inComposer = () => ozInComposer(document.activeElement);
461
419
  const run = () => {
462
- try {
463
- if (inComposer()) {
464
- ozEnsureSpendCss();
465
- ozEnsureFloatSpend();
466
- } else {
467
- ozCollapseSpend();
468
- }
469
- } catch (e) {}
420
+ const a = document.activeElement;
421
+ if (a && (a.tagName === 'TEXTAREA' || a.tagName === 'INPUT' || a.isContentEditable)) return;
422
+ try { ozCollapseSpend(); } catch (e) {}
470
423
  };
471
- const debounced = () => { clearTimeout(t); t = setTimeout(run, 400); };
424
+ const debounced = () => { clearTimeout(t); t = setTimeout(run, 600); };
472
425
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
473
426
  else run();
474
427
  try {
475
428
  const mo = new MutationObserver(debounced);
476
- const start = () => { if (document.body) mo.observe(document.body, { childList: true, subtree: true }); };
429
+ const start = () => { if (document.body) mo.observe(document.body, { childList: true, subtree: false }); };
477
430
  if (document.body) start();
478
431
  else document.addEventListener('DOMContentLoaded', start);
479
432
  } catch (e) {}
480
433
  try { window.addEventListener('resize', debounced); } catch (e) {}
481
- try { setInterval(run, 2000); } catch (e) {}
482
434
  }
483
435
 
484
436
  export function spendChipSource() {
485
437
  return [
486
438
  '(function ozSpendChip(){',
487
439
  "'use strict';",
488
- 'if (window.__OZ_SPEND_CHIP__ === 13) return;',
489
- 'window.__OZ_SPEND_CHIP__ = 13;',
440
+ 'if (window.__OZ_SPEND_CHIP__ === 12) return;',
441
+ 'window.__OZ_SPEND_CHIP__ = 12;',
490
442
  chipUsd.toString(),
491
443
  labelFromSpendBody.toString(),
492
444
  spendLinesOnly.toString(),
@@ -496,13 +448,11 @@ export function spendChipSource() {
496
448
  ozSpendHost.toString(),
497
449
  ozVisibleSpendText.toString(),
498
450
  stripSpendFromText.toString(),
499
- ozInComposer.toString(),
500
451
  ozBlankSpendIn.toString(),
501
452
  ozHideSpendLeftovers.toString(),
502
453
  ozPreviousMessageCard.toString(),
503
454
  ozAttachSpendChip.toString(),
504
455
  ozCollapseSpend.toString(),
505
- ozFloatLabel.toString(),
506
456
  ozEnsureFloatSpend.toString(),
507
457
  ozSavedFloatPos.toString(),
508
458
  ozPlaceFloat.toString(),
package/lib/proxy.js CHANGED
@@ -9,11 +9,11 @@ import {
9
9
  } from './config.js';
10
10
  import { execSync } from 'node:child_process';
11
11
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
12
- import { withOnrampLink, settleFailCopy, isFundInstruction } from './stripeOnramp.js';
12
+ import { withOnrampLink } from './stripeOnramp.js';
13
13
  import { tokenBalance } from './x402.js';
14
14
  import { evmTokenBalance } from './evm.js';
15
15
  import { autoContext } from './autobind.js';
16
- import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows } from './models.js';
16
+ import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows, unopenrouter } from './models.js';
17
17
 
18
18
  /**
19
19
  * Quoteable catalog ids, cached 5 minutes, for the fuzzy /v1/models/<id> probe.
@@ -38,89 +38,19 @@ import { receiptUsedCogs, receiptDirectUsd, pairActualBilled } from './racesettl
38
38
  import { fetchHeaders } from './fetch.js';
39
39
  import { attachX402Proof } from './spendProof.js';
40
40
 
41
- /** Kill whatever is LISTEN on this port except this process.
42
- * lsof first (macOS), then fuser, then /proc (Omarchy/Arch with neither).
43
- * Every openzoo subcommand that binds :8402 goes through this. Hopping to
44
- * 8403 was the second-burner bug. */
41
+ /** Kill whatever is LISTEN on this port except this process. */
45
42
  export function killListen(port, run = execSync) {
46
- const n = Number(port);
47
- const seen = new Set();
48
- const addFrom = (out) => {
49
- for (const tok of String(out || '').split(/[^\d]+/)) {
50
- const pid = Number(tok);
51
- if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) seen.add(pid);
52
- }
53
- };
54
- const tryRun = (cmd, opts = {}) => {
55
- try {
56
- return run(cmd, { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], ...opts });
57
- } catch { return ''; }
58
- };
59
- addFrom(tryRun(`lsof -nP -iTCP:${n} -sTCP:LISTEN -t`));
60
- addFrom(tryRun(`fuser -n tcp ${n}`));
61
- if (process.platform === 'linux' && seen.size === 0) {
62
- addFrom(tryRun(`python3 - ${n}`, {
63
- timeout: 2500,
64
- input: `import os, glob, sys
65
- port=int(sys.argv[1]); hx=f'{port:04X}'
66
- inodes=set(); pids=set()
67
- for path in ('/proc/net/tcp','/proc/net/tcp6'):
68
- try:
69
- for line in open(path):
70
- p=line.split()
71
- if len(p)<10: continue
72
- if p[1].split(':')[-1].upper()==hx: inodes.add(p[9])
73
- except FileNotFoundError:
74
- pass
75
- for fd in glob.glob('/proc/[0-9]*/fd/[0-9]*'):
76
- try: t=os.readlink(fd)
77
- except OSError: continue
78
- if any(ino and ino!='0' and ino in t for ino in inodes):
79
- try: pids.add(int(fd.split('/')[2]))
80
- except (OSError, ValueError): pass
81
- print('\\n'.join(str(p) for p in pids if p != os.getpid()))
82
- `,
83
- }));
84
- }
85
- for (const pid of seen) {
86
- try { run(`kill ${pid}`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
87
- try { run(`kill -9 ${pid}`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
88
- }
89
- return [...seen];
90
- }
91
-
92
- /** This package.json version. Same one /v1/info and /v1/session publish. */
93
- export function packageVersion() {
94
- return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
95
- }
96
-
97
- /** True when the listener on this port is THIS openzoo version or newer.
98
- * Missing / unparseable version is stale (0.49.x answered with no version).
99
- * Older than us is stale. Newer we leave alone so we never downgrade. */
100
- export async function oursOn(port = config.port) {
101
- const mine = packageVersion();
102
- const parse = (v) => {
103
- const m = String(v || '').trim().match(/^(\d+)\.(\d+)\.(\d+)/);
104
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
105
- };
106
- const attachable = (theirs) => {
107
- const a = parse(theirs), b = parse(mine);
108
- if (!a || !b) return false;
109
- for (let i = 0; i < 3; i++) {
110
- if (a[i] > b[i]) return true;
111
- if (a[i] < b[i]) return false;
43
+ try {
44
+ const pids = run(`lsof -nP -iTCP:${Number(port)} -sTCP:LISTEN -t`, {
45
+ encoding: 'utf8', timeout: 2000,
46
+ }).trim().split('\n').map(Number).filter((n) => Number.isInteger(n) && n > 0 && n !== process.pid);
47
+ for (const pid of pids) {
48
+ try { run(`kill ${pid}`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
112
49
  }
113
- return true;
114
- };
115
- for (const path of ['/v1/info', '/v1/session']) {
116
- try {
117
- const r = await fetch(`http://127.0.0.1:${Number(port)}${path}`, { signal: AbortSignal.timeout(1500) });
118
- if (!r.ok) continue;
119
- const j = await r.json().catch(() => ({}));
120
- if (attachable(j.version)) return true;
121
- } catch { /* try next */ }
50
+ return pids;
51
+ } catch {
52
+ return [];
122
53
  }
123
- return false;
124
54
  }
125
55
 
126
56
  // THE SHIM IS A FACILITATOR, NOT A MIDDLEBOX.
@@ -458,6 +388,33 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
458
388
  let creditInflight = null;
459
389
  let lastPrices = {};
460
390
  let pricesAt = 0;
391
+ // WALLET BALANCE, CACHED LIKE CREDIT.
392
+ //
393
+ // The HUD showed one number — cumulative spend — and a user read it as their
394
+ // balance: "$13.86" beside a wallet holding $0.18, then every call 402'd
395
+ // "underfunded" and nothing on screen explained why. Spend is a total to
396
+ // date; the wallet is what is left. Both, labelled, or neither is legible.
397
+ //
398
+ // Never awaited on the /v1/info path: this quotes the gateway and reads
399
+ // chain balances, and the statusline gives it 1s. Serve last-known, refresh
400
+ // behind it — the same contract refreshCredit() keeps.
401
+ let walletUsd = null;
402
+ let walletAt = 0;
403
+ let walletInflight = null;
404
+ const refreshWallet = async (force = false) => {
405
+ if (!force && Date.now() - walletAt < 60000 && walletUsd != null) return walletUsd;
406
+ if (walletInflight) return walletInflight;
407
+ walletInflight = (async () => {
408
+ try {
409
+ const { affordableUsd } = await import('./info.js');
410
+ const v = await affordableUsd();
411
+ if (Number.isFinite(v)) { walletUsd = v; walletAt = Date.now(); }
412
+ } catch { /* keep last known */ }
413
+ walletInflight = null;
414
+ return walletUsd;
415
+ })();
416
+ return walletInflight;
417
+ };
461
418
  const refreshCredit = async (force = false) => {
462
419
  if (!force && Date.now() - creditAt < 20000 && creditUsd != null) return creditUsd;
463
420
  if (creditInflight) return creditInflight;
@@ -578,22 +535,23 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
578
535
  const p0 = (req.url || '').split('?')[0];
579
536
  if (req.method === 'GET' && (p0 === '/v1/info' || p0 === '/info')) {
580
537
  refreshCredit();
538
+ refreshWallet();
581
539
  const self = viaTunnel && tunnelGate?.publicUrl
582
540
  ? `${tunnelGate.publicUrl}/v1`
583
541
  : `http://localhost:${config.port}/v1`;
584
542
  res.writeHead(200, { 'content-type': 'application/json' });
585
- const { version: ozVersion } = JSON.parse(
586
- readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
587
- );
588
543
  res.end(JSON.stringify({
589
544
  youAreTalkingTo: 'openzoo proxy',
590
- version: ozVersion,
591
- solana: client.address,
592
545
  yourEndpoint: self,
593
546
  reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
594
547
  publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
595
548
  servedRequests,
549
+ // SPENT-TO-DATE, not a balance: restored from ~/.openzoo/session.json at
550
+ // startup, so it spans every session on this machine.
596
551
  spendUsd: sessionSpent,
552
+ spendScope: 'all-time on this machine',
553
+ // WHAT IS LEFT to pay with. null while the first read is in flight.
554
+ walletUsd,
597
555
  creditUsd,
598
556
  // WHAT THE SAME CALLS WOULD HAVE COST DIRECT. Spend on its own is a
599
557
  // bill; spend beside the counterfactual is the product.
@@ -693,10 +651,43 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
693
651
  const isPaidPost = req.method === 'POST'
694
652
  && /\/(chat\/completions|completions|messages|responses)$/.test(rawPath);
695
653
  let wantsStream = false;
654
+ // RECEIPTS ARE OPT-OUT. `disableStats: true` in the body (or the
655
+ // x-openzoo-disable-stats header) means this caller does not want the x402
656
+ // block, so we neither attach our settle proof nor pass the gateway's
657
+ // receipt through. The field is deliberately FORWARDED, not stripped: the
658
+ // gateway reads it too and drops its own half. Absent/false keeps today's
659
+ // behaviour, because our own spend line reads that block.
660
+ let statsOff = false;
661
+ if (isPaidPost) {
662
+ try {
663
+ const b = JSON.parse(bodyBuf.toString('utf8'));
664
+ const h = req.headers['x-openzoo-disable-stats'];
665
+ const truthy = (v) => v === true || v === 'true' || v === '1' || v === 1;
666
+ statsOff = truthy(b?.disableStats) || truthy(Array.isArray(h) ? h[0] : h);
667
+ } catch { /* not JSON */ }
668
+ }
696
669
  if (isPaidPost) {
697
670
  servedRequests += 1;
698
671
  say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
699
672
  try { wantsStream = JSON.parse(bodyBuf.toString('utf8'))?.stream === true; } catch { /* not JSON */ }
673
+ // UNOPENROUTER THE MODEL ID before anything downstream sees the body —
674
+ // the replay key, the outage gate, the wire. A vendor-prefixed
675
+ // OpenRouter spelling becomes the bare id the doors serve when the
676
+ // catalog lists it (OPENZOO_UNOPENROUTER=1 forces it, =0 disables).
677
+ // This is the one place the body is rewritten; see models.js.
678
+ try {
679
+ const parsed = JSON.parse(bodyBuf.toString('utf8'));
680
+ if (parsed && typeof parsed.model === 'string') {
681
+ let ids = [];
682
+ try { ids = await catalogIdsCached(`${config.apiBase}/v1/models`, upstreamHeaders(req)); } catch { /* catalog unreachable: only the forced mode rewrites */ }
683
+ const bare = unopenrouter(parsed.model, ids);
684
+ if (bare) {
685
+ say(` model ${parsed.model} -> ${bare} (bare id: doors, not OpenRouter)`);
686
+ parsed.model = bare;
687
+ bodyBuf = Buffer.from(JSON.stringify(parsed));
688
+ }
689
+ }
690
+ } catch { /* not JSON */ }
700
691
  }
701
692
 
702
693
  // Retry of a body we answered seconds ago? Serve the cached completion —
@@ -867,7 +858,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
867
858
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
868
859
  }
869
860
  if (data?.object === 'chat.completion') {
870
- if (paid && receipt) {
861
+ if (statsOff) {
862
+ // The gateway already dropped its half; drop ours, and any block an
863
+ // older gateway in front of us still attached.
864
+ delete data.x402;
865
+ } else if (paid && receipt) {
871
866
  attachX402Proof(data, {
872
867
  tx: receipt.tx,
873
868
  memo: receipt.memo || accept?.extra?.memo,
@@ -904,13 +899,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
904
899
  rememberSpend();
905
900
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
906
901
  };
907
- // A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote.
908
- // The client-side balance check is advisory, so a funded wallet can still
909
- // fail on-chain / at the facilitator, and the gateway answers the paid
910
- // retry with a fresh 402. Relaying that as "wallet underfunded" + Whop
911
- // copy-paste blamed burners that already paid. Surface the gateway's
912
- // real reason; only prepend fund-me copy on genuine insufficient_funds.
902
+ // A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote
903
+ // the client-side balance check is advisory, so a wallet that looks
904
+ // fundable can still fail on-chain, and the gateway answers the paid
905
+ // retry with a fresh 402. Relaying that raw meant Claude Code printed a
906
+ // half-kilobyte accepts[] blob at the user instead of the one thing they
907
+ // need: the price, what the wallet holds, and where to send funds.
913
908
  if (response.status === 402) {
909
+ let quoted = '';
914
910
  let usd;
915
911
  let q402 = null;
916
912
  try { q402 = await response.clone().json(); } catch { q402 = null; }
@@ -927,21 +923,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
927
923
  return;
928
924
  }
929
925
  try {
930
- usd = Number(q402?.accepts?.[0]?.extra?.billedUsd);
931
- if (!Number.isFinite(usd)) usd = undefined;
932
- } catch { usd = undefined; }
933
- const copy = settleFailCopy(q402);
934
- // paid:true never "wallet underfunded", never Whop unless the
935
- // gateway itself named insufficient_funds. UnderfundedError (empty
936
- // wallet preflight) is handled in the catch below.
937
- let msg = copy.message;
938
- const wantOnramp = copy.code === 'insufficient_funds'
939
- || (!paid && isFundInstruction(copy.reason, copy));
940
- if (wantOnramp) {
941
- msg = await withOnrampLink(msg, { solana: client.address, usd, code: copy.code });
942
- }
943
- log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : `402: ${msg.slice(0, 140)}`);
944
- jsonErr(res, paid ? copy.status : 402, msg);
926
+ const q = q402;
927
+ usd = Number(q?.accepts?.[0]?.extra?.billedUsd);
928
+ if (Number.isFinite(usd)) quoted = ` This call needs ≈$${usd.toFixed(4)}.`;
929
+ } catch { /* body was not the quote after all */ }
930
+ const msg = await withOnrampLink(
931
+ `openzoo wallet underfunded payment did not settle.${quoted} `
932
+ + `Fund it and retry: send USDC (or TOKEN/LEOS for half price) to ${client.address} on Solana, `
933
+ + `or USDC to ${client.evmAddress} on Base. Check with: openzoo balance`,
934
+ { solana: client.address, usd },
935
+ );
936
+ log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : 'onramp: no fund blurb');
937
+ jsonErr(res, 402, msg);
945
938
  return;
946
939
  }
947
940
  await relay(res, response, meterStreamed);
@@ -974,18 +967,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
974
967
  // OPENZOO_BIND=0.0.0.0 AND a tunnel token, so that port stays gated exactly
975
968
  // like the public tunnel path.
976
969
  const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
977
- // SELF-HEAL :8402. Kill whoever is on THIS port, then bind it. Never hop to
978
- // 8403 that was the second-burner. Every subcommand that calls startProxy
979
- // (openzoo, claude, bot, web, cursor, aoe, grok, tunnel) goes through here.
980
- // Reusing a leftover listener left a stale PayClient serving $0 after a
981
- // TOKEN top-up, and an old npx cache answering /v1 with no version.
982
- {
983
- const pids = killListen(config.port);
984
- if (pids.length) {
985
- say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
986
- await new Promise((r) => setTimeout(r, 400));
987
- }
988
- }
970
+ // SELF-HEAL A TAKEN PORT. Walk up to the next free port instead of dying;
971
+ // the caller reads config.port back out, so every URL printed afterwards is
972
+ // the one we actually bound. A healthy proxy already on the port is KILLED
973
+ // reusing it left a stale PayClient serving $0 after a TOKEN top-up.
974
+ const wanted = config.port;
989
975
  for (let attempt = 0; ; attempt++) {
990
976
  try {
991
977
  await new Promise((resolve, reject) => {
@@ -995,12 +981,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
995
981
  });
996
982
  break;
997
983
  } catch (e) {
998
- if (e?.code !== 'EADDRINUSE' || attempt >= 8) throw e;
999
- const pids = killListen(config.port);
1000
- if (pids.length) say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
1001
- await new Promise((r) => setTimeout(r, 300));
984
+ if (e?.code !== 'EADDRINUSE' || attempt >= 12) throw e;
985
+ if (attempt === 0) {
986
+ const pids = killListen(config.port);
987
+ if (pids.length) {
988
+ say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
989
+ await new Promise((r) => setTimeout(r, 400));
990
+ continue;
991
+ }
992
+ }
993
+ config.port += 1;
994
+ say(`openzoo: :${config.port - 1} busy — trying :${config.port}`);
1002
995
  }
1003
996
  }
997
+ if (config.port !== wanted) say(`openzoo: listening on :${config.port} (:${wanted} was busy)`);
1004
998
 
1005
999
  // AUTO-PREPAY. Paying on-chain per call is where the latency lives: credit
1006
1000
  // is applied automatically server-side whenever a balance covers the quote,
package/lib/setup.js CHANGED
@@ -214,7 +214,7 @@ async function printStartupDiagnostic(base, which) {
214
214
 
215
215
  console.log('openzoo diagnostic');
216
216
  console.log(` version : ${version} node ${process.version} ${process.platform}/${process.arch}`);
217
- console.log(` port ${config.port} : ${portBusy ? 'occupied (steal unless this exact version)' : 'free'}`);
217
+ console.log(` port ${config.port} : ${portBusy ? 'ALREADY SERVING (an older proxy may still be running — kill it if this build is newer)' : 'free'}`);
218
218
  console.log(` editor : ${picked ? `${picked.which} @ ${picked.cmd}` : 'NONE FOUND'}`);
219
219
  console.log(` running : ${picked ? (q(() => editorRunning(picked.which), false) ? 'yes — will be quit so settings stick' : 'no') : '-'}`);
220
220
  console.log(` backend : ${hosts}`);
@@ -245,17 +245,9 @@ export async function setupEditor(which, target) {
245
245
  // Declared out here: the tunnel-rebind hook is registered further down, well
246
246
  // outside the block that starts the proxy.
247
247
  let started = null;
248
- const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
249
- if (await oursOn(config.port)) {
250
- console.log(`proxy v${packageVersion()} already on ${base} keeping it`);
251
- try {
252
- const info = await (await fetch(`${base}/info`)).json();
253
- publicUrl = (info?.publicTunnel || '').replace(/\/v1$/, '') || null;
254
- tunnelKey = info?.tunnelToken ?? tunnelKey;
255
- if (publicUrl) console.log(`tunnel: ${publicUrl}/v1 (from the running proxy)`);
256
- } catch { /* no /info */ }
257
- } else {
258
- console.log(`${(await proxyUp(base)) ? 'stale proxy — stealing' : 'starting proxy on'} ${base} (+ public tunnel)...`);
248
+ if (!(await proxyUp(base))) {
249
+ console.log(`starting proxy on ${base} (+ public tunnel)...`);
250
+ const { startProxy } = await import('./proxy.js');
259
251
  started = await startProxy({ silent: true, autoTunnel: true });
260
252
  publicUrl = started?.publicUrl ?? null;
261
253
  tunnelKey = started?.tunnelToken ?? null;
@@ -306,6 +298,15 @@ export async function setupEditor(which, target) {
306
298
  console.log(' most common cause here: no working IPv6 route (we already force');
307
299
  console.log(' --edge-ip-version 4). check: npx openzoo tunnel for the raw log.');
308
300
  }
301
+ } else {
302
+ console.log(`proxy already running on ${base}`);
303
+ // A proxy someone else started owns the tunnel; ask it for the public URL.
304
+ try {
305
+ const info = await (await fetch(`${base}/info`)).json();
306
+ publicUrl = (info?.publicTunnel || '').replace(/\/v1$/, '') || null;
307
+ tunnelKey = info?.tunnelToken ?? tunnelKey;
308
+ if (publicUrl) console.log(`tunnel: ${publicUrl}/v1 (from the running proxy)`);
309
+ } catch { /* no /info — fall through to the localhost warning below */ }
309
310
  }
310
311
  // What the EDITOR is configured with. Localhost only as a last resort, and
311
312
  // said out loud, because it will fail with the private-networks error.
@@ -103,58 +103,8 @@ export function whopFundBlurb(solana) {
103
103
  ].join('\n');
104
104
  }
105
105
 
106
- /**
107
- * Genuine empty-wallet / fund-me copy. A post-pay settle failure
108
- * ("payment did not settle" with a gateway reason and no underfunded
109
- * wording) is NOT this — those wallets are often funded; the 402 is
110
- * the facilitator or upstream.
111
- */
112
- export function isFundInstruction(text, extra = {}) {
113
- const code = extra.code ?? extra.advice?.code;
114
- if (String(code || '') === 'insufficient_funds') return true;
115
- const s = String(text || '');
116
- if (!s) return false;
117
- if (/\b(?:wallet underfunded|empty wallet|wallet is empty|needs more than the wallet holds|insufficient[_\s]funds)\b/i.test(s)) return true;
118
- if (/\bunderfunded\b/i.test(s)) return true;
119
- if (/\bsend (?:usdc|a few cents)\b/i.test(s)) return true;
120
- if (/\bno offered payment row is affordable/i.test(s)) return true;
121
- return false;
122
- }
123
-
124
- function gatewayReason(q402) {
125
- if (!q402 || typeof q402 !== 'object') return '';
126
- const err = q402.error;
127
- const advice = q402.advice;
128
- if (typeof err?.message === 'string' && err.message.trim()) return err.message.trim();
129
- if (typeof err === 'string' && err.trim()) return err.trim();
130
- if (typeof advice?.message === 'string' && advice.message.trim()) return advice.message.trim();
131
- if (typeof advice === 'string' && advice.trim()) return advice.trim();
132
- if (advice && typeof advice === 'object') {
133
- const bits = [advice.code, advice.reason, advice.detail].filter((x) => typeof x === 'string' && x.trim());
134
- if (bits.length) return bits.join(': ');
135
- }
136
- return '';
137
- }
138
-
139
- /**
140
- * Copy for a 402 AFTER PayClient already signed and retried (paid:true).
141
- * Never "wallet underfunded" — that string is reserved for preflight
142
- * empty-wallet errors. Prefix stays greppable as "payment did not settle".
143
- */
144
- export function settleFailCopy(q402) {
145
- const reason = gatewayReason(q402);
146
- const code = q402?.advice?.code || q402?.error?.code || '';
147
- const fund = isFundInstruction(reason, { code, advice: q402?.advice });
148
- const message = reason
149
- ? `openzoo payment did not settle: ${reason}`
150
- : 'openzoo payment did not settle';
151
- const upstreamish = /upstream|facilitator|internal(?: server)? error|settle(?:ment)? (?:failed|error)/i.test(reason) && !fund;
152
- return { message, status: upstreamish ? 502 : 402, fund, reason, code: String(code || '') };
153
- }
154
-
155
106
  export async function withOnrampLink(text, dest) {
156
107
  const body = String(text || '').trim();
157
- if (!isFundInstruction(body, dest)) return body;
158
108
  const blurb = whopFundBlurb(dest?.solana);
159
109
  if (!blurb) return body;
160
110
  if (/ties to your account/i.test(body) && body.includes(String(dest.solana))) return body;
@@ -0,0 +1,31 @@
1
+ // Keyless web search for `openzoo ask --web`: DuckDuckGo's HTML endpoint,
2
+ // scraped for title / url / snippet. No API key, no account, one GET. The
3
+ // only thing that leaves is the question text, to duckduckgo.com.
4
+ const strip = (s) => String(s || '')
5
+ .replace(/<[^>]+>/g, '')
6
+ .replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
7
+ .replace(/\s+/g, ' ').trim();
8
+
9
+ export async function webSearch(query, max = 5) {
10
+ const res = await fetch('https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query), {
11
+ headers: { 'user-agent': 'Mozilla/5.0 openzoo-ask/1.0' },
12
+ signal: AbortSignal.timeout(12_000),
13
+ });
14
+ if (!res.ok) throw new Error(`duckduckgo HTTP ${res.status}`);
15
+ const html = await res.text();
16
+ const out = [];
17
+ const re = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
18
+ let m;
19
+ while ((m = re.exec(html)) && out.length < Math.max(1, Math.min(10, max))) {
20
+ let url = m[1];
21
+ const redirected = url.match(/uddg=([^&]+)/);
22
+ if (redirected) url = decodeURIComponent(redirected[1]);
23
+ out.push({ title: strip(m[2]), url, snippet: strip(m[3]).slice(0, 400) });
24
+ }
25
+ return out;
26
+ }
27
+
28
+ export function formatWebResults(query, hits) {
29
+ const lines = hits.map((h, i) => `${i + 1}. ${h.title} — ${h.url}\n ${h.snippet}`);
30
+ return `Web search results for "${query}" (DuckDuckGo, fetched just now; cite the url when you rely on one):\n${lines.join('\n')}`;
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.93",
3
+ "version": "0.50.95",
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",