openzoo 0.50.94 → 0.50.96

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++) {
@@ -44,7 +44,8 @@ import {
44
44
  readWakeups, writeWakeups, shapeWakeup, parseWakeupEvery, wantsWakeupCron,
45
45
  DEFAULT_WAKEUP_PROMPT, addDeletedIds, filterDeleted,
46
46
  } from './grokbotAccount.js';
47
- import { formatSpendFooter, mergeTurnProof } from './spendProof.js';
47
+ import { spendChipLabel, formatSpendFooter, mergeTurnProof } from './spendProof.js';
48
+ import { spendLinesOnly, writeSpendHud } from './ozSpendChip.js';
48
49
  import { zooModelIds, zooModelRow, mediaKindOf } from './models.js';
49
50
  import { prefixVisitorRichText } from './grokbotweb.js';
50
51
  import {
@@ -1880,7 +1881,15 @@ async function zooSpendOverlay(data) {
1880
1881
  const bal = Number.isFinite(wallet) && wallet > 0.004
1881
1882
  ? wallet
1882
1883
  : (Number.isFinite(credit) && credit > 0.004 ? credit : null);
1883
- return formatSpendFooter({
1884
+ const label = spent > 0.00005 ? spendChipLabel({
1885
+ billedUsd: x.billedUsd,
1886
+ directUsd: x.directUsd,
1887
+ spent,
1888
+ would,
1889
+ saved,
1890
+ pct,
1891
+ }) : '';
1892
+ const footer = formatSpendFooter({
1884
1893
  billedUsd: x.billedUsd,
1885
1894
  directUsd: x.directUsd,
1886
1895
  spent,
@@ -1890,6 +1899,12 @@ async function zooSpendOverlay(data) {
1890
1899
  balance: bal,
1891
1900
  x402: x,
1892
1901
  });
1902
+ const body = spendLinesOnly(footer) || (spent > 0.00005
1903
+ ? `spent $${spent.toFixed(4)} · OpenRouter would $${would.toFixed(4)} · saved $${saved.toFixed(4)} (${pct.toFixed(0)}%)`
1904
+ : '');
1905
+ const state = { spent, would, saved, pct, label, billedUsd: x.billedUsd, directUsd: x.directUsd, balance: bal, body };
1906
+ try { writeSpendHud(state); } catch { /* */ }
1907
+ return state;
1893
1908
  }
1894
1909
 
1895
1910
  const MODELS_PATH = path.join(os.homedir(), '.openzoo', 'grokbot-models.json');
@@ -2941,7 +2956,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2941
2956
  'This is a public visitor chat. You do not have filesystem, shell, or local-exec access on the host Mac.',
2942
2957
  'Reply in chat only. Do not claim you will write files, run commands, or use local tools.',
2943
2958
  'Each human line is prefixed with that visitor\'s shortname and a colon, like "rex: hello". Different shortnames are different people. Address them by that name.',
2944
- 'A spend footer is appended after your reply by the host -- ignore it.',
2959
+ 'Do not write spend totals, solscan links, memos, or ::oz-spend:: into the reply. The host paints a HUD pill.',
2945
2960
  'Prior turns of THIS Grok Bot chat are in the messages below. Do not claim the thread starts blank or that earlier questions did not arrive.',
2946
2961
  ].join(' ')
2947
2962
  : [
@@ -2984,8 +2999,8 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
2984
2999
  'Never claim you lack filesystem access or local-exec. If a tool errors, report the error.',
2985
3000
  'After using tools you MUST still write a normal chat reply: what you did, file paths written, and what to open. Empty content is a bug. A new user message cancels this turn — leave a visible reply before that happens.',
2986
3001
  'Do not stop mid-task. Do not write "Stopped on research", "nothing to open yet", "no app files written this turn", or "say go again". Keep using tools until the files the user asked for exist on disk THIS turn. Summarize only after those writes succeed.',
2987
- 'A spend footer is appended after your reply by the host ignore it.',
2988
- 'The operator wallet pays every zoo call via x402 (TOKEN/USDC on Solana; SOL gas is sponsored). If this thread has a spend footer or solscan/basescan tx, the call WAS paid. Do not say you have no money, cannot afford tools, or need the human to fund you. A 402/underfunded tool error is a host retry — continue the job.',
3002
+ 'Do not write spend totals, solscan links, memos, or ::oz-spend:: into the reply. The host paints a HUD pill.',
3003
+ 'The operator wallet pays every zoo call via x402 (TOKEN/USDC on Solana; SOL gas is sponsored). If the spend HUD is visible or a prior turn paid, the call WAS paid. Do not say you have no money, cannot afford tools, or need the human to fund you. A 402/underfunded tool error is a host retry — continue the job.',
2989
3004
  'Prior turns of THIS Grok Bot chat are in the messages below. Do not claim the thread starts blank or that earlier questions did not arrive.',
2990
3005
  ].join(' '),
2991
3006
  },
@@ -3075,17 +3090,22 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
3075
3090
  if (r.status === 402) {
3076
3091
  const raw = zooTextFromMessage(data?.choices?.[0]?.message, data)
3077
3092
  || data?.error?.message
3078
- || 'openzoo wallet underfunded.';
3093
+ || 'openzoo payment required (HTTP 402).';
3094
+ data.error = data.error || {};
3095
+ data.error.message = raw;
3079
3096
  try {
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
- });
3097
+ const { withOnrampLink, isFundInstruction } = await import('./stripeOnramp.js');
3098
+ // Settle/upstream 402s keep the real message. Whop copy-paste only
3099
+ // when this is a genuine empty-wallet / fund-me instruction.
3100
+ if (isFundInstruction(raw)) {
3101
+ const { loadOrCreateWallet } = await import('./wallet.js');
3102
+ const w = loadOrCreateWallet();
3103
+ const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
3104
+ data.error.message = await withOnrampLink(raw, {
3105
+ solana: w.keypair.publicKey.toBase58(),
3106
+ usd,
3107
+ });
3108
+ }
3089
3109
  } catch { /* keep proxy copy */ }
3090
3110
  }
3091
3111
  return { r, data };
@@ -3232,7 +3252,7 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
3232
3252
  if (lastData && typeof lastData === 'object') {
3233
3253
  lastData.x402 = mergeTurnProof(turnX402, lastData);
3234
3254
  }
3235
- try { text += await zooSpendOverlay(lastData); } catch { /* overlay must never eat the reply */ }
3255
+ try { await zooSpendOverlay(lastData); } catch { /* HUD dump; never append to the reply */ }
3236
3256
  return { text, data: lastData };
3237
3257
  }
3238
3258
 
@@ -3297,6 +3317,12 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3297
3317
  // Revive: the minutely cron (scripts/revive-bots.sh) calls this after it has
3298
3318
  // made sure the hijack + app are alive. Every non-group bot gets a wakeup
3299
3319
  // timer if it lost one; nothing is spawned, nothing already armed is touched.
3320
+ if (name === 'ozSpend') {
3321
+ let state = { spent: 0, would: 0, saved: 0, pct: 0, label: '' };
3322
+ try { state = await zooSpendOverlay(null); } catch { /* */ }
3323
+ jsonSend(res, { ok: true, ...state });
3324
+ return true;
3325
+ }
3300
3326
  if (name === 'ozRevive') {
3301
3327
  let parsed = {};
3302
3328
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
@@ -3603,7 +3629,6 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
3603
3629
  text = `model set to ${id}`;
3604
3630
  }
3605
3631
  }
3606
- try { text += await zooSpendOverlay({}); } catch { /* */ }
3607
3632
  } else {
3608
3633
  const members = groupMemberIds(agentId);
3609
3634
  if (members.length) {
package/lib/grokbotweb.js CHANGED
@@ -18,7 +18,7 @@ import { exec } from 'node:child_process';
18
18
  import { config } from './config.js';
19
19
  import { readHouseRoster } from './grokbotAccount.js';
20
20
  import { ingestUpload, lookupUpload } from './grokbotUploads.js';
21
- import { spendChipSource } from './ozSpendChip.js';
21
+ import { spendChipSource, sessionSpendState } from './ozSpendChip.js';
22
22
 
23
23
  const HERE = path.dirname(fileURLToPath(import.meta.url));
24
24
  const SHIM_PATH = path.join(HERE, 'grokbotweb-shim.js');
@@ -763,6 +763,15 @@ export async function startGrokBotWeb(opts = {}) {
763
763
 
764
764
  const server = http.createServer((req, res) => {
765
765
  const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
766
+ if (urlPath === '/oz-spend') {
767
+ writeHead(res, 200, {
768
+ 'content-type': 'application/json',
769
+ 'cache-control': 'no-store',
770
+ 'access-control-allow-origin': '*',
771
+ }, req);
772
+ res.end(JSON.stringify({ ok: true, ...sessionSpendState() }));
773
+ return;
774
+ }
766
775
  if (urlPath === '/oz-health') {
767
776
  writeHead(res, 200, { 'content-type': 'application/json' }, req);
768
777
  res.end(JSON.stringify({
package/lib/launch.js CHANGED
@@ -190,18 +190,21 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
190
190
  * DEFAULT is the desktop app; `--terminal` (or `-t`) runs the Claude Code CLI.
191
191
  * Both get ANTHROPIC_BASE_URL so inference pays x402.
192
192
  */
193
+
194
+
193
195
  export async function launchClaude(argv) {
194
- // let, not const: startProxy can heal onto a different port and every URL
195
- // below must follow the port we actually bound.
196
+ // NEVER hop. :8402 is the product. startProxy steals a stale listener.
196
197
  let base = `http://localhost:${config.port}/v1`;
197
- // AUTO-START THE PROXY. One command should just work — if nothing is listening,
198
- // boot the proxy in THIS process (it stays alive because claude runs in the
199
- // 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.
200
200
  let up = false;
201
- // /info, not /models see the poll below. "Is a proxy already listening" must
202
- // not be answered by an endpoint that needs the gateway, or a user whose
203
- // upstream is flaky gets told to start a proxy that is already running.
204
- try { up = (await fetch(`${base}/info`, { signal: AbortSignal.timeout(3000) })).ok; } 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
+ }
205
208
  if (!up) {
206
209
  // NEVER GO SILENT DURING STARTUP. silent:true routes the proxy's own lines
207
210
  // to ~/.openzoo/proxy.log so payment receipts cannot corrupt Claude Code's
@@ -218,7 +221,6 @@ export async function launchClaude(argv) {
218
221
  tick.unref?.();
219
222
  const done = (msg) => { clearInterval(tick); process.stderr.write(`\r\x1b[2Kopenzoo: ${msg}\n`); };
220
223
  try {
221
- const { startProxy } = await import('./proxy.js');
222
224
  await startProxy({ silent: true, autoTunnel: true });
223
225
  } catch (err) {
224
226
  // An exception here used to surface as an eternal spinner. Say what broke.
@@ -227,9 +229,6 @@ export async function launchClaude(argv) {
227
229
  console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
228
230
  process.exit(1);
229
231
  }
230
- // The proxy may have healed onto a different port (8402 busy). config.port
231
- // is the one it ACTUALLY bound, so re-derive every URL from it — the old
232
- // code kept polling the port it wished for and timed out on a live proxy.
233
232
  base = `http://localhost:${config.port}/v1`;
234
233
  // PROBE /v1/info, NOT /v1/models. `models` is PROXIED UPSTREAM, so on a
235
234
  // network with a bad path to the gateway the local proxy is listening and
@@ -580,17 +579,12 @@ export async function launchClaude(argv) {
580
579
  }
581
580
 
582
581
  export async function launchHarness(cmd, args) {
583
- const base = `http://localhost:${config.port}/v1`;
584
- // Fail early with a clear message rather than letting the harness spew
585
- // connection errors — the #1 support question would otherwise be "why won't
586
- // claude connect" when the answer is "the proxy isn't up".
587
- try {
588
- const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(4000) });
589
- if (!r.ok) throw new Error(String(r.status));
590
- } catch {
591
- console.error(`openzoo: no proxy reachable at ${base}`);
592
- console.error('start it first in another terminal: npx openzoo');
593
- 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`;
594
588
  }
595
589
 
596
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
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Cafe spend chip the $N <details> that folds formatSpendFooter.
2
+ * Cafe/Grok Bot spend HUD. Live totals come from /oz-spend and /api/ozSpend;
3
+ * leftover ::oz-spend:: footers in old transcripts still fold into a chip.
3
4
  *
4
5
  * Cafe injects this via grokbotweb-shim concatenation. Grok Bot.app cannot
5
6
  * be patched (asar integrity), so `openzoo bot` launches Chromium with a
@@ -14,18 +15,82 @@ import { spendChipLabel } from './spendProof.js';
14
15
 
15
16
  export const GROKBOT_CDP_PORT = Number(process.env.OZ_GROKBOT_CDP_PORT || 9444);
16
17
 
17
- /** Session totals for a floating pill when the open canvas has no footer. */
18
- export function sessionSpendLabel(home = os.homedir()) {
18
+ export function spendHudPath(home = os.homedir()) {
19
+ return path.join(home, '.openzoo', 'spend-hud.json');
20
+ }
21
+
22
+ export function writeSpendHud(state, home = os.homedir()) {
23
+ try {
24
+ const spent = Number(state?.spent) || 0;
25
+ const would = Number(state?.would) || 0;
26
+ const saved = Number(state?.saved) || 0;
27
+ const pct = Number(state?.pct) || 0;
28
+ const payload = {
29
+ spent,
30
+ would,
31
+ saved,
32
+ pct,
33
+ label: String(state?.label || ''),
34
+ body: String(state?.body || ''),
35
+ billedUsd: state?.billedUsd ?? null,
36
+ updatedAt: Date.now(),
37
+ };
38
+ fs.mkdirSync(path.dirname(spendHudPath(home)), { recursive: true, mode: 0o700 });
39
+ fs.writeFileSync(spendHudPath(home), JSON.stringify(payload), { mode: 0o600 });
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ export function readSpendHud(home = os.homedir()) {
47
+ try {
48
+ const j = JSON.parse(fs.readFileSync(spendHudPath(home), 'utf8'));
49
+ return j && typeof j === 'object' ? j : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function sessionTotals(home) {
19
56
  try {
20
57
  const s = JSON.parse(fs.readFileSync(path.join(home, '.openzoo', 'session.json'), 'utf8'));
21
58
  const spent = Number(s.spentUsd || s.spendUsd || 0);
22
59
  const would = Number(s.directUsd || 0);
23
60
  const saved = Number(s.savedUsd != null ? s.savedUsd : Math.max(0, would - spent));
24
- if (!(spent > 0.00005)) return '';
25
- return spendChipLabel({ spent, would, saved, pct: would > 0 ? (100 * saved) / would : 0 });
61
+ const pct = would > 0 ? (100 * saved) / would : 0;
62
+ const label = spent > 0.00005 ? spendChipLabel({ spent, would, saved, pct }) : '';
63
+ return { spent, would, saved, pct, label };
26
64
  } catch {
27
- return '';
65
+ return { spent: 0, would: 0, saved: 0, pct: 0, label: '' };
66
+ }
67
+ }
68
+
69
+ function sessionBody(sess) {
70
+ if (!(sess.spent > 0.00005)) return '';
71
+ return `spent $${sess.spent.toFixed(4)} · OpenRouter would $${sess.would.toFixed(4)} · saved $${sess.saved.toFixed(4)} (${Math.round(sess.pct)}%)`;
72
+ }
73
+
74
+ /** Session totals + last-call dump for the HUD pill. */
75
+ export function sessionSpendState(home = os.homedir()) {
76
+ const sess = sessionTotals(home);
77
+ const hud = readSpendHud(home);
78
+ if (hud && (hud.label || hud.body)) {
79
+ return {
80
+ spent: Number(hud.spent) || sess.spent,
81
+ would: Number(hud.would) || sess.would,
82
+ saved: Number(hud.saved) || sess.saved,
83
+ pct: Number(hud.pct) || sess.pct,
84
+ label: String(hud.label || sess.label || ''),
85
+ body: String(hud.body || sessionBody(sess)),
86
+ billedUsd: hud.billedUsd ?? null,
87
+ };
28
88
  }
89
+ return { ...sess, body: sessionBody(sess), billedUsd: null };
90
+ }
91
+
92
+ export function sessionSpendLabel(home = os.homedir()) {
93
+ return sessionSpendState(home).label;
29
94
  }
30
95
 
31
96
  export function grokBotChromiumArgs(port = GROKBOT_CDP_PORT) {
@@ -159,9 +224,13 @@ function ozEnsureSpendCss() {
159
224
  }
160
225
  s.textContent = [
161
226
  '.oz-spend{margin:.55rem 0 0;font-size:12px;color:inherit;opacity:.82;max-width:36em}',
162
- '#oz-spend-float>summary{cursor:grab}',
227
+ '#oz-spend-hud{opacity:1;max-width:min(28em,calc(100vw - 24px));pointer-events:auto}',
228
+ '#oz-spend-hud>summary{cursor:grab;background:#e85d1c;color:#fff;font-weight:600;font-size:13px;',
229
+ 'border:0;box-shadow:0 4px 18px rgba(0,0,0,.5)}',
230
+ '#oz-spend-hud>.oz-spend-body{background:rgba(20,20,22,.94);color:#f4f4f5;padding:8px 10px;',
231
+ 'border-radius:10px;margin-top:6px}',
163
232
  '.oz-spend>summary{cursor:help;list-style:none;display:inline-flex;align-items:center;gap:.35rem;',
164
- 'padding:3px 9px;border-radius:999px;border:1px solid rgba(255,255,255,.18);white-space:nowrap;',
233
+ 'padding:6px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.18);white-space:nowrap;',
165
234
  'max-width:100%;overflow:hidden;text-overflow:ellipsis}',
166
235
  '.oz-spend>summary::-webkit-details-marker{display:none}',
167
236
  '.oz-spend-body{white-space:pre-wrap;margin:.55rem 0 0;font-size:11px;line-height:1.45;opacity:.88;overflow-wrap:anywhere}',
@@ -213,11 +282,19 @@ export function stripSpendFromText(s) {
213
282
  return t;
214
283
  }
215
284
 
285
+ function ozInComposer(el) {
286
+ if (!el) return false;
287
+ if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' || el.isContentEditable) return true;
288
+ return !!(el.closest && el.closest('textarea, input, [contenteditable="true"]'));
289
+ }
290
+
216
291
  function ozBlankSpendIn(root) {
292
+ if (ozInComposer(root)) return;
217
293
  const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
218
294
  while (w.nextNode()) {
219
295
  const tn = w.currentNode;
220
296
  if (tn.parentElement && tn.parentElement.closest && tn.parentElement.closest('.oz-spend')) continue;
297
+ if (ozInComposer(tn.parentElement)) continue;
221
298
  const next = stripSpendFromText(tn.nodeValue || '');
222
299
  if (next !== tn.nodeValue) tn.nodeValue = next;
223
300
  }
@@ -228,6 +305,7 @@ function ozHideSpendLeftovers() {
228
305
  for (let i = 0; i < nodes.length; i += 1) {
229
306
  const el = nodes[i];
230
307
  if (el.closest && el.closest('.oz-spend')) continue;
308
+ if (ozInComposer(el)) continue;
231
309
  if (el.querySelector && el.querySelector('.oz-spend')) continue;
232
310
  const vis = ozVisibleSpendText(el);
233
311
  if (!spendOnlyText(vis)) continue;
@@ -290,10 +368,11 @@ function ozCollapseSpend() {
290
368
  for (let i = 0; i < nodes.length; i += 1) {
291
369
  const el = nodes[i];
292
370
  if (el.closest && el.closest('.oz-spend')) continue;
371
+ if (ozInComposer(el)) continue;
293
372
  const t = ozVisibleSpendText(el);
294
373
  if (!/::oz-spend::|this call \$|spent \$/i.test(t)) continue;
295
374
  const host = ozSpendHost(el);
296
- if (!host || seen.has(host)) continue;
375
+ if (!host || seen.has(host) || ozInComposer(host)) continue;
297
376
  seen.add(host);
298
377
  hosts.push(host);
299
378
  }
@@ -302,10 +381,11 @@ function ozCollapseSpend() {
302
381
  const vis = ozVisibleSpendText(host);
303
382
  const split = splitSpendText(vis);
304
383
  if (!split || !split.body || split.body.length < 12) continue;
384
+ if (split.summary) window.__OZ_SPEND_LAST__ = split.summary;
305
385
  ozEnsureSpendCss();
306
386
  if (spendOnlyText(vis)) {
307
387
  const prev = ozPreviousMessageCard(host);
308
- if (prev && prev !== host) {
388
+ if (prev && prev !== host && !ozInComposer(prev)) {
309
389
  ozAttachSpendChip(prev, split);
310
390
  host.setAttribute('data-oz-spend-hide', '1');
311
391
  continue;
@@ -318,19 +398,44 @@ function ozCollapseSpend() {
318
398
  ozEnsureFloatSpend();
319
399
  }
320
400
 
321
- 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) {
326
- if (el) el.remove();
327
- return;
401
+ /** Live HUD label: latest folded chip, then a read-only canvas scan, then inject snapshot. */
402
+ function ozFloatLabel() {
403
+ const pills = document.querySelectorAll('.oz-spend:not(#oz-spend-hud):not(#oz-spend-float) summary');
404
+ if (pills.length) {
405
+ const lab = String(pills[pills.length - 1].textContent || '').replace(/^ⓘ\s*/, '').trim();
406
+ if (lab) {
407
+ window.__OZ_SPEND_LAST__ = lab;
408
+ return lab;
409
+ }
328
410
  }
411
+ try {
412
+ const nodes = document.querySelectorAll('[class*="sand-message"]');
413
+ for (let i = nodes.length - 1; i >= 0; i -= 1) {
414
+ if (ozInComposer(nodes[i])) continue;
415
+ const vis = ozVisibleSpendText(nodes[i]);
416
+ if (!/::oz-spend::|this call \$|spent \$/i.test(vis)) continue;
417
+ const split = splitSpendText(vis);
418
+ if (split && split.summary) {
419
+ window.__OZ_SPEND_LAST__ = split.summary;
420
+ return split.summary;
421
+ }
422
+ }
423
+ } catch (e) {}
424
+ if (window.__OZ_SPEND_LAST__) return String(window.__OZ_SPEND_LAST__);
425
+ return String(window.__OZ_SESSION_SPEND__ || '').trim();
426
+ }
427
+
428
+ function ozEnsureFloatSpend() {
429
+ const leftover = document.getElementById('oz-spend-float');
430
+ if (leftover) leftover.remove();
431
+ const label = ozFloatLabel() || String(window.__OZ_SESSION_SPEND__ || '').trim() || 'openzoo';
432
+ let el = document.getElementById('oz-spend-hud');
329
433
  ozEnsureSpendCss();
330
434
  if (!el) {
331
435
  el = document.createElement('details');
332
- el.id = 'oz-spend-float';
436
+ el.id = 'oz-spend-hud';
333
437
  el.className = 'oz-spend';
438
+ el.open = true;
334
439
  const sum = document.createElement('summary');
335
440
  const body = document.createElement('div');
336
441
  body.className = 'oz-spend-body';
@@ -343,6 +448,9 @@ function ozEnsureFloatSpend() {
343
448
  ozDragFloat(el);
344
449
  const sum = el.querySelector('summary');
345
450
  if (sum) sum.textContent = 'ⓘ ' + label;
451
+ const bodyEl = el.querySelector('.oz-spend-body');
452
+ const dump = String(window.__OZ_SPEND_BODY__ || '').trim();
453
+ if (bodyEl && dump) bodyEl.textContent = dump;
346
454
  }
347
455
 
348
456
  function ozSavedFloatPos() {
@@ -357,9 +465,15 @@ function ozPlaceFloat(el) {
357
465
  if (!el) return;
358
466
  el.style.position = 'fixed';
359
467
  el.style.zIndex = '2147483646';
360
- el.style.opacity = '0.95';
468
+ el.style.opacity = '1';
469
+ el.style.pointerEvents = 'auto';
470
+ const vw = Math.max(320, Number(window.innerWidth) || 800);
471
+ const vh = Math.max(240, Number(window.innerHeight) || 600);
361
472
  const saved = ozSavedFloatPos();
362
- if (saved) {
473
+ const onScreen = saved
474
+ && saved.left >= 0 && saved.top >= 0
475
+ && saved.left < vw - 24 && saved.top < vh - 24;
476
+ if (onScreen) {
363
477
  el.style.left = Math.max(8, saved.left) + 'px';
364
478
  el.style.top = Math.max(8, saved.top) + 'px';
365
479
  el.style.right = 'auto';
@@ -368,8 +482,8 @@ function ozPlaceFloat(el) {
368
482
  }
369
483
  el.style.left = 'auto';
370
484
  el.style.right = '16px';
371
- el.style.top = '48px';
372
- el.style.bottom = 'auto';
485
+ el.style.top = 'auto';
486
+ el.style.bottom = '88px';
373
487
  }
374
488
 
375
489
  function ozDragFloat(el) {
@@ -414,31 +528,71 @@ function ozDragFloat(el) {
414
528
  }, true);
415
529
  }
416
530
 
531
+ function ozLabelFromInfo(j) {
532
+ if (!j || typeof j !== 'object') return '';
533
+ if (typeof j.label === 'string' && j.label.trim()) return j.label.trim();
534
+ const spent = Number(j.spentUsd ?? j.spendUsd ?? j.spent ?? 0);
535
+ const would = Number(j.directUsd ?? j.would ?? 0);
536
+ const saved = Number(j.savedUsd != null ? j.savedUsd : (j.saved != null ? j.saved : Math.max(0, would - spent)));
537
+ if (!(spent > 0.00005)) return '';
538
+ return labelFromSpendBody(
539
+ 'spent $' + spent.toFixed(4) + ' OpenRouter would $' + would.toFixed(4) + ' saved $' + saved.toFixed(4),
540
+ '',
541
+ );
542
+ }
543
+
544
+ function ozPollSpend() {
545
+ const urls = ['/oz-spend', '/api/ozSpend', 'https://127.0.0.1:8443/api/ozSpend', 'http://127.0.0.1:8402/v1/info'];
546
+ (async () => {
547
+ for (let i = 0; i < urls.length; i += 1) {
548
+ try {
549
+ const r = await fetch(urls[i], { signal: AbortSignal.timeout(1500) });
550
+ if (!r || !r.ok) continue;
551
+ const j = await r.json();
552
+ const lab = ozLabelFromInfo(j);
553
+ if (!lab) continue;
554
+ window.__OZ_SESSION_SPEND__ = lab;
555
+ if (typeof j.body === 'string' && j.body.trim()) window.__OZ_SPEND_BODY__ = j.body.trim();
556
+ ozEnsureFloatSpend();
557
+ return;
558
+ } catch (e) {}
559
+ }
560
+ })();
561
+ }
562
+
417
563
  function ozWatchSpend() {
418
564
  let t = 0;
565
+ const inComposer = () => ozInComposer(document.activeElement);
419
566
  const run = () => {
420
- const a = document.activeElement;
421
- if (a && (a.tagName === 'TEXTAREA' || a.tagName === 'INPUT' || a.isContentEditable)) return;
422
- try { ozCollapseSpend(); } catch (e) {}
567
+ try {
568
+ if (inComposer()) {
569
+ ozEnsureSpendCss();
570
+ ozEnsureFloatSpend();
571
+ } else {
572
+ ozCollapseSpend();
573
+ }
574
+ } catch (e) {}
423
575
  };
424
- const debounced = () => { clearTimeout(t); t = setTimeout(run, 600); };
576
+ const debounced = () => { clearTimeout(t); t = setTimeout(run, 400); };
425
577
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
426
578
  else run();
427
579
  try {
428
580
  const mo = new MutationObserver(debounced);
429
- const start = () => { if (document.body) mo.observe(document.body, { childList: true, subtree: false }); };
581
+ const start = () => { if (document.body) mo.observe(document.body, { childList: true, subtree: true }); };
430
582
  if (document.body) start();
431
583
  else document.addEventListener('DOMContentLoaded', start);
432
584
  } catch (e) {}
433
585
  try { window.addEventListener('resize', debounced); } catch (e) {}
586
+ try { setInterval(() => { run(); ozPollSpend(); }, 2000); } catch (e) {}
587
+ try { ozPollSpend(); } catch (e) {}
434
588
  }
435
589
 
436
590
  export function spendChipSource() {
437
591
  return [
438
592
  '(function ozSpendChip(){',
439
593
  "'use strict';",
440
- 'if (window.__OZ_SPEND_CHIP__ === 12) return;',
441
- 'window.__OZ_SPEND_CHIP__ = 12;',
594
+ 'if (window.__OZ_SPEND_CHIP__ === 16) return;',
595
+ 'window.__OZ_SPEND_CHIP__ = 16;',
442
596
  chipUsd.toString(),
443
597
  labelFromSpendBody.toString(),
444
598
  spendLinesOnly.toString(),
@@ -448,15 +602,19 @@ export function spendChipSource() {
448
602
  ozSpendHost.toString(),
449
603
  ozVisibleSpendText.toString(),
450
604
  stripSpendFromText.toString(),
605
+ ozInComposer.toString(),
451
606
  ozBlankSpendIn.toString(),
452
607
  ozHideSpendLeftovers.toString(),
453
608
  ozPreviousMessageCard.toString(),
454
609
  ozAttachSpendChip.toString(),
455
610
  ozCollapseSpend.toString(),
611
+ ozFloatLabel.toString(),
456
612
  ozEnsureFloatSpend.toString(),
457
613
  ozSavedFloatPos.toString(),
458
614
  ozPlaceFloat.toString(),
459
615
  ozDragFloat.toString(),
616
+ ozLabelFromInfo.toString(),
617
+ ozPollSpend.toString(),
460
618
  ozWatchSpend.toString(),
461
619
  'ozWatchSpend();',
462
620
  '})();',
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 } from './stripeOnramp.js';
12
+ import { withOnrampLink, settleFailCopy, isFundInstruction } 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, unopenrouter } from './models.js';
16
+ import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows } from './models.js';
17
17
 
18
18
  /**
19
19
  * Quoteable catalog ids, cached 5 minutes, for the fuzzy /v1/models/<id> probe.
@@ -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);
49
52
  }
50
- return pids;
51
- } catch {
52
- return [];
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
+ }));
53
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;
112
+ }
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 */ }
122
+ }
123
+ return false;
54
124
  }
55
125
 
56
126
  // THE SHIM IS A FACILITATOR, NOT A MIDDLEBOX.
@@ -511,9 +581,17 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
511
581
  const self = viaTunnel && tunnelGate?.publicUrl
512
582
  ? `${tunnelGate.publicUrl}/v1`
513
583
  : `http://localhost:${config.port}/v1`;
514
- res.writeHead(200, { 'content-type': 'application/json' });
584
+ res.writeHead(200, {
585
+ 'content-type': 'application/json',
586
+ 'access-control-allow-origin': '*',
587
+ });
588
+ const { version: ozVersion } = JSON.parse(
589
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
590
+ );
515
591
  res.end(JSON.stringify({
516
592
  youAreTalkingTo: 'openzoo proxy',
593
+ version: ozVersion,
594
+ solana: client.address,
517
595
  yourEndpoint: self,
518
596
  reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
519
597
  publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
@@ -618,43 +696,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
618
696
  const isPaidPost = req.method === 'POST'
619
697
  && /\/(chat\/completions|completions|messages|responses)$/.test(rawPath);
620
698
  let wantsStream = false;
621
- // RECEIPTS ARE OPT-OUT. `disableStats: true` in the body (or the
622
- // x-openzoo-disable-stats header) means this caller does not want the x402
623
- // block, so we neither attach our settle proof nor pass the gateway's
624
- // receipt through. The field is deliberately FORWARDED, not stripped: the
625
- // gateway reads it too and drops its own half. Absent/false keeps today's
626
- // behaviour, because our own spend line reads that block.
627
- let statsOff = false;
628
- if (isPaidPost) {
629
- try {
630
- const b = JSON.parse(bodyBuf.toString('utf8'));
631
- const h = req.headers['x-openzoo-disable-stats'];
632
- const truthy = (v) => v === true || v === 'true' || v === '1' || v === 1;
633
- statsOff = truthy(b?.disableStats) || truthy(Array.isArray(h) ? h[0] : h);
634
- } catch { /* not JSON */ }
635
- }
636
699
  if (isPaidPost) {
637
700
  servedRequests += 1;
638
701
  say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
639
702
  try { wantsStream = JSON.parse(bodyBuf.toString('utf8'))?.stream === true; } catch { /* not JSON */ }
640
- // UNOPENROUTER THE MODEL ID before anything downstream sees the body —
641
- // the replay key, the outage gate, the wire. A vendor-prefixed
642
- // OpenRouter spelling becomes the bare id the doors serve when the
643
- // catalog lists it (OPENZOO_UNOPENROUTER=1 forces it, =0 disables).
644
- // This is the one place the body is rewritten; see models.js.
645
- try {
646
- const parsed = JSON.parse(bodyBuf.toString('utf8'));
647
- if (parsed && typeof parsed.model === 'string') {
648
- let ids = [];
649
- try { ids = await catalogIdsCached(`${config.apiBase}/v1/models`, upstreamHeaders(req)); } catch { /* catalog unreachable: only the forced mode rewrites */ }
650
- const bare = unopenrouter(parsed.model, ids);
651
- if (bare) {
652
- say(` model ${parsed.model} -> ${bare} (bare id: doors, not OpenRouter)`);
653
- parsed.model = bare;
654
- bodyBuf = Buffer.from(JSON.stringify(parsed));
655
- }
656
- }
657
- } catch { /* not JSON */ }
658
703
  }
659
704
 
660
705
  // Retry of a body we answered seconds ago? Serve the cached completion —
@@ -825,11 +870,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
825
870
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
826
871
  }
827
872
  if (data?.object === 'chat.completion') {
828
- if (statsOff) {
829
- // The gateway already dropped its half; drop ours, and any block an
830
- // older gateway in front of us still attached.
831
- delete data.x402;
832
- } else if (paid && receipt) {
873
+ if (paid && receipt) {
833
874
  attachX402Proof(data, {
834
875
  tx: receipt.tx,
835
876
  memo: receipt.memo || accept?.extra?.memo,
@@ -866,14 +907,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
866
907
  rememberSpend();
867
908
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
868
909
  };
869
- // A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote
870
- // the client-side balance check is advisory, so a wallet that looks
871
- // fundable can still fail on-chain, and the gateway answers the paid
872
- // retry with a fresh 402. Relaying that raw meant Claude Code printed a
873
- // half-kilobyte accepts[] blob at the user instead of the one thing they
874
- // need: the price, what the wallet holds, and where to send funds.
910
+ // A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote.
911
+ // The client-side balance check is advisory, so a funded wallet can still
912
+ // fail on-chain / at the facilitator, and the gateway answers the paid
913
+ // retry with a fresh 402. Relaying that as "wallet underfunded" + Whop
914
+ // copy-paste blamed burners that already paid. Surface the gateway's
915
+ // real reason; only prepend fund-me copy on genuine insufficient_funds.
875
916
  if (response.status === 402) {
876
- let quoted = '';
877
917
  let usd;
878
918
  let q402 = null;
879
919
  try { q402 = await response.clone().json(); } catch { q402 = null; }
@@ -890,18 +930,21 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
890
930
  return;
891
931
  }
892
932
  try {
893
- const q = q402;
894
- usd = Number(q?.accepts?.[0]?.extra?.billedUsd);
895
- if (Number.isFinite(usd)) quoted = ` This call needs ≈$${usd.toFixed(4)}.`;
896
- } catch { /* body was not the quote after all */ }
897
- const msg = await withOnrampLink(
898
- `openzoo wallet underfunded payment did not settle.${quoted} `
899
- + `Fund it and retry: send USDC (or TOKEN/LEOS for half price) to ${client.address} on Solana, `
900
- + `or USDC to ${client.evmAddress} on Base. Check with: openzoo balance`,
901
- { solana: client.address, usd },
902
- );
903
- log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : 'onramp: no fund blurb');
904
- jsonErr(res, 402, msg);
933
+ usd = Number(q402?.accepts?.[0]?.extra?.billedUsd);
934
+ if (!Number.isFinite(usd)) usd = undefined;
935
+ } catch { usd = undefined; }
936
+ const copy = settleFailCopy(q402);
937
+ // paid:true never "wallet underfunded", never Whop unless the
938
+ // gateway itself named insufficient_funds. UnderfundedError (empty
939
+ // wallet preflight) is handled in the catch below.
940
+ let msg = copy.message;
941
+ const wantOnramp = copy.code === 'insufficient_funds'
942
+ || (!paid && isFundInstruction(copy.reason, copy));
943
+ if (wantOnramp) {
944
+ msg = await withOnrampLink(msg, { solana: client.address, usd, code: copy.code });
945
+ }
946
+ log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : `402: ${msg.slice(0, 140)}`);
947
+ jsonErr(res, paid ? copy.status : 402, msg);
905
948
  return;
906
949
  }
907
950
  await relay(res, response, meterStreamed);
@@ -934,11 +977,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
934
977
  // OPENZOO_BIND=0.0.0.0 AND a tunnel token, so that port stays gated exactly
935
978
  // like the public tunnel path.
936
979
  const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
937
- // SELF-HEAL A TAKEN PORT. Walk up to the next free port instead of dying;
938
- // the caller reads config.port back out, so every URL printed afterwards is
939
- // the one we actually bound. A healthy proxy already on the port is KILLED
940
- // reusing it left a stale PayClient serving $0 after a TOKEN top-up.
941
- const wanted = config.port;
980
+ // SELF-HEAL :8402. Kill whoever is on THIS port, then bind it. Never hop to
981
+ // 8403 that was the second-burner. Every subcommand that calls startProxy
982
+ // (openzoo, claude, bot, web, cursor, aoe, grok, tunnel) goes through here.
983
+ // Reusing a leftover listener left a stale PayClient serving $0 after a
984
+ // TOKEN top-up, and an old npx cache answering /v1 with no version.
985
+ {
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
+ }
991
+ }
942
992
  for (let attempt = 0; ; attempt++) {
943
993
  try {
944
994
  await new Promise((resolve, reject) => {
@@ -948,20 +998,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
948
998
  });
949
999
  break;
950
1000
  } catch (e) {
951
- if (e?.code !== 'EADDRINUSE' || attempt >= 12) throw e;
952
- if (attempt === 0) {
953
- const pids = killListen(config.port);
954
- if (pids.length) {
955
- say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
956
- await new Promise((r) => setTimeout(r, 400));
957
- continue;
958
- }
959
- }
960
- config.port += 1;
961
- say(`openzoo: :${config.port - 1} busy — trying :${config.port}`);
1001
+ if (e?.code !== 'EADDRINUSE' || attempt >= 8) throw e;
1002
+ const pids = killListen(config.port);
1003
+ if (pids.length) say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
1004
+ await new Promise((r) => setTimeout(r, 300));
962
1005
  }
963
1006
  }
964
- if (config.port !== wanted) say(`openzoo: listening on :${config.port} (:${wanted} was busy)`);
965
1007
 
966
1008
  // AUTO-PREPAY. Paying on-chain per call is where the latency lives: credit
967
1009
  // 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.
@@ -103,8 +103,58 @@ 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
+
106
155
  export async function withOnrampLink(text, dest) {
107
156
  const body = String(text || '').trim();
157
+ if (!isFundInstruction(body, dest)) return body;
108
158
  const blurb = whopFundBlurb(dest?.solana);
109
159
  if (!blurb) return body;
110
160
  if (/ties to your account/i.test(body) && body.includes(String(dest.solana))) return body;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.94",
3
+ "version": "0.50.96",
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",
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
- }