openzoo 0.50.91 → 0.50.93

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 listening
9
+ * - starts the proxy if it is not already THIS version on :8402 (steals stale)
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,7 +16,8 @@
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 } from 'node:fs';
19
+ import { existsSync, readdirSync as fsReaddir, readFileSync } from 'node:fs';
20
+ import { execSync } from 'node:child_process';
20
21
  import { homedir, platform } from 'node:os';
21
22
  import { dirname, join, sep } from 'node:path';
22
23
  import { fileURLToPath } from 'node:url';
@@ -50,10 +51,34 @@ if (!occ) {
50
51
  const PROXY_PORT = Number(process.env.OPENZOO_PROXY_PORT || 8402);
51
52
  const PROXY_URL = `http://localhost:${PROXY_PORT}/v1`;
52
53
 
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
+
53
70
  async function proxyUp() {
54
71
  try {
55
72
  const r = await fetch(`http://localhost:${PROXY_PORT}/v1/info`, { signal: AbortSignal.timeout(1500) });
56
- return r.ok;
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;
57
82
  } catch { return false; }
58
83
  }
59
84
 
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>] [--web [--web-results N]]');
333
+ if (!question) throw new Error('usage: openzoo ask "<question>" [--context <id>] [--model <id>] [--system <text>]');
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,20 +344,7 @@ 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
- 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
- }
347
+ const system = si !== -1 ? process.argv[si + 1] : '';
361
348
  const { PayClient } = await import('../lib/pay.js');
362
349
  const { config } = await import('../lib/config.js');
363
350
  const client = new PayClient();
@@ -402,17 +389,6 @@ async function main() {
402
389
  case '-h':
403
390
  console.log(HELP);
404
391
  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
- }
416
392
  default:
417
393
  console.error(`unknown command: ${cmd}\n`);
418
394
  console.log(HELP);
package/lib/aoe.js CHANGED
@@ -287,9 +287,15 @@ export async function setupAoe(argv = []) {
287
287
 
288
288
  const base = `http://localhost:${config.port}/v1`;
289
289
  if (!flags.has('--no-proxy')) {
290
- if (await proxyUp(base)) {
291
- console.error(`openzoo: proxy already up on ${base}`);
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}`);
292
293
  } 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
+ }
293
299
  const { pid, logPath } = startDetachedProxy({ tunnel: flags.has('--tunnel') });
294
300
  let up = false;
295
301
  for (let i = 0; i < 40 && !up; i++) {
@@ -3080,6 +3080,8 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
3080
3080
  data.error.message = raw;
3081
3081
  try {
3082
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.
3083
3085
  if (isFundInstruction(raw)) {
3084
3086
  const { loadOrCreateWallet } = await import('./wallet.js');
3085
3087
  const w = loadOrCreateWallet();
package/lib/launch.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * harness with the two env vars set. The proxy must already be running
10
10
  * (`npx openzoo` in another terminal); we check first and say so if not.
11
11
  */
12
- import { spawn, spawnSync, execSync } from 'node:child_process';
12
+ import { spawn, spawnSync } from 'node:child_process';
13
13
  import fs from 'node:fs';
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
@@ -191,67 +191,20 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
191
191
  * Both get ANTHROPIC_BASE_URL so inference pays x402.
192
192
  */
193
193
 
194
- /** Omarchy often has no lsof. Kill whatever is bound to the proxy port. */
195
- function killPort(port) {
196
- const n = Number(port);
197
- for (const cmd of [
198
- `lsof -t -iTCP:${n} -sTCP:LISTEN | xargs kill -9`,
199
- `fuser -k ${n}/tcp`,
200
- ]) {
201
- try { execSync(cmd, { stdio: 'ignore', timeout: 2000, shell: true }); } catch { /* tool missing */ }
202
- }
203
- // /proc fallback — no lsof/fuser required (Omarchy).
204
- try {
205
- execSync(`python3 - ${n}`, {
206
- stdio: 'ignore', timeout: 2500, input: `import os, glob, sys
207
- port=int(sys.argv[1]); hx=f'{port:04X}'
208
- inodes=set()
209
- for path in ('/proc/net/tcp','/proc/net/tcp6'):
210
- try:
211
- for line in open(path):
212
- p=line.split()
213
- if len(p)<10: continue
214
- if p[1].split(':')[-1].upper()==hx: inodes.add(p[9])
215
- except FileNotFoundError:
216
- pass
217
- for fd in glob.glob('/proc/[0-9]*/fd/[0-9]*'):
218
- try: t=os.readlink(fd)
219
- except OSError: continue
220
- if any(ino and ino!='0' and ino in t for ino in inodes):
221
- try: os.kill(int(fd.split('/')[2]), 9)
222
- except OSError: pass
223
- `,
224
- });
225
- } catch { /* no python or not linux */ }
226
- }
227
194
 
228
195
  export async function launchClaude(argv) {
229
- // let, not const: startProxy can heal onto a different port and every URL
230
- // below must follow the port we actually bound.
196
+ // NEVER hop. :8402 is the product. startProxy steals a stale listener.
231
197
  let base = `http://localhost:${config.port}/v1`;
232
- // AUTO-START THE PROXY. One command should just work — if nothing is listening,
233
- // boot the proxy in THIS process (it stays alive because claude runs in the
234
- // foreground below), rather than making the user run `npx openzoo` first.
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.
235
200
  let up = false;
236
- // /info, not /models see the poll below. "Is a proxy already listening" must
237
- // not be answered by an endpoint that needs the gateway, or a user whose
238
- // upstream is flaky gets told to start a proxy that is already running.
239
- const mine = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
240
- try {
241
- const r = await fetch(`${base}/info`, { signal: AbortSignal.timeout(3000) });
242
- if (r.ok) {
243
- const info = await r.json().catch(() => ({}));
244
- const theirs = String(info.version || '');
245
- if (!theirs || theirs !== mine) {
246
- process.stderr.write(`openzoo: replacing stale proxy ${theirs ? 'v'+theirs : '(no version)'} with v${mine} on :${config.port}\n`);
247
- killPort(config.port);
248
- await new Promise((ok) => setTimeout(ok, 300));
249
- up = false;
250
- } else {
251
- up = true;
252
- }
253
- }
254
- } catch { 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
+ }
255
208
  if (!up) {
256
209
  // NEVER GO SILENT DURING STARTUP. silent:true routes the proxy's own lines
257
210
  // to ~/.openzoo/proxy.log so payment receipts cannot corrupt Claude Code's
@@ -268,7 +221,6 @@ export async function launchClaude(argv) {
268
221
  tick.unref?.();
269
222
  const done = (msg) => { clearInterval(tick); process.stderr.write(`\r\x1b[2Kopenzoo: ${msg}\n`); };
270
223
  try {
271
- const { startProxy } = await import('./proxy.js');
272
224
  await startProxy({ silent: true, autoTunnel: true });
273
225
  } catch (err) {
274
226
  // An exception here used to surface as an eternal spinner. Say what broke.
@@ -277,9 +229,6 @@ export async function launchClaude(argv) {
277
229
  console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
278
230
  process.exit(1);
279
231
  }
280
- // The proxy may have healed onto a different port (8402 busy). config.port
281
- // is the one it ACTUALLY bound, so re-derive every URL from it — the old
282
- // code kept polling the port it wished for and timed out on a live proxy.
283
232
  base = `http://localhost:${config.port}/v1`;
284
233
  // PROBE /v1/info, NOT /v1/models. `models` is PROXIED UPSTREAM, so on a
285
234
  // network with a bad path to the gateway the local proxy is listening and
@@ -630,17 +579,12 @@ export async function launchClaude(argv) {
630
579
  }
631
580
 
632
581
  export async function launchHarness(cmd, args) {
633
- const base = `http://localhost:${config.port}/v1`;
634
- // Fail early with a clear message rather than letting the harness spew
635
- // connection errors — the #1 support question would otherwise be "why won't
636
- // claude connect" when the answer is "the proxy isn't up".
637
- try {
638
- const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(4000) });
639
- if (!r.ok) throw new Error(String(r.status));
640
- } catch {
641
- console.error(`openzoo: no proxy reachable at ${base}`);
642
- console.error('start it first in another terminal: npx openzoo');
643
- process.exit(1);
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`;
644
588
  }
645
589
 
646
590
  const env = claudeZooEnv(process.env, { base });
package/lib/models.js CHANGED
@@ -168,39 +168,9 @@ 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
-
198
171
  export function resolveModel(requested, ids) {
199
172
  // Virtual router id — never family-match, never steal via OPENZOO_DEFAULT_MODEL.
200
173
  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;
204
174
  // Bare Anthropic / Claude Code ids are never live on Fly/OpenRouter
205
175
  // (`claude-opus-5` → 500 unknown model). Rewrite even on a catalog miss
206
176
  // 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 a floating pill when the open canvas has no footer. */
17
+ /** Session totals for the always-on HUD pill. Message chips still fold the 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,7 +159,9 @@ 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-float>summary{cursor:grab}',
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)}',
163
165
  '.oz-spend>summary{cursor:help;list-style:none;display:inline-flex;align-items:center;gap:.35rem;',
164
166
  'padding:3px 9px;border-radius:999px;border:1px solid rgba(255,255,255,.18);white-space:nowrap;',
165
167
  'max-width:100%;overflow:hidden;text-overflow:ellipsis}',
@@ -213,11 +215,19 @@ export function stripSpendFromText(s) {
213
215
  return t;
214
216
  }
215
217
 
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
+
216
224
  function ozBlankSpendIn(root) {
225
+ if (ozInComposer(root)) return;
217
226
  const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
218
227
  while (w.nextNode()) {
219
228
  const tn = w.currentNode;
220
229
  if (tn.parentElement && tn.parentElement.closest && tn.parentElement.closest('.oz-spend')) continue;
230
+ if (ozInComposer(tn.parentElement)) continue;
221
231
  const next = stripSpendFromText(tn.nodeValue || '');
222
232
  if (next !== tn.nodeValue) tn.nodeValue = next;
223
233
  }
@@ -228,6 +238,7 @@ function ozHideSpendLeftovers() {
228
238
  for (let i = 0; i < nodes.length; i += 1) {
229
239
  const el = nodes[i];
230
240
  if (el.closest && el.closest('.oz-spend')) continue;
241
+ if (ozInComposer(el)) continue;
231
242
  if (el.querySelector && el.querySelector('.oz-spend')) continue;
232
243
  const vis = ozVisibleSpendText(el);
233
244
  if (!spendOnlyText(vis)) continue;
@@ -290,10 +301,11 @@ function ozCollapseSpend() {
290
301
  for (let i = 0; i < nodes.length; i += 1) {
291
302
  const el = nodes[i];
292
303
  if (el.closest && el.closest('.oz-spend')) continue;
304
+ if (ozInComposer(el)) continue;
293
305
  const t = ozVisibleSpendText(el);
294
306
  if (!/::oz-spend::|this call \$|spent \$/i.test(t)) continue;
295
307
  const host = ozSpendHost(el);
296
- if (!host || seen.has(host)) continue;
308
+ if (!host || seen.has(host) || ozInComposer(host)) continue;
297
309
  seen.add(host);
298
310
  hosts.push(host);
299
311
  }
@@ -302,10 +314,11 @@ function ozCollapseSpend() {
302
314
  const vis = ozVisibleSpendText(host);
303
315
  const split = splitSpendText(vis);
304
316
  if (!split || !split.body || split.body.length < 12) continue;
317
+ if (split.summary) window.__OZ_SPEND_LAST__ = split.summary;
305
318
  ozEnsureSpendCss();
306
319
  if (spendOnlyText(vis)) {
307
320
  const prev = ozPreviousMessageCard(host);
308
- if (prev && prev !== host) {
321
+ if (prev && prev !== host && !ozInComposer(prev)) {
309
322
  ozAttachSpendChip(prev, split);
310
323
  host.setAttribute('data-oz-spend-hide', '1');
311
324
  continue;
@@ -318,18 +331,46 @@ function ozCollapseSpend() {
318
331
  ozEnsureFloatSpend();
319
332
  }
320
333
 
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
+
321
361
  function ozEnsureFloatSpend() {
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) {
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) {
326
367
  if (el) el.remove();
327
368
  return;
328
369
  }
329
370
  ozEnsureSpendCss();
330
371
  if (!el) {
331
372
  el = document.createElement('details');
332
- el.id = 'oz-spend-float';
373
+ el.id = 'oz-spend-hud';
333
374
  el.className = 'oz-spend';
334
375
  const sum = document.createElement('summary');
335
376
  const body = document.createElement('div');
@@ -357,7 +398,7 @@ function ozPlaceFloat(el) {
357
398
  if (!el) return;
358
399
  el.style.position = 'fixed';
359
400
  el.style.zIndex = '2147483646';
360
- el.style.opacity = '0.95';
401
+ el.style.opacity = '1';
361
402
  const saved = ozSavedFloatPos();
362
403
  if (saved) {
363
404
  el.style.left = Math.max(8, saved.left) + 'px';
@@ -416,29 +457,36 @@ function ozDragFloat(el) {
416
457
 
417
458
  function ozWatchSpend() {
418
459
  let t = 0;
460
+ const inComposer = () => ozInComposer(document.activeElement);
419
461
  const run = () => {
420
- const a = document.activeElement;
421
- if (a && (a.tagName === 'TEXTAREA' || a.tagName === 'INPUT' || a.isContentEditable)) return;
422
- try { ozCollapseSpend(); } catch (e) {}
462
+ try {
463
+ if (inComposer()) {
464
+ ozEnsureSpendCss();
465
+ ozEnsureFloatSpend();
466
+ } else {
467
+ ozCollapseSpend();
468
+ }
469
+ } catch (e) {}
423
470
  };
424
- const debounced = () => { clearTimeout(t); t = setTimeout(run, 600); };
471
+ const debounced = () => { clearTimeout(t); t = setTimeout(run, 400); };
425
472
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
426
473
  else run();
427
474
  try {
428
475
  const mo = new MutationObserver(debounced);
429
- const start = () => { if (document.body) mo.observe(document.body, { childList: true, subtree: false }); };
476
+ const start = () => { if (document.body) mo.observe(document.body, { childList: true, subtree: true }); };
430
477
  if (document.body) start();
431
478
  else document.addEventListener('DOMContentLoaded', start);
432
479
  } catch (e) {}
433
480
  try { window.addEventListener('resize', debounced); } catch (e) {}
481
+ try { setInterval(run, 2000); } catch (e) {}
434
482
  }
435
483
 
436
484
  export function spendChipSource() {
437
485
  return [
438
486
  '(function ozSpendChip(){',
439
487
  "'use strict';",
440
- 'if (window.__OZ_SPEND_CHIP__ === 12) return;',
441
- 'window.__OZ_SPEND_CHIP__ = 12;',
488
+ 'if (window.__OZ_SPEND_CHIP__ === 13) return;',
489
+ 'window.__OZ_SPEND_CHIP__ = 13;',
442
490
  chipUsd.toString(),
443
491
  labelFromSpendBody.toString(),
444
492
  spendLinesOnly.toString(),
@@ -448,11 +496,13 @@ export function spendChipSource() {
448
496
  ozSpendHost.toString(),
449
497
  ozVisibleSpendText.toString(),
450
498
  stripSpendFromText.toString(),
499
+ ozInComposer.toString(),
451
500
  ozBlankSpendIn.toString(),
452
501
  ozHideSpendLeftovers.toString(),
453
502
  ozPreviousMessageCard.toString(),
454
503
  ozAttachSpendChip.toString(),
455
504
  ozCollapseSpend.toString(),
505
+ ozFloatLabel.toString(),
456
506
  ozEnsureFloatSpend.toString(),
457
507
  ozSavedFloatPos.toString(),
458
508
  ozPlaceFloat.toString(),
package/lib/proxy.js CHANGED
@@ -38,19 +38,89 @@ 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. */
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. */
42
45
  export function killListen(port, run = execSync) {
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 */ }
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;
49
112
  }
50
- return pids;
51
- } catch {
52
- return [];
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 */ }
53
122
  }
123
+ return false;
54
124
  }
55
125
 
56
126
  // THE SHIM IS A FACILITATOR, NOT A MIDDLEBOX.
@@ -904,11 +974,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
904
974
  // OPENZOO_BIND=0.0.0.0 AND a tunnel token, so that port stays gated exactly
905
975
  // like the public tunnel path.
906
976
  const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
907
- // SELF-HEAL A TAKEN PORT. Walk up to the next free port instead of dying;
908
- // the caller reads config.port back out, so every URL printed afterwards is
909
- // the one we actually bound. A healthy proxy already on the port is KILLED
910
- // reusing it left a stale PayClient serving $0 after a TOKEN top-up.
911
- const wanted = config.port;
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
+ }
912
989
  for (let attempt = 0; ; attempt++) {
913
990
  try {
914
991
  await new Promise((resolve, reject) => {
@@ -918,20 +995,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
918
995
  });
919
996
  break;
920
997
  } catch (e) {
921
- if (e?.code !== 'EADDRINUSE' || attempt >= 12) throw e;
922
- if (attempt === 0) {
923
- const pids = killListen(config.port);
924
- if (pids.length) {
925
- say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
926
- await new Promise((r) => setTimeout(r, 400));
927
- continue;
928
- }
929
- }
930
- config.port += 1;
931
- say(`openzoo: :${config.port - 1} busy — trying :${config.port}`);
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));
932
1002
  }
933
1003
  }
934
- if (config.port !== wanted) say(`openzoo: listening on :${config.port} (:${wanted} was busy)`);
935
1004
 
936
1005
  // AUTO-PREPAY. Paying on-chain per call is where the latency lives: credit
937
1006
  // 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 ? 'ALREADY SERVING (an older proxy may still be running — kill it if this build is newer)' : 'free'}`);
217
+ console.log(` port ${config.port} : ${portBusy ? 'occupied (steal unless this exact version)' : '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,9 +245,17 @@ 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
- if (!(await proxyUp(base))) {
249
- console.log(`starting proxy on ${base} (+ public tunnel)...`);
250
- const { startProxy } = await import('./proxy.js');
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)...`);
251
259
  started = await startProxy({ silent: true, autoTunnel: true });
252
260
  publicUrl = started?.publicUrl ?? null;
253
261
  tunnelKey = started?.tunnelToken ?? null;
@@ -298,15 +306,6 @@ export async function setupEditor(which, target) {
298
306
  console.log(' most common cause here: no working IPv6 route (we already force');
299
307
  console.log(' --edge-ip-version 4). check: npx openzoo tunnel for the raw log.');
300
308
  }
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 */ }
310
309
  }
311
310
  // What the EDITOR is configured with. Localhost only as a last resort, and
312
311
  // said out loud, because it will fail with the private-networks error.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.91",
4
- "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 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.",
3
+ "version": "0.50.93",
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",
7
7
  "bin": {
package/lib/websearch.js DELETED
@@ -1,31 +0,0 @@
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
- }