@genex-ai/cli-demo 1.34.9 → 1.35.0-dev.736

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/dist/index.js CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  writeSecretFile,
43
43
  writeUserToken,
44
44
  writeWorkspace
45
- } from "./chunk-5WBSWMH5.js";
45
+ } from "./chunk-FYHYZYBC.js";
46
46
  import {
47
47
  CLI_CHANNEL,
48
48
  DEFAULT_API_URL,
@@ -63,7 +63,7 @@ import {
63
63
  normalizeApiOrigin,
64
64
  originKey,
65
65
  resolveAgentTargets
66
- } from "./chunk-QI7FIYBY.js";
66
+ } from "./chunk-K4EHXOZK.js";
67
67
 
68
68
  // src/instrument.ts
69
69
  import * as Sentry from "@sentry/node";
@@ -418,7 +418,7 @@ async function authorizeLoopback(authBaseUrl, options) {
418
418
  cleanup();
419
419
  resolve(token);
420
420
  };
421
- const fail3 = (err) => {
421
+ const fail4 = (err) => {
422
422
  if (settled) return;
423
423
  settled = true;
424
424
  cleanup();
@@ -435,7 +435,7 @@ async function authorizeLoopback(authBaseUrl, options) {
435
435
  }
436
436
  });
437
437
  });
438
- server.on("error", (err) => fail3(err));
438
+ server.on("error", (err) => fail4(err));
439
439
  server.listen(0, "127.0.0.1", () => {
440
440
  const { port } = server.address();
441
441
  const redirectUri = `http://127.0.0.1:${port}/callback`;
@@ -466,7 +466,7 @@ async function authorizeLoopback(authBaseUrl, options) {
466
466
  );
467
467
  }
468
468
  timer = setTimeout(() => {
469
- fail3(
469
+ fail4(
470
470
  new Error(
471
471
  "Timed out waiting for authorization. Re-run the command to try again."
472
472
  )
@@ -569,6 +569,7 @@ function openBrowser(url, onError = () => {
569
569
  }) {
570
570
  let command;
571
571
  let args;
572
+ if (process.env.GENEX_NO_BROWSER === "1") return false;
572
573
  const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
573
574
  const customParts = custom ? tokenizeCommand(custom) : [];
574
575
  if (customParts.length > 0) {
@@ -901,6 +902,7 @@ Important note: put soul into your creations, with many details and love. Aim to
901
902
  21. A turn ends in exactly one of two ways: on a question the player must answer before the next step can be chosen, or on a handoff \u2014 the draft link, what changed, what to try, and what is still open. Never close a turn on a promise. "I'll keep building", "while I keep working", "adding that now" are things you say and then DO before you stop; if you are stopping, say that you have stopped and what remains. While the Build plan's \`Now:\` line has open work and no answer is needed from the player, the turn is not over: preview, hand off, and start the next milestone in the same turn, until the requested outcome is reached or the platform's own budget ends the session. If a stop was forced on you anyway, the first line of your next turn is the promise you left and where it stands.
902
903
  22. **What the player sees must read as the thing it is**, in this game's own style: a building as that building, a person as a person, a prop as that prop, a surface as its material \u2014 and a coloured box, a capsule, or a flat grey block standing in for one of them is never its finished version. Generation is the default route to that bar wherever code will not honestly reach it: the player's character, whatever the request names, whatever the player walks up to, enters, or interacts with, and the music and sound underneath \u2014 reach for the belt on your own judgment, without stopping to ask; a status line saying what you queued is enough, and \`--no-wait\` keeps you building while it lands. Procedural code is a first-class engine for what is structural, repeated, distant, or parametric \u2014 terrain, sky, fences, paving, modular kits, filler \u2014 and for anything else only when the result meets the same bar and you have looked at it in a capture before its row says \`landed\`. Start with one reusable implementation per distinct required gameplay or visual role. Background populations default to reusable procedural bodies and motion or compatible existing rigged assets. Catalog clips need a compatible skeleton and may be paid. Reserve paid custom bodies for the player and characters examined or interacted with closely, while honoring explicit user requests. Add variants only for an unmet requirement; unspent allowance is not unfinished work. Allowance questions use timeoutPolicy no-consent: silence does not raise the allowance. Record role, reuse and completion criteria in the existing Assets table. Decide per object, not per category, and write the route on each Assets row in \`DESIGN.md\` \u2014 the paid flow for generated pieces, the procedural flow with its local path for code-built ones. An Assets table with no generation in it is a decision, not a default: record it as \`Generation: none \u2014 <why code alone reaches the bar here>\` or generate.
903
904
  23. **The screenshots you take to look at your own work go in \`.genex/scratch/\`.** Captures, render comparisons, before/after strips, traces and metrics dumps are how you SEE what you built \u2014 they are not part of what you built, and \`genex preview\` pushes the whole folder to the game's source, every time, forever. \`.genex/scratch/\` already exists for this and is already ignored, so there is nothing to set up: write the capture as \`.genex/scratch/arena-before.png\` and read it back from there. Never put them in a folder of your own at the top level: \`progress/\`, \`reports/\`, \`shots/\` and their kind are pushed like source and become permanent weight in the game and in every remix of it \u2014 one real game reached 11 GB and 10,332 committed screenshots exactly this way. If you find such a folder already there, add it to \`.gitignore\`; never delete the player's files (law 18).
905
+ 24. **Benchmark before declaring an in-game LLM price.** If the game calls a language model while the player plays \u2014 an NPC answering in its own words, a quest written for this save, a judge reading what the player typed \u2014 the PLAYER pays for it, and \`estimateCoins\` is the FIXED price of a started attempt: charged whether it succeeds, fails, is canceled, or stops at its own budget. Never pick that number by judgement or from a vendor's rate card. Run the real prompt on your own coins \u2014 \`npx genex llm bench "<the prompt>" --schema <file> --samples 3 --max-coins <n> --user-approved\` \u2014 and declare the figure it recommends, recorded in \`DESIGN.md\` with its model and date; re-benchmark whenever the prompt, the schema or the model changes, because a prompt edit is a price change. Check the lane first with \`npx genex llm models\`: where it is off the routes 404, so build the feature behind a graceful unavailable state and say so in the handoff. A repeated call is a standing budget the player approves once (\`requestSpendGrant()\`, then \`generate({ grantId })\`), never a popup per turn, and its disclosed call rate is computed from the game's own loop. Model output is data the game validates against its own expectation \u2014 never executed, and never authority over coin, items, entitlements or rewards. Load \`$genex-llm-in-games\` before writing any of it.
904
906
  ${CONTRACT_END}
905
907
  `;
906
908
  var TOOLS_CONTRACT_HEAD = `# Genex Tools (always in effect in this folder)
@@ -1001,7 +1003,7 @@ async function writeToolsContract(projectDir, opts = {}) {
1001
1003
  }
1002
1004
  async function healAncestorContracts(projectDir, stopDir = os3.homedir()) {
1003
1005
  const findings = [];
1004
- const stop = path5.resolve(stopDir);
1006
+ const stop2 = path5.resolve(stopDir);
1005
1007
  let dir = path5.dirname(path5.resolve(projectDir));
1006
1008
  for (let depth = 0; depth < 20; depth++) {
1007
1009
  try {
@@ -1043,7 +1045,7 @@ async function healAncestorContracts(projectDir, stopDir = os3.homedir()) {
1043
1045
  }
1044
1046
  } catch {
1045
1047
  }
1046
- if (dir === stop) break;
1048
+ if (dir === stop2) break;
1047
1049
  const parent = path5.dirname(dir);
1048
1050
  if (parent === dir) break;
1049
1051
  dir = parent;
@@ -1485,8 +1487,8 @@ async function installedPackageVersion(cwd, name) {
1485
1487
  return null;
1486
1488
  }
1487
1489
  }
1488
- function formatNudge(label, latest, installed, install) {
1489
- return `\u2B06 Genex ${label} ${latest} available (installed ${installed}) \u2014 run: ${install}`;
1490
+ function formatNudge(label, latest, installed, install2) {
1491
+ return `\u2B06 Genex ${label} ${latest} available (installed ${installed}) \u2014 run: ${install2}`;
1490
1492
  }
1491
1493
  async function reportUpdateNudges(check, log, cwd = process.cwd(), cachePath = getUpdateCachePath()) {
1492
1494
  try {
@@ -1704,8 +1706,8 @@ async function ensureLocalCli(log, cwd = process.cwd(), opts = {}) {
1704
1706
  const spec = `${CLI_PACKAGE}@${CLI_CHANNEL}`;
1705
1707
  const { command, args } = installArgs(pm, spec);
1706
1708
  log.step(`Adding ${c.cyan(CLI_PACKAGE)} as a devDependency (${pm}) so npx genex resolves here\u2026`);
1707
- const run2 = opts.runner ?? spawnInstall;
1708
- const result = await run2(command, args, cwd);
1709
+ const run4 = opts.runner ?? spawnInstall;
1710
+ const result = await run4(command, args, cwd);
1709
1711
  if (result.ok) {
1710
1712
  log.success(`${c.cyan("npx genex")} now resolves in this folder.`);
1711
1713
  } else {
@@ -1746,10 +1748,10 @@ function sourceCrc32(bytes, previous = 0) {
1746
1748
  var RemixError = class extends Error {
1747
1749
  code;
1748
1750
  status;
1749
- constructor(code, message, status) {
1751
+ constructor(code, message, status2) {
1750
1752
  super(message);
1751
1753
  this.code = code;
1752
- this.status = status;
1754
+ this.status = status2;
1753
1755
  }
1754
1756
  };
1755
1757
  var badArchive = (message) => {
@@ -1926,8 +1928,8 @@ function draftPageUrl(dashboard, slug) {
1926
1928
  function publicPageUrl(dashboard, slug) {
1927
1929
  return `${dashboard.replace(/\/+$/, "")}/${slug}`;
1928
1930
  }
1929
- function gamePageUrl(dashboard, slug, status) {
1930
- return status === "published" ? publicPageUrl(dashboard, slug) : draftPageUrl(dashboard, slug);
1931
+ function gamePageUrl(dashboard, slug, status2) {
1932
+ return status2 === "published" ? publicPageUrl(dashboard, slug) : draftPageUrl(dashboard, slug);
1931
1933
  }
1932
1934
 
1933
1935
  // src/lib/deploy.ts
@@ -2005,10 +2007,10 @@ async function readLedger(cwd = process.cwd()) {
2005
2007
  }
2006
2008
  return [...entries.values()];
2007
2009
  }
2008
- async function recordTerminal(id, status, urls, cwd = process.cwd()) {
2010
+ async function recordTerminal(id, status2, urls, cwd = process.cwd()) {
2009
2011
  const existing = (await readLedger(cwd)).find((e) => e.id === id);
2010
- if (existing && existing.status === status) return;
2011
- await append(cwd, { t: "done", id, status, ...urls?.length ? { urls } : {} });
2012
+ if (existing && existing.status === status2) return;
2013
+ await append(cwd, { t: "done", id, status: status2, ...urls?.length ? { urls } : {} });
2012
2014
  }
2013
2015
  async function recordShipped(commit, cwd = process.cwd()) {
2014
2016
  await append(cwd, { t: "ship", at: (/* @__PURE__ */ new Date()).toISOString(), ...commit ? { commit } : {} });
@@ -2035,13 +2037,13 @@ async function countFailed(kind, cwd = process.cwd()) {
2035
2037
  }
2036
2038
  async function trailingFailures(kind, cwd = process.cwd()) {
2037
2039
  const rows = (await readLedger(cwd)).filter((e) => e.kind === kind);
2038
- let run2 = 0;
2040
+ let run4 = 0;
2039
2041
  for (let i = rows.length - 1; i >= 0; i -= 1) {
2040
- const status = rows[i].status;
2041
- if (status === "completed") break;
2042
- if (status === "failed") run2 += 1;
2042
+ const status2 = rows[i].status;
2043
+ if (status2 === "completed") break;
2044
+ if (status2 === "failed") run4 += 1;
2043
2045
  }
2044
- return run2;
2046
+ return run4;
2045
2047
  }
2046
2048
  async function countOutcomes(kind, cwd = process.cwd()) {
2047
2049
  const rows = (await readLedger(cwd)).filter((e) => e.kind === kind);
@@ -2578,25 +2580,25 @@ function embedBootNudges(log, d) {
2578
2580
  }
2579
2581
  }
2580
2582
  function generationNudges(log, g) {
2581
- const describe = (refs) => refs.map((r) => `${r.kind} ${r.id} ("${r.prompt.slice(0, 48)}")`).join("; ");
2583
+ const describe2 = (refs) => refs.map((r) => `${r.kind} ${r.id} ("${r.prompt.slice(0, 48)}")`).join("; ");
2582
2584
  if (g.unpicked.length) {
2583
2585
  log.warn(
2584
- `${g.unpicked.length} generation(s) enqueued 15+ minutes ago were never picked up: ${describe(g.unpicked)}. Run npx genex wait <id> on each (npx genex wait --all shows every status at once) \u2014 landed assets get wired in before the last push of a session, never parked for "later".`
2586
+ `${g.unpicked.length} generation(s) enqueued 15+ minutes ago were never picked up: ${describe2(g.unpicked)}. Run npx genex wait <id> on each (npx genex wait --all shows every status at once) \u2014 landed assets get wired in before the last push of a session, never parked for "later".`
2585
2587
  );
2586
2588
  }
2587
2589
  if (g.unresolved.length) {
2588
2590
  log.warn(
2589
- `${g.unresolved.length} generation(s) are wired into the game but were never picked up, so their real status is unknown: ${describe(g.unresolved)}. A generation that failed server-side (credits, moderation) leaves a dead URL \u2014 the asset just silently never appears. One call checks them all: npx genex wait --all.`
2591
+ `${g.unresolved.length} generation(s) are wired into the game but were never picked up, so their real status is unknown: ${describe2(g.unresolved)}. A generation that failed server-side (credits, moderation) leaves a dead URL \u2014 the asset just silently never appears. One call checks them all: npx genex wait --all.`
2590
2592
  );
2591
2593
  }
2592
2594
  if (g.deadWired.length) {
2593
2595
  log.warn(
2594
- `${g.deadWired.length} FAILED generation(s) are still wired into the game: ${describe(g.deadWired)}. Those URLs will never exist \u2014 each asset silently never appears. Replace the wiring or re-generate (that bills a new one), and tell the user what changed.`
2596
+ `${g.deadWired.length} FAILED generation(s) are still wired into the game: ${describe2(g.deadWired)}. Those URLs will never exist \u2014 each asset silently never appears. Replace the wiring or re-generate (that bills a new one), and tell the user what changed.`
2595
2597
  );
2596
2598
  }
2597
2599
  if (g.unwired.length) {
2598
2600
  log.warn(
2599
- `${g.unwired.length} finished generation(s) are wired nowhere in the game: ${describe(g.unwired)}. Each was paid for \u2014 wire it in (its URL embeds the id) or tell the user in one plain line why it stays unused.`
2601
+ `${g.unwired.length} finished generation(s) are wired nowhere in the game: ${describe2(g.unwired)}. Each was paid for \u2014 wire it in (its URL embeds the id) or tell the user in one plain line why it stays unused.`
2600
2602
  );
2601
2603
  }
2602
2604
  if (g.musicNoSlider) {
@@ -2760,14 +2762,14 @@ function classify(cells) {
2760
2762
  const name = cells[0] ?? "";
2761
2763
  if (/^<.*>$/.test(name)) return null;
2762
2764
  const rest = cells.slice(1).join(" ");
2763
- const status = (STATUS.exec(rest) ?? [""])[0].toLowerCase();
2764
- if (PROCEDURAL_ROW.test(rest)) return { name, detail: rest, lane: null, status, procedural: true };
2765
+ const status2 = (STATUS.exec(rest) ?? [""])[0].toLowerCase();
2766
+ if (PROCEDURAL_ROW.test(rest)) return { name, detail: rest, lane: null, status: status2, procedural: true };
2765
2767
  const token = new RegExp(LANE_TOKEN.source).exec(rest)?.[1];
2766
2768
  const fromSkill = token ? LANE_OF_SKILL[token] : void 0;
2767
- if (fromSkill) return { name, detail: rest, lane: fromSkill, status, procedural: false };
2769
+ if (fromSkill) return { name, detail: rest, lane: fromSkill, status: status2, procedural: false };
2768
2770
  if (!PAID_ROW.test(rest) && !/https?:\/\//.test(rest) && !/generating\s*\(/i.test(rest)) return null;
2769
2771
  const lane = LANE_WORDS.find(([re]) => re.test(rest))?.[1] ?? null;
2770
- return { name, detail: rest, lane, status, procedural: false };
2772
+ return { name, detail: rest, lane, status: status2, procedural: false };
2771
2773
  }
2772
2774
  function auditGenerationPlan(input) {
2773
2775
  const { design, ledger } = input;
@@ -2836,8 +2838,8 @@ function auditGenerationPlan(input) {
2836
2838
  if (lanesRun.has(lane) || lanesPending.has(lane) || owed.has(lane) || lane === "skybox") continue;
2837
2839
  if (!rows.some((r) => r.lane === lane)) owed.set(lane, ["declared as this subsystem's lane"]);
2838
2840
  }
2839
- for (const [lane, names] of owed) {
2840
- const shown = names.slice(0, 3).join(", ") + (names.length > 3 ? `, +${names.length - 3} more` : "");
2841
+ for (const [lane, names2] of owed) {
2842
+ const shown = names2.slice(0, 3).join(", ") + (names2.length > 3 ? `, +${names2.length - 3} more` : "");
2841
2843
  warnings.push(
2842
2844
  `DESIGN.md plans generated ${lane} work (${shown}) and this project has no ${lane} generation that landed \u2014 the plan says so and the generation ledger has nothing completed. Generate it (${LANE_COMMAND[lane] ?? `npx genex ${lane}`} "<prompt>" --no-wait), or change the row to say what happens instead and why.`
2843
2845
  );
@@ -3487,8 +3489,8 @@ function requireOpt(value, flag, sub) {
3487
3489
  async function uiExtract(opts, log) {
3488
3490
  const input = requireOpt(opts.input, "--in", "extract");
3489
3491
  const outDir = requireOpt(opts.outDir, "--out-dir", "extract");
3490
- const names = (opts.names ?? "").split(",").map((s) => s.trim()).filter(Boolean);
3491
- if (names.length === 0) fail("Missing --names (comma-separated element names, reading order).");
3492
+ const names2 = (opts.names ?? "").split(",").map((s) => s.trim()).filter(Boolean);
3493
+ if (names2.length === 0) fail("Missing --names (comma-separated element names, reading order).");
3492
3494
  const minPixels = opts.minPixels ?? 2e3;
3493
3495
  const padding = opts.padding ?? 8;
3494
3496
  const chroma = opts.chroma ?? 240;
@@ -3497,7 +3499,7 @@ async function uiExtract(opts, log) {
3497
3499
  const { width: W, height: H, data } = sheet;
3498
3500
  await fs16.mkdir(outDir, { recursive: true });
3499
3501
  log.plain(c.bold("genex ui extract"));
3500
- log.dim(` ${input} (${W}x${H}) \u2192 ${outDir}, ${names.length} names`);
3502
+ log.dim(` ${input} (${W}x${H}) \u2192 ${outDir}, ${names2.length} names`);
3501
3503
  let hasTransparency = false;
3502
3504
  for (let i = 0; i < W * H; i++) {
3503
3505
  if (data[i * 4 + 3] < 250) {
@@ -3533,32 +3535,32 @@ async function uiExtract(opts, log) {
3533
3535
  if (dilatePx > 0) {
3534
3536
  const tmp = new Uint8Array(W * H);
3535
3537
  for (let y = 0; y < H; y++) {
3536
- let run2 = 0;
3538
+ let run4 = 0;
3537
3539
  for (let x = 0; x < W; x++) {
3538
- if (subject[y * W + x]) run2 = dilatePx;
3539
- else if (run2 > 0) run2--;
3540
- tmp[y * W + x] = run2 > 0 || subject[y * W + x] ? 1 : 0;
3540
+ if (subject[y * W + x]) run4 = dilatePx;
3541
+ else if (run4 > 0) run4--;
3542
+ tmp[y * W + x] = run4 > 0 || subject[y * W + x] ? 1 : 0;
3541
3543
  }
3542
- run2 = 0;
3544
+ run4 = 0;
3543
3545
  for (let x = W - 1; x >= 0; x--) {
3544
- if (subject[y * W + x]) run2 = dilatePx;
3545
- else if (run2 > 0) run2--;
3546
- if (run2 > 0) tmp[y * W + x] = 1;
3546
+ if (subject[y * W + x]) run4 = dilatePx;
3547
+ else if (run4 > 0) run4--;
3548
+ if (run4 > 0) tmp[y * W + x] = 1;
3547
3549
  }
3548
3550
  }
3549
3551
  const tmp2 = new Uint8Array(W * H);
3550
3552
  for (let x = 0; x < W; x++) {
3551
- let run2 = 0;
3553
+ let run4 = 0;
3552
3554
  for (let y = 0; y < H; y++) {
3553
- if (tmp[y * W + x]) run2 = dilatePx;
3554
- else if (run2 > 0) run2--;
3555
- tmp2[y * W + x] = run2 > 0 || tmp[y * W + x] ? 1 : 0;
3555
+ if (tmp[y * W + x]) run4 = dilatePx;
3556
+ else if (run4 > 0) run4--;
3557
+ tmp2[y * W + x] = run4 > 0 || tmp[y * W + x] ? 1 : 0;
3556
3558
  }
3557
- run2 = 0;
3559
+ run4 = 0;
3558
3560
  for (let y = H - 1; y >= 0; y--) {
3559
- if (tmp[y * W + x]) run2 = dilatePx;
3560
- else if (run2 > 0) run2--;
3561
- if (run2 > 0) tmp2[y * W + x] = 1;
3561
+ if (tmp[y * W + x]) run4 = dilatePx;
3562
+ else if (run4 > 0) run4--;
3563
+ if (run4 > 0) tmp2[y * W + x] = 1;
3562
3564
  }
3563
3565
  }
3564
3566
  connect = tmp2;
@@ -3604,9 +3606,9 @@ async function uiExtract(opts, log) {
3604
3606
  }
3605
3607
  );
3606
3608
  }
3607
- if (big.length < names.length) {
3609
+ if (big.length < names2.length) {
3608
3610
  log.warn(
3609
- `Only ${big.length} components \u2265 ${minPixels}px; expected ${names.length}. Lower --min-pixels or check the sheet.`
3611
+ `Only ${big.length} components \u2265 ${minPixels}px; expected ${names2.length}. Lower --min-pixels or check the sheet.`
3610
3612
  );
3611
3613
  }
3612
3614
  for (const comp of big) {
@@ -3648,7 +3650,7 @@ async function uiExtract(opts, log) {
3648
3650
  band.items.sort((a, b) => a.cx - b.cx);
3649
3651
  sorted.push(...band.items);
3650
3652
  }
3651
- const nameOf = (i) => i < names.length ? names[i] : `extra-${i - names.length}`;
3653
+ const nameOf = (i) => i < names2.length ? names2[i] : `extra-${i - names2.length}`;
3652
3654
  for (let i = 0; i < sorted.length; i++) {
3653
3655
  const comp = sorted[i];
3654
3656
  const x0 = Math.max(0, comp.minX - padding);
@@ -3678,7 +3680,7 @@ async function uiExtract(opts, log) {
3678
3680
  const extractions = [];
3679
3681
  for (let i = 0; i < sorted.length; i++) {
3680
3682
  const comp = sorted[i];
3681
- const name = i < names.length ? names[i] : `extra-${i - names.length}`;
3683
+ const name = i < names2.length ? names2[i] : `extra-${i - names2.length}`;
3682
3684
  const padX0 = Math.max(0, comp.minX - padding);
3683
3685
  const padY0 = Math.max(0, comp.minY - padding);
3684
3686
  const padX1 = Math.min(W, comp.maxX + padding + 1);
@@ -3775,7 +3777,7 @@ async function uiExtract(opts, log) {
3775
3777
  2
3776
3778
  )
3777
3779
  );
3778
- const missing = names.filter((n) => !extractions.some((e) => e.name === n));
3780
+ const missing = names2.filter((n) => !extractions.some((e) => e.name === n));
3779
3781
  log.plain("");
3780
3782
  log.success(`Extracted ${extractions.length} elements \u2192 ${outDir} (+ .bbox.json sidecars).`);
3781
3783
  if (missing.length > 0) {
@@ -4105,13 +4107,13 @@ function autoDetectPairs(image, opts, log) {
4105
4107
  lastCy = cell.cy;
4106
4108
  }
4107
4109
  annotatedCells.sort((a, b) => bandOf.get(a) - bandOf.get(b) || a.cx - b.cx);
4108
- const names = (opts.names ?? "").split(",").map((s) => s.trim()).filter(Boolean);
4109
- if (names.length > 0 && names.length !== annotatedCells.length) {
4110
+ const names2 = (opts.names ?? "").split(",").map((s) => s.trim()).filter(Boolean);
4111
+ if (names2.length > 0 && names2.length !== annotatedCells.length) {
4110
4112
  fail(
4111
- `--names has ${names.length} name(s) but --auto detected ${annotatedCells.length} annotated cell(s). Match the counts (reading order) or drop --names for meter-1..n.`
4113
+ `--names has ${names2.length} name(s) but --auto detected ${annotatedCells.length} annotated cell(s). Match the counts (reading order) or drop --names for meter-1..n.`
4112
4114
  );
4113
4115
  }
4114
- for (const name of names) {
4116
+ for (const name of names2) {
4115
4117
  if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) fail("Invalid pair name.", { name });
4116
4118
  }
4117
4119
  const scoreTwin = (a, c2) => {
@@ -4187,7 +4189,7 @@ function autoDetectPairs(image, opts, log) {
4187
4189
  h: boxH
4188
4190
  });
4189
4191
  out.push({
4190
- name: names[i] ?? `meter-${i + 1}`,
4192
+ name: names2[i] ?? `meter-${i + 1}`,
4191
4193
  cleanBox: place(best.cell.minX, best.cell.minY, cw, ch),
4192
4194
  annotatedBox: place(ann.minX, ann.minY, aw, ah),
4193
4195
  // D6: --auto used to leave the component count ungated (null). --expect N
@@ -5688,9 +5690,9 @@ import crypto3 from "crypto";
5688
5690
  async function createDraftProject(opts) {
5689
5691
  const { apiUrl, token, dashboardUrl, log } = opts;
5690
5692
  log.step("Creating your project\u2026");
5691
- const names = [opts.name, `${opts.name}-${randomSuffix()}`];
5692
- for (let i = 0; i < names.length; i++) {
5693
- const name = names[i];
5693
+ const names2 = [opts.name, `${opts.name}-${randomSuffix()}`];
5694
+ for (let i = 0; i < names2.length; i++) {
5695
+ const name = names2[i];
5694
5696
  let res;
5695
5697
  try {
5696
5698
  res = await apiFetch(`${apiUrl}/api/projects`, {
@@ -7287,9 +7289,9 @@ async function runDomain(opts) {
7287
7289
  return;
7288
7290
  }
7289
7291
  if (sub === "verify") {
7290
- const status = String(data.status ?? "");
7291
- if (status === "active") log.success(`${hostname} is live.`);
7292
- else if (status === "failed") log.error(`${hostname} is stopped \u2014 its verification record is missing.`);
7292
+ const status2 = String(data.status ?? "");
7293
+ if (status2 === "active") log.success(`${hostname} is live.`);
7294
+ else if (status2 === "failed") log.error(`${hostname} is stopped \u2014 its verification record is missing.`);
7293
7295
  else log.plain(`${hostname} isn't verified yet \u2014 DNS changes can take a few minutes to spread.`);
7294
7296
  return;
7295
7297
  }
@@ -7352,7 +7354,7 @@ async function runShop(opts) {
7352
7354
  const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
7353
7355
  const call = (url, init2) => apiFetch(url, { ...init2, headers: { ...auth, ...init2?.headers ?? {} } });
7354
7356
  const projectSkus = `${apiUrl}/api/coin/projects/${encodeURIComponent(meta.id)}/skus`;
7355
- async function fail3(res2, what) {
7357
+ async function fail4(res2, what) {
7356
7358
  if (printedStructuredError(res2)) {
7357
7359
  process.exitCode = 1;
7358
7360
  return;
@@ -7373,7 +7375,7 @@ async function runShop(opts) {
7373
7375
  }
7374
7376
  if (sub === "list") {
7375
7377
  const res2 = await call(projectSkus);
7376
- if (!res2.ok) return fail3(res2, "read your shop");
7378
+ if (!res2.ok) return fail4(res2, "read your shop");
7377
7379
  const body = await res2.json();
7378
7380
  if (opts.json) {
7379
7381
  log.plain(JSON.stringify(body, null, 2));
@@ -7418,7 +7420,7 @@ async function runShop(opts) {
7418
7420
  ...opts.resellable !== void 0 ? { resellable: opts.resellable } : {}
7419
7421
  })
7420
7422
  });
7421
- if (!res2.ok) return fail3(res2, "add that item");
7423
+ if (!res2.ok) return fail4(res2, "add that item");
7422
7424
  const sku = await res2.json();
7423
7425
  if (opts.json) {
7424
7426
  log.plain(JSON.stringify(sku, null, 2));
@@ -7439,7 +7441,7 @@ async function runShop(opts) {
7439
7441
  const skuUrl = `${apiUrl}/api/coin/skus/${encodeURIComponent(skuId)}`;
7440
7442
  if (sub === "test") {
7441
7443
  const res2 = await call(`${skuUrl}/test-grant`, { method: "POST" });
7442
- if (!res2.ok) return fail3(res2, "grant yourself a test copy");
7444
+ if (!res2.ok) return fail4(res2, "grant yourself a test copy");
7443
7445
  const got = await res2.json();
7444
7446
  log.success(`You now own ${c.cyan(got.name)} in this game.`);
7445
7447
  log.dim(" It cost nothing and no sale was recorded \u2014 it is a test copy.");
@@ -7462,14 +7464,14 @@ async function runShop(opts) {
7462
7464
  return;
7463
7465
  }
7464
7466
  const res2 = await call(skuUrl, { method: "PATCH", body: JSON.stringify(patch) });
7465
- if (!res2.ok) return fail3(res2, "update that item");
7467
+ if (!res2.ok) return fail4(res2, "update that item");
7466
7468
  const sku = await res2.json();
7467
7469
  log.success(`Updated ${c.cyan(sku.name)} \u2014 ${sku.priceCoins} coin.`);
7468
7470
  if (opts.resellable === false) log.dim(" Listings already on the market stay up.");
7469
7471
  return;
7470
7472
  }
7471
7473
  const res = await call(skuUrl, { method: "DELETE" });
7472
- if (!res.ok) return fail3(res, "remove that item");
7474
+ if (!res.ok) return fail4(res, "remove that item");
7473
7475
  log.success("Removed from the shop.");
7474
7476
  log.dim(" Players who already bought it keep it.");
7475
7477
  }
@@ -8475,6 +8477,7 @@ function tokenize(cmd) {
8475
8477
  function openUrl(url) {
8476
8478
  let command;
8477
8479
  let args;
8480
+ if (process.env.GENEX_NO_BROWSER === "1") return;
8478
8481
  const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
8479
8482
  const customParts = custom ? tokenize(custom) : [];
8480
8483
  if (customParts.length > 0) {
@@ -9297,10 +9300,10 @@ function reportLocalFiles(result, log, json) {
9297
9300
  }
9298
9301
  }
9299
9302
  function laneFailureAdvice(kind, outcomes) {
9300
- const run2 = outcomes.trailingFailed ?? (outcomes.completed > 0 ? 0 : outcomes.failed);
9301
- if (run2 < 2) return null;
9303
+ const run4 = outcomes.trailingFailed ?? (outcomes.completed > 0 ? 0 : outcomes.failed);
9304
+ if (run4 < 2) return null;
9302
9305
  const everSucceeded = outcomes.completed > 0;
9303
- const opening = everSucceeded ? `\u270B the last ${run2} ${kind} generations in this project all failed.` : `\u270B ${run2} ${kind} generations attempted in this project, ${run2} failed, none succeeded.`;
9306
+ const opening = everSucceeded ? `\u270B the last ${run4} ${kind} generations in this project all failed.` : `\u270B ${run4} ${kind} generations attempted in this project, ${run4} failed, none succeeded.`;
9304
9307
  return ` ${c.bold(opening)} That pattern is the LANE, not your prompt \u2014 a fourth attempt bills the same and returns the same. Build this one in code, use a different lane, or tell the user in one plain line what is unavailable and carry on with the rest of the game.`;
9305
9308
  }
9306
9309
  function videoFailureAdvice(failures) {
@@ -9610,7 +9613,7 @@ function reportCharacterPreviewTerminal(view, log, opts = {}) {
9610
9613
  const intent = requestedBuildOf(view);
9611
9614
  const nextCommand = characterFinalizeCommand(view.id, intent);
9612
9615
  const sourceUrl = previewSource(view);
9613
- const stop = `STOP \u2014 show the front, back, left, and right views to the user. The high-detail source has ${sourceFaceCount} faces and remains preserved in R2. Ask them to approve a separate 10,000-face triangle rigging copy.`;
9616
+ const stop2 = `STOP \u2014 show the front, back, left, and right views to the user. The high-detail source has ${sourceFaceCount} faces and remains preserved in R2. Ask them to approve a separate 10,000-face triangle rigging copy.`;
9614
9617
  if (opts.open) openUrl(views.front);
9615
9618
  if (opts.json) {
9616
9619
  writeJson({
@@ -9639,7 +9642,7 @@ function reportCharacterPreviewTerminal(view, log, opts = {}) {
9639
9642
  if (sourceUrl) log.plain(` High-detail source: ${sourceUrl}`);
9640
9643
  log.plain("");
9641
9644
  log.plain(` ${c.bold("\u{1F441} Show these to the user")} \u2014 include all four views.`);
9642
- log.plain(` ${c.bold(stop)}`);
9645
+ log.plain(` ${c.bold(stop2)}`);
9643
9646
  log.plain(` After approval: ${c.cyan(nextCommand)}`);
9644
9647
  log.plain("");
9645
9648
  }
@@ -19416,8 +19419,8 @@ async function runAnims(opts) {
19416
19419
  `${parts.join(", ")} \u2192 ${c.cyan(ANIMS_DEST + path28.sep)}${addedBytes > 0 ? ` (+${formatMb(addedBytes)})` : ""}`
19417
19420
  );
19418
19421
  for (const [selector, entries] of resolved) {
19419
- const names = entries.filter((e) => !coreNames.has(e.name)).map((e) => e.name);
19420
- if (names.length > 0) log.dim(` ${selector}: ${names.join(", ")}`);
19422
+ const names2 = entries.filter((e) => !coreNames.has(e.name)).map((e) => e.name);
19423
+ if (names2.length > 0) log.dim(` ${selector}: ${names2.join(", ")}`);
19421
19424
  }
19422
19425
  log.info(
19423
19426
  `Wiring: load the ${c.cyan("genex-threejs-character-controller")} skill \u2192 "Animation packs" (loadCharacterClips picks these up automatically).`
@@ -20829,22 +20832,22 @@ async function context2(opts) {
20829
20832
  if (!token) return null;
20830
20833
  return { token, apiUrl };
20831
20834
  }
20832
- async function readVideo(path37, log) {
20835
+ async function readVideo(path44, log) {
20833
20836
  let bytes;
20834
20837
  try {
20835
- bytes = await readFile(path37);
20838
+ bytes = await readFile(path44);
20836
20839
  } catch {
20837
- log.error(`Can't read ${path37}.`);
20840
+ log.error(`Can't read ${path44}.`);
20838
20841
  return null;
20839
20842
  }
20840
20843
  if (bytes.byteLength > MAX_VIDEO_BYTES) {
20841
- log.error(`${basename(path37)} is ${(bytes.byteLength / 1e6).toFixed(0)} MB \u2014 the limit is ${MAX_VIDEO_BYTES / 1e6} MB.`);
20844
+ log.error(`${basename(path44)} is ${(bytes.byteLength / 1e6).toFixed(0)} MB \u2014 the limit is ${MAX_VIDEO_BYTES / 1e6} MB.`);
20842
20845
  return null;
20843
20846
  }
20844
20847
  return bytes;
20845
20848
  }
20846
- async function uploadVideo(apiUrl, token, characterId, path37, bytes, log) {
20847
- const contentType = /\.mov$/i.test(path37) ? "video/quicktime" : "video/mp4";
20849
+ async function uploadVideo(apiUrl, token, characterId, path44, bytes, log) {
20850
+ const contentType = /\.mov$/i.test(path44) ? "video/quicktime" : "video/mp4";
20848
20851
  const minted = await apiFetch(
20849
20852
  `${apiUrl}/api/characters/${encodeURIComponent(characterId)}/motions/video-url`,
20850
20853
  {
@@ -20859,7 +20862,7 @@ async function uploadVideo(apiUrl, token, characterId, path37, bytes, log) {
20859
20862
  return null;
20860
20863
  }
20861
20864
  const { uploadUrl, videoUrl } = await minted.json();
20862
- log.dim(` uploading ${basename(path37)} (${(bytes.byteLength / 1e6).toFixed(1)} MB)\u2026`);
20865
+ log.dim(` uploading ${basename(path44)} (${(bytes.byteLength / 1e6).toFixed(1)} MB)\u2026`);
20863
20866
  const put = await fetch(uploadUrl, {
20864
20867
  method: "PUT",
20865
20868
  headers: { "Content-Type": contentType, "Content-Length": String(bytes.byteLength) },
@@ -21859,11 +21862,11 @@ function mAimhold(C, w) {
21859
21862
  const r = yawMed * Math.PI / 180;
21860
21863
  const axis = [Math.sin(r), 0, Math.cos(r)];
21861
21864
  const fwdF = fwdSrc.map(({ v, L }) => (v[0] * axis[0] + v[1] * axis[1] + v[2] * axis[2]) / (L + 1e-9));
21862
- let run2 = 0, best = 0;
21865
+ let run4 = 0, best = 0;
21863
21866
  for (let i = 0; i < fwdF.length; i++) {
21864
21867
  const ok = dev[i] < 15 && dyF[i] < -0.22 && gapF[i] > 0.18 && gapF[i] < 0.46 && fwdF[i] > 0.75 && hunchF[i] < 16;
21865
- run2 = ok ? run2 + 1 : 0;
21866
- best = Math.max(best, run2);
21868
+ run4 = ok ? run4 + 1 : 0;
21869
+ best = Math.max(best, run4);
21867
21870
  }
21868
21871
  let holdBest = 0, holdBestScore = -Infinity;
21869
21872
  for (let i = 0; i < fwdF.length; i++) {
@@ -22217,15 +22220,15 @@ function cutOneshot(t, cfg) {
22217
22220
  const cc = folded(t);
22218
22221
  const air = cc.map((c2) => c2[0] === 0 && c2[1] === 0);
22219
22222
  let best = null;
22220
- let run2 = null;
22223
+ let run4 = null;
22221
22224
  for (let f = 0; f < air.length; f++) {
22222
- if (air[f]) run2 = run2 ? [run2[0], f] : [f, f];
22225
+ if (air[f]) run4 = run4 ? [run4[0], f] : [f, f];
22223
22226
  else {
22224
- if (run2 && (!best || run2[1] - run2[0] > best[1] - best[0])) best = run2;
22225
- run2 = null;
22227
+ if (run4 && (!best || run4[1] - run4[0] > best[1] - best[0])) best = run4;
22228
+ run4 = null;
22226
22229
  }
22227
22230
  }
22228
- if (run2 && (!best || run2[1] - run2[0] > best[1] - best[0])) best = run2;
22231
+ if (run4 && (!best || run4[1] - run4[0] > best[1] - best[0])) best = run4;
22229
22232
  let s, e;
22230
22233
  if (best) {
22231
22234
  s = best[0];
@@ -22469,8 +22472,8 @@ async function expandTakes(selectors) {
22469
22472
  for (const sel of selectors) {
22470
22473
  const st = await fs30.stat(sel).catch(() => null);
22471
22474
  if (st?.isDirectory()) {
22472
- const names = await fs30.readdir(sel);
22473
- for (const n of names.sort()) if (n.endsWith(".npz")) out.push(path31.join(sel, n));
22475
+ const names2 = await fs30.readdir(sel);
22476
+ for (const n of names2.sort()) if (n.endsWith(".npz")) out.push(path31.join(sel, n));
22474
22477
  } else if (st?.isFile()) {
22475
22478
  out.push(sel);
22476
22479
  } else {
@@ -22758,7 +22761,7 @@ async function runBlender(opts) {
22758
22761
  return 1;
22759
22762
  }
22760
22763
  if (sub === "serve") {
22761
- const { serveLocalBlender } = await import("./blender-serve-66OHC4ML.js");
22764
+ const { serveLocalBlender } = await import("./blender-serve-EH2MOW3U.js");
22762
22765
  const port = Number(process.env.GENEX_BLENDER_PORT ?? 8088);
22763
22766
  log.step(`Starting a local Blender service on port ${port}`);
22764
22767
  log.plain(
@@ -22767,7 +22770,7 @@ async function runBlender(opts) {
22767
22770
  return serveLocalBlender({ port, log });
22768
22771
  }
22769
22772
  if (sub === "mcp") {
22770
- const { runBlenderMcp } = await import("./blender-mcp-6TEAE65U.js");
22773
+ const { runBlenderMcp } = await import("./blender-mcp-HKT7N5BQ.js");
22771
22774
  return runBlenderMcp();
22772
22775
  }
22773
22776
  if (sub === "seat") {
@@ -23265,10 +23268,592 @@ async function runAssetNew(options) {
23265
23268
  }
23266
23269
 
23267
23270
  // src/commands/tools.ts
23268
- import path35 from "path";
23271
+ import path36 from "path";
23269
23272
 
23270
23273
  // src/commands/doctor.ts
23274
+ import path35 from "path";
23275
+
23276
+ // src/commands/llm.ts
23277
+ import fs33 from "fs/promises";
23271
23278
  import path34 from "path";
23279
+ import { randomUUID as randomUUID2 } from "crypto";
23280
+ var SUBS3 = ["models", "bench", "price"];
23281
+ var LLM_BENCH_APPROVAL_REQUIRED = "STOP: `genex llm bench` runs the real model and spends the player's COIN \u2014 a wallet this build's asset allowance cannot see. Re-run with --max-coins <n> --user-approved only after they agreed to the number.";
23282
+ var LLM_LANE_OFF_LINE = "The runtime LLM lane is off on this stand \u2014 in-game generate() answers 404 here, and nothing can be benchmarked.";
23283
+ var LLM_BENCH_FILE = ".genex/llm-bench.json";
23284
+ var DEFAULT_BENCH_SAMPLES = 3;
23285
+ var DEFAULT_SAMPLE_TIMEOUT_SEC = 180;
23286
+ async function readRuntimeLane(apiUrl, token) {
23287
+ const empty = (state) => ({
23288
+ state,
23289
+ models: null,
23290
+ recommendedDeclaredHeadroomBps: null,
23291
+ externalProviders: null
23292
+ });
23293
+ try {
23294
+ const res = await apiFetch(`${apiUrl}/api/runtime/models`, {
23295
+ ...token ? { headers: { Authorization: `Bearer ${token}` } } : {},
23296
+ signal: AbortSignal.timeout(6e3)
23297
+ });
23298
+ if (res.status === 404) return empty("off");
23299
+ if (res.status === 503) return empty("unavailable");
23300
+ if (!res.ok) return empty("unknown");
23301
+ const body = await res.json().catch(() => null);
23302
+ if (!body || !Array.isArray(body.models)) return empty("unknown");
23303
+ return {
23304
+ state: "live",
23305
+ models: body.models,
23306
+ recommendedDeclaredHeadroomBps: typeof body.recommendedDeclaredHeadroomBps === "number" && Number.isFinite(body.recommendedDeclaredHeadroomBps) ? body.recommendedDeclaredHeadroomBps : null,
23307
+ externalProviders: Array.isArray(body.externalProviders) ? body.externalProviders : null
23308
+ };
23309
+ } catch {
23310
+ return empty("unknown");
23311
+ }
23312
+ }
23313
+ function percentile2(values, p) {
23314
+ if (values.length === 0) return null;
23315
+ const sorted = [...values].sort((a, b) => a - b);
23316
+ const rank2 = Math.ceil(p / 100 * sorted.length);
23317
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank2 - 1))];
23318
+ }
23319
+ function recommendEstimateCoins(p95, headroomBps) {
23320
+ return Math.max(1, Math.ceil(p95 * (1e4 + headroomBps) / 1e4));
23321
+ }
23322
+ function recommendPerCallMaxCoins(p95, max, headroomBps) {
23323
+ return Math.max(recommendEstimateCoins(p95, headroomBps), recommendEstimateCoins(max, headroomBps));
23324
+ }
23325
+ function usdPerMillion(value) {
23326
+ return `$${value.toFixed(value < 1 ? 4 : 2)}`;
23327
+ }
23328
+ function usd(value) {
23329
+ return value === null ? "unknown" : `$${value.toFixed(6)}`;
23330
+ }
23331
+ async function runLlm(opts = {}) {
23332
+ const log = createLogger({ quiet: opts.quiet || opts.json });
23333
+ const cwd = opts.cwd ?? process.cwd();
23334
+ const sub = opts.name?.trim() || "models";
23335
+ const fail4 = (command, message, extra = {}) => {
23336
+ if (opts.json) writeJsonLine({ command, status: "failed", error: message, ...extra });
23337
+ else log.error(message);
23338
+ process.exitCode = 1;
23339
+ };
23340
+ if (!SUBS3.includes(sub)) {
23341
+ fail4(`llm ${sub}`, `Unknown subcommand ${sub}. Use: ${SUBS3.join(", ")}.`);
23342
+ return;
23343
+ }
23344
+ if (sub === "price") {
23345
+ await reportSavedPrice(cwd, opts, log);
23346
+ return;
23347
+ }
23348
+ if (sub === "bench") {
23349
+ if (opts.maxCoins === void 0 || opts.userApproved !== true) {
23350
+ fail4("llm bench", LLM_BENCH_APPROVAL_REQUIRED);
23351
+ return;
23352
+ }
23353
+ if (!Number.isInteger(opts.maxCoins) || opts.maxCoins < 1) {
23354
+ fail4("llm bench", `--max-coins takes a whole number of coin, 1 or more (got ${String(opts.maxCoins)}).`);
23355
+ return;
23356
+ }
23357
+ if (!opts.benchPrompt?.trim()) {
23358
+ fail4("llm bench", '`genex llm bench` needs the prompt your game would send, e.g. genex llm bench "<prompt>" --max-coins <n> --user-approved.');
23359
+ return;
23360
+ }
23361
+ if (opts.jsonOutput && opts.textOutput) {
23362
+ fail4("llm bench", "Pass --json-output or --text, not both.");
23363
+ return;
23364
+ }
23365
+ const ws = await resolveWorkspace(cwd);
23366
+ if (ws.mode === "tools" && !ws.hosted) {
23367
+ if (opts.json) writeJsonLine({ command: "llm bench", status: "failed", error: "`genex llm bench` is not part of Genex Tools." });
23368
+ else reportPlatformRefused(log, "llm bench");
23369
+ process.exitCode = 1;
23370
+ return;
23371
+ }
23372
+ }
23373
+ const meta = await readProject(cwd);
23374
+ if (sub === "bench" && !meta?.id) {
23375
+ fail4(
23376
+ "llm bench",
23377
+ "This folder isn't linked to a game \u2014 a benchmark is billed to a project you own."
23378
+ );
23379
+ if (!opts.json) {
23380
+ log.dim(` Run ${c.cyan("genex link")} (or ${c.cyan("genex list")} to find the slug) first.`);
23381
+ }
23382
+ return;
23383
+ }
23384
+ const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
23385
+ let token = opts.token ?? await readUserToken(opts.envPath, apiUrl);
23386
+ if (!token) {
23387
+ if (opts.noAuth) {
23388
+ fail4(`llm ${sub}`, "Not signed in. Re-run without --no-auth to connect.");
23389
+ return;
23390
+ }
23391
+ log.plain("Not signed in \u2014 connecting\u2026");
23392
+ try {
23393
+ assertAuthorizationOrigin(apiUrl, getApiUrl(opts.apiUrl));
23394
+ token = await authorize(apiUrl, getAuthUrl(opts.authUrl), {
23395
+ envPath: opts.envPath,
23396
+ log,
23397
+ inlineWaitMs: opts.timeoutSec ? opts.timeoutSec * 1e3 : void 0
23398
+ });
23399
+ } catch (err) {
23400
+ if (err instanceof AuthPendingError) {
23401
+ printAuthHandoff(log, err);
23402
+ log.dim(`Then re-run ${c.cyan(`genex llm ${sub}`)}.`);
23403
+ return;
23404
+ }
23405
+ fail4(`llm ${sub}`, `Sign-in didn't complete: ${err instanceof Error ? err.message : String(err)}`);
23406
+ return;
23407
+ }
23408
+ await writeUserToken(token, opts.envPath, apiUrl);
23409
+ }
23410
+ if (sub === "models") {
23411
+ await reportModels(apiUrl, token, opts, log);
23412
+ return;
23413
+ }
23414
+ await runBench({ apiUrl, token, projectId: meta.id, cwd, opts, log });
23415
+ }
23416
+ async function reportModels(apiUrl, token, opts, log) {
23417
+ const probe = await apiFetch(`${apiUrl}/api/runtime/models`, {
23418
+ headers: { Authorization: `Bearer ${token}` }
23419
+ }).catch(() => null);
23420
+ if (probe?.status === 403) {
23421
+ const body = await probe.json().catch(() => ({}));
23422
+ if (!printedStructuredError(probe)) {
23423
+ log.error(
23424
+ body.error === "credential_scope" ? "This credential can't reach the runtime lane." : "The server refused this credential for the runtime lane."
23425
+ );
23426
+ log.dim(" An API key or an MCP connector token is scoped to the asset routes and can never");
23427
+ log.dim(" benchmark. Sign this machine in with the CLI's own device-code flow instead:");
23428
+ log.dim(` ${c.cyan("genex auth")} \u2014 it prints a short link and a code; approve it on any device.`);
23429
+ }
23430
+ if (opts.json) {
23431
+ writeJsonLine({
23432
+ command: "llm models",
23433
+ status: "failed",
23434
+ error: body.error ?? "forbidden",
23435
+ enabled: null,
23436
+ models: null,
23437
+ externalProviders: null,
23438
+ recommendedDeclaredHeadroomBps: null
23439
+ });
23440
+ }
23441
+ process.exitCode = 1;
23442
+ return;
23443
+ }
23444
+ const lane = await readRuntimeLane(apiUrl, token);
23445
+ if (opts.json) {
23446
+ writeJsonLine({
23447
+ command: "llm models",
23448
+ status: lane.state,
23449
+ error: null,
23450
+ enabled: lane.state === "live",
23451
+ models: lane.models,
23452
+ externalProviders: lane.externalProviders,
23453
+ recommendedDeclaredHeadroomBps: lane.recommendedDeclaredHeadroomBps
23454
+ });
23455
+ if (lane.state === "unknown") process.exitCode = 1;
23456
+ return;
23457
+ }
23458
+ log.plain(c.bold("genex llm models"));
23459
+ log.dim(` ${apiUrl}`);
23460
+ log.plain("");
23461
+ if (lane.state === "off") {
23462
+ log.plain(` ${LLM_LANE_OFF_LINE}`);
23463
+ log.dim(" Build the feature without an in-game model, or ask the operator to turn the lane on.");
23464
+ return;
23465
+ }
23466
+ if (lane.state === "unavailable") {
23467
+ log.warn(" This stand has the lane on but cannot serve its model list.");
23468
+ log.dim(" That is a server configuration problem, not yours \u2014 nothing here can be benchmarked yet.");
23469
+ return;
23470
+ }
23471
+ if (lane.state === "unknown" || !lane.models) {
23472
+ log.error(` Couldn't read the runtime lane from ${apiUrl}.`);
23473
+ log.dim(` Check the network, then ${c.cyan("genex doctor")}.`);
23474
+ process.exitCode = 1;
23475
+ return;
23476
+ }
23477
+ log.plain(` ${lane.models.length} model${lane.models.length === 1 ? "" : "s"} live:`);
23478
+ for (const m of lane.models) {
23479
+ log.plain(
23480
+ ` ${c.cyan(m.id)} ${m.label} \u2014 ${usdPerMillion(m.inputUsdPerMillion)} in / ${usdPerMillion(m.outputUsdPerMillion)} out per million tokens`
23481
+ );
23482
+ }
23483
+ if (lane.externalProviders && lane.externalProviders.length > 0) {
23484
+ log.plain("");
23485
+ log.plain(` Personal plans offered here: ${lane.externalProviders.map((p) => p.label).join(", ")}`);
23486
+ }
23487
+ log.plain("");
23488
+ if (lane.recommendedDeclaredHeadroomBps === null) {
23489
+ log.dim(" This stand serves no recommended headroom, so no price can be recommended from a bench.");
23490
+ } else {
23491
+ log.dim(` Recommended headroom over benchmarked coins on this stand: ${lane.recommendedDeclaredHeadroomBps} bps.`);
23492
+ }
23493
+ log.dim(" Those per-million rates are the PROVIDER's; what your game is charged is the platform");
23494
+ log.dim(` tariff on top, which only a real call reveals: ${c.cyan('genex llm bench "<prompt>" --max-coins <n> --user-approved')}`);
23495
+ }
23496
+ async function runBench(args) {
23497
+ const { apiUrl, token, projectId, cwd, opts, log } = args;
23498
+ const maxCoins = opts.maxCoins;
23499
+ const samples = opts.samples ?? DEFAULT_BENCH_SAMPLES;
23500
+ const prompt = opts.benchPrompt.trim();
23501
+ const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
23502
+ const call = (url, init2) => apiFetch(url, { ...init2, headers: { ...auth, ...init2?.headers ?? {} } });
23503
+ let schema;
23504
+ if (opts.schemaPath) {
23505
+ try {
23506
+ schema = JSON.parse(await fs33.readFile(path34.resolve(cwd, opts.schemaPath), "utf8"));
23507
+ } catch (err) {
23508
+ benchFailed(opts, log, `Couldn't read --schema ${opts.schemaPath}: ${err instanceof Error ? err.message : String(err)}`);
23509
+ return;
23510
+ }
23511
+ }
23512
+ const outputFormat = schema !== void 0 || opts.jsonOutput ? "json" : "text";
23513
+ if (schema !== void 0 && opts.textOutput) {
23514
+ benchFailed(opts, log, "--schema asks for a structured answer, so --text cannot apply. Drop one of them.");
23515
+ return;
23516
+ }
23517
+ const lane = await readRuntimeLane(apiUrl, token);
23518
+ if (lane.state !== "live" || !lane.models || lane.models.length === 0) {
23519
+ if (lane.state === "off") {
23520
+ if (opts.json) writeJsonLine({ ...emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoins, opts), status: "off", error: null });
23521
+ else {
23522
+ log.plain(LLM_LANE_OFF_LINE);
23523
+ log.dim(" Nothing was spent.");
23524
+ }
23525
+ return;
23526
+ }
23527
+ benchFailed(
23528
+ opts,
23529
+ log,
23530
+ lane.state === "unavailable" ? "This stand has the lane on but cannot serve its model list \u2014 nothing can be benchmarked yet." : `Couldn't read the runtime lane from ${apiUrl}.`
23531
+ );
23532
+ return;
23533
+ }
23534
+ const modelId = opts.modelId ?? lane.models[0].id;
23535
+ if (!lane.models.some((m) => m.id === modelId)) {
23536
+ benchFailed(opts, log, `This stand does not serve ${modelId}. Run \`genex llm models\` for the ids it does.`);
23537
+ return;
23538
+ }
23539
+ const balanceBefore = await readCoinBalance(apiUrl, token);
23540
+ if (!opts.json) {
23541
+ log.plain(c.bold("genex llm bench"));
23542
+ log.dim(` ${apiUrl}`);
23543
+ log.plain("");
23544
+ log.plain(` Model ${c.cyan(modelId)}`);
23545
+ log.plain(` Samples ${samples} real attempt${samples === 1 ? "" : "s"}, ${outputFormat} output`);
23546
+ log.plain(` Approved ${maxCoins} coin per attempt \u2014 at worst ${maxCoins * samples} coin for this run`);
23547
+ log.plain(
23548
+ ` Balance ${balanceBefore === null ? "couldn't be read" : `${balanceBefore} coin spendable`}`
23549
+ );
23550
+ log.plain("");
23551
+ }
23552
+ const base = `${apiUrl}/api/runtime/development/projects/${encodeURIComponent(projectId)}/generations`;
23553
+ const results = [];
23554
+ let inflight = null;
23555
+ const onSigint = () => {
23556
+ const target = inflight;
23557
+ inflight = null;
23558
+ if (!opts.json) log.warn("Stopping \u2014 cancelling the attempt in flight.");
23559
+ void (async () => {
23560
+ if (target) {
23561
+ await call(`${base}/${encodeURIComponent(target)}/cancel`, { method: "POST" }).catch(() => {
23562
+ });
23563
+ }
23564
+ process.exit(130);
23565
+ })();
23566
+ };
23567
+ process.once("SIGINT", onSigint);
23568
+ try {
23569
+ for (let i = 0; i < samples; i++) {
23570
+ const res = await call(base, {
23571
+ method: "POST",
23572
+ body: JSON.stringify({
23573
+ maxCoins,
23574
+ request: {
23575
+ idempotencyKey: `bench-${randomUUID2()}`,
23576
+ modelId,
23577
+ prompt,
23578
+ outputFormat,
23579
+ ...schema !== void 0 ? { schema } : {}
23580
+ }
23581
+ })
23582
+ });
23583
+ if (!res.ok) {
23584
+ const stop2 = await explainBenchRefusal(res, log, opts, i, results.length);
23585
+ if (stop2) break;
23586
+ continue;
23587
+ }
23588
+ const started = await res.json().catch(() => ({}));
23589
+ if (!started.id) {
23590
+ results.push({ id: null, status: null, billingStatus: null, chargedCoins: null, costUsd: null, error: "no_generation_id" });
23591
+ continue;
23592
+ }
23593
+ inflight = started.id;
23594
+ const settled = await pollSettled(call, `${base}/${encodeURIComponent(started.id)}`, opts.timeoutSec ?? DEFAULT_SAMPLE_TIMEOUT_SEC);
23595
+ inflight = null;
23596
+ const row = {
23597
+ id: started.id,
23598
+ status: settled?.status ?? null,
23599
+ billingStatus: settled?.billingStatus ?? null,
23600
+ chargedCoins: typeof settled?.chargedCoins === "number" ? settled.chargedCoins : null,
23601
+ costUsd: typeof settled?.usage?.costUsd === "number" ? settled.usage.costUsd : null,
23602
+ error: settled?.error ?? (settled ? null : "timed_out")
23603
+ };
23604
+ results.push(row);
23605
+ if (!opts.json) {
23606
+ log.plain(
23607
+ ` ${row.status === "succeeded" ? c.green("\u2713") : c.yellow("!")} sample ${i + 1} ${String(row.chargedCoins ?? "\u2014").padStart(4)} coin charged \xB7 provider ${usd(row.costUsd)}${row.error ? ` \xB7 ${row.error}` : ""}`
23608
+ );
23609
+ }
23610
+ }
23611
+ } finally {
23612
+ process.removeListener("SIGINT", onSigint);
23613
+ }
23614
+ const charged = results.filter((r) => r.billingStatus === "final" && typeof r.chargedCoins === "number").map((r) => r.chargedCoins);
23615
+ const settledCount = charged.length;
23616
+ const p50 = percentile2(charged, 50);
23617
+ const p95 = percentile2(charged, 95);
23618
+ const max = charged.length ? Math.max(...charged) : null;
23619
+ const headroomBps = lane.recommendedDeclaredHeadroomBps;
23620
+ const recommended = p95 !== null && headroomBps !== null ? recommendEstimateCoins(p95, headroomBps) : null;
23621
+ const ceiling = p95 !== null && max !== null && headroomBps !== null ? recommendPerCallMaxCoins(p95, max, headroomBps) : null;
23622
+ const balanceAfter = await readCoinBalance(apiUrl, token);
23623
+ const record = {
23624
+ v: 1,
23625
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
23626
+ apiUrl,
23627
+ projectId,
23628
+ modelId,
23629
+ prompt,
23630
+ outputFormat,
23631
+ samples,
23632
+ completed: settledCount,
23633
+ chargedCoins: charged,
23634
+ p50,
23635
+ p95,
23636
+ max,
23637
+ recommendedDeclaredHeadroomBps: headroomBps,
23638
+ recommendedEstimateCoins: recommended,
23639
+ recommendedPerCallMaxCoins: ceiling
23640
+ };
23641
+ const savedTo = await saveBench(cwd, record);
23642
+ if (opts.json) {
23643
+ writeJsonLine({
23644
+ command: "llm bench",
23645
+ status: charged.length > 0 ? "ok" : "failed",
23646
+ error: charged.length > 0 ? null : "no_sample_settled",
23647
+ apiUrl,
23648
+ projectId,
23649
+ modelId,
23650
+ prompt,
23651
+ outputFormat,
23652
+ schemaPath: opts.schemaPath ?? null,
23653
+ samples,
23654
+ completed: settledCount,
23655
+ maxCoinsPerSample: maxCoins,
23656
+ balanceBefore,
23657
+ balanceAfter,
23658
+ results,
23659
+ chargedCoins: { p50, p95, max },
23660
+ recommendedDeclaredHeadroomBps: headroomBps,
23661
+ recommendedEstimateCoins: recommended,
23662
+ recommendedPerCallMaxCoins: ceiling,
23663
+ savedTo
23664
+ });
23665
+ if (charged.length === 0) process.exitCode = 1;
23666
+ return;
23667
+ }
23668
+ log.plain("");
23669
+ if (charged.length === 0) {
23670
+ log.error(" No sample settled, so there is nothing to price from.");
23671
+ process.exitCode = 1;
23672
+ return;
23673
+ }
23674
+ log.plain(c.bold(" Charged coins"));
23675
+ log.plain(` p50 ${p50} p95 ${p95} max ${max} (${charged.length} of ${samples} settled)`);
23676
+ if (balanceBefore !== null && balanceAfter !== null) {
23677
+ log.plain(` Balance ${balanceBefore} \u2192 ${balanceAfter} coin spendable`);
23678
+ }
23679
+ log.plain("");
23680
+ printRecommendation(log, record);
23681
+ if (savedTo) log.dim(` Saved to ${savedTo} \u2014 re-read it any time with ${c.cyan("genex llm price")}.`);
23682
+ }
23683
+ function printRecommendation(log, record) {
23684
+ if (record.recommendedEstimateCoins === null || record.p95 === null) {
23685
+ log.warn(" No price recommended.");
23686
+ log.dim(
23687
+ record.recommendedDeclaredHeadroomBps === null ? " This stand served no recommended headroom, and the multiplier is the server's to set \u2014" : " No sample settled, so there is no p95 to build a price on \u2014"
23688
+ );
23689
+ log.dim(" declaring a number from this run would be a guess dressed as a measurement.");
23690
+ return;
23691
+ }
23692
+ log.plain(c.bold(` Declare estimateCoins: ${record.recommendedEstimateCoins}`));
23693
+ log.dim(
23694
+ ` = p95 of charged coins (${record.p95}) plus this stand's recommended headroom (${record.recommendedDeclaredHeadroomBps} bps),`
23695
+ );
23696
+ log.dim(` measured over ${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}.`);
23697
+ log.dim(" That number is a PRICE: a started attempt is charged in full, including one that fails.");
23698
+ if (record.recommendedPerCallMaxCoins != null) {
23699
+ log.plain(c.bold(` Grant perCallMaxCoins: ${record.recommendedPerCallMaxCoins}`));
23700
+ log.dim(` = the same headroom over the worst sample (${record.max}), and never below the price above it.`);
23701
+ log.dim(" The ceiling is the price's room to be wrong; declaring one under the price is refused.");
23702
+ }
23703
+ }
23704
+ async function explainBenchRefusal(res, log, opts, index, settledSoFar) {
23705
+ if (printedStructuredError(res)) return true;
23706
+ const body = await res.json().catch(() => ({}));
23707
+ if (opts.json) return res.status !== 429;
23708
+ if (res.status === 404 && body.error === "not_found") {
23709
+ log.error(` ${LLM_LANE_OFF_LINE}`);
23710
+ return true;
23711
+ }
23712
+ if (res.status === 403 && body.error === "credential_scope") {
23713
+ log.error(" This credential can't reach the runtime lane \u2014 an API key or MCP token is scoped out.");
23714
+ log.dim(` Sign in with ${c.cyan("genex auth")} and re-run.`);
23715
+ return true;
23716
+ }
23717
+ if (res.status === 403 && body.error === "project_owner_required") {
23718
+ log.error(" A benchmark is billed to a project YOU own, and this one isn't yours.");
23719
+ return true;
23720
+ }
23721
+ if (res.status === 402) {
23722
+ log.error(" Not enough coin to start the attempt.");
23723
+ return true;
23724
+ }
23725
+ log.error(
23726
+ ` Sample ${index + 1} was refused: ${body.message ?? body.error ?? `HTTP ${res.status}`}${settledSoFar > 0 ? " (earlier samples still count)" : ""}`
23727
+ );
23728
+ return res.status >= 500 || res.status === 401 || res.status === 403;
23729
+ }
23730
+ async function pollSettled(call, url, timeoutSec) {
23731
+ const deadline = Date.now() + timeoutSec * 1e3;
23732
+ let last = null;
23733
+ while (Date.now() < deadline) {
23734
+ const res = await call(url).catch(() => null);
23735
+ if (res?.ok) {
23736
+ last = await res.json().catch(() => null);
23737
+ if (last?.billingStatus === "final") return last;
23738
+ }
23739
+ await new Promise((r) => setTimeout(r, 1500));
23740
+ }
23741
+ return last;
23742
+ }
23743
+ async function readCoinBalance(apiUrl, token) {
23744
+ try {
23745
+ const res = await apiFetch(`${apiUrl}/api/coin/balance`, {
23746
+ headers: { Authorization: `Bearer ${token}` },
23747
+ signal: AbortSignal.timeout(6e3)
23748
+ });
23749
+ if (!res.ok) return null;
23750
+ const body = await res.json().catch(() => null);
23751
+ return typeof body?.spendable === "number" ? body.spendable : null;
23752
+ } catch {
23753
+ return null;
23754
+ }
23755
+ }
23756
+ async function saveBench(cwd, record) {
23757
+ try {
23758
+ const file = path34.join(cwd, LLM_BENCH_FILE);
23759
+ await fs33.mkdir(path34.dirname(file), { recursive: true });
23760
+ await fs33.writeFile(file, JSON.stringify(record, null, 2) + "\n");
23761
+ return LLM_BENCH_FILE;
23762
+ } catch {
23763
+ return null;
23764
+ }
23765
+ }
23766
+ function benchFailed(opts, log, message) {
23767
+ if (opts.json) writeJsonLine({ command: "llm bench", status: "failed", error: message });
23768
+ else log.error(message);
23769
+ process.exitCode = 1;
23770
+ }
23771
+ function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoins, opts) {
23772
+ return {
23773
+ command: "llm bench",
23774
+ apiUrl,
23775
+ projectId,
23776
+ modelId: opts.modelId ?? null,
23777
+ prompt,
23778
+ outputFormat,
23779
+ schemaPath: opts.schemaPath ?? null,
23780
+ samples,
23781
+ completed: 0,
23782
+ maxCoinsPerSample: maxCoins,
23783
+ balanceBefore: null,
23784
+ balanceAfter: null,
23785
+ results: [],
23786
+ chargedCoins: { p50: null, p95: null, max: null },
23787
+ recommendedDeclaredHeadroomBps: null,
23788
+ recommendedEstimateCoins: null,
23789
+ recommendedPerCallMaxCoins: null,
23790
+ savedTo: null
23791
+ };
23792
+ }
23793
+ async function reportSavedPrice(cwd, opts, log) {
23794
+ let record = null;
23795
+ try {
23796
+ record = JSON.parse(await fs33.readFile(path34.join(cwd, LLM_BENCH_FILE), "utf8"));
23797
+ } catch {
23798
+ record = null;
23799
+ }
23800
+ if (!record) {
23801
+ if (opts.json) {
23802
+ writeJsonLine({
23803
+ command: "llm price",
23804
+ status: "failed",
23805
+ error: "no_bench",
23806
+ ranAt: null,
23807
+ modelId: null,
23808
+ samples: null,
23809
+ completed: null,
23810
+ p50: null,
23811
+ p95: null,
23812
+ max: null,
23813
+ recommendedDeclaredHeadroomBps: null,
23814
+ recommendedEstimateCoins: null,
23815
+ recommendedPerCallMaxCoins: null,
23816
+ savedTo: null
23817
+ });
23818
+ } else {
23819
+ log.error("Nothing has been benchmarked in this folder yet.");
23820
+ log.dim(` Run ${c.cyan('genex llm bench "<prompt>" --max-coins <n> --user-approved')} first.`);
23821
+ }
23822
+ process.exitCode = 1;
23823
+ return;
23824
+ }
23825
+ if (opts.json) {
23826
+ writeJsonLine({
23827
+ command: "llm price",
23828
+ status: record.recommendedEstimateCoins === null ? "failed" : "ok",
23829
+ error: record.recommendedEstimateCoins === null ? "no_recommendation" : null,
23830
+ ranAt: record.ranAt ?? null,
23831
+ modelId: record.modelId ?? null,
23832
+ samples: record.samples ?? null,
23833
+ completed: record.completed ?? null,
23834
+ p50: record.p50 ?? null,
23835
+ p95: record.p95 ?? null,
23836
+ max: record.max ?? null,
23837
+ recommendedDeclaredHeadroomBps: record.recommendedDeclaredHeadroomBps ?? null,
23838
+ recommendedEstimateCoins: record.recommendedEstimateCoins ?? null,
23839
+ recommendedPerCallMaxCoins: record.recommendedPerCallMaxCoins ?? null,
23840
+ savedTo: LLM_BENCH_FILE
23841
+ });
23842
+ if (record.recommendedEstimateCoins === null) process.exitCode = 1;
23843
+ return;
23844
+ }
23845
+ log.plain(c.bold("genex llm price"));
23846
+ log.dim(` from ${LLM_BENCH_FILE}, benchmarked ${record.ranAt}`);
23847
+ log.plain("");
23848
+ printRecommendation(log, record);
23849
+ if (record.recommendedEstimateCoins === null) process.exitCode = 1;
23850
+ }
23851
+ function writeJsonLine(value) {
23852
+ process.stdout.write(`${JSON.stringify(value)}
23853
+ `);
23854
+ }
23855
+
23856
+ // src/commands/doctor.ts
23272
23857
  var LANE_ORDER = [
23273
23858
  "model",
23274
23859
  "image",
@@ -23339,15 +23924,18 @@ async function runDoctor(opts = {}) {
23339
23924
  let credits = null;
23340
23925
  let lanes = null;
23341
23926
  let legal = null;
23927
+ let runtime = null;
23342
23928
  if (authState === "ok" && token) {
23343
- [credits, lanes, legal] = await Promise.all([
23929
+ [credits, lanes, legal, runtime] = await Promise.all([
23344
23930
  fetchCredits(apiUrl, token),
23345
23931
  fetchLanes(apiUrl, token),
23346
- fetchLegalStatus(apiUrl, token)
23932
+ fetchLegalStatus(apiUrl, token),
23933
+ readRuntimeLane(apiUrl, token)
23347
23934
  ]);
23348
23935
  }
23349
23936
  rows.push(termsRow(authState, legal));
23350
23937
  rows.push(creditsRow(authState, credits));
23938
+ rows.push(runtimeRow(authState, runtime));
23351
23939
  if (opts.json) {
23352
23940
  process.stdout.write(
23353
23941
  JSON.stringify(
@@ -23366,6 +23954,10 @@ async function runDoctor(opts = {}) {
23366
23954
  terms: legal,
23367
23955
  credits,
23368
23956
  lanes: lanes ? { paused: lanes.paused, lanes: lanes.lanes } : null,
23957
+ // Same always-present rule as `assetsMode`: a consumer must be able
23958
+ // to tell a CLI that KNOWS the runtime lane (key present, object or
23959
+ // null) from one that predates it (no key at all).
23960
+ runtime,
23369
23961
  ok: !rows.some((r) => r.bad)
23370
23962
  },
23371
23963
  null,
@@ -23504,6 +24096,29 @@ function creditsRow(state, info) {
23504
24096
  fix: info.balance <= 0 ? "Out of credits \u2014 generation is refused until the refill or a top-up." : void 0
23505
24097
  };
23506
24098
  }
24099
+ function runtimeRow(state, lane) {
24100
+ if (state !== "ok") return { label: "Runtime", value: "\u2014 (sign in first)" };
24101
+ switch (lane?.state) {
24102
+ case "live": {
24103
+ const n = lane.models?.length ?? 0;
24104
+ return {
24105
+ label: "Runtime",
24106
+ value: `in-game LLM lane live \xB7 ${n} model${n === 1 ? "" : "s"}`,
24107
+ fix: 'Price a call before a game declares one: `npx genex llm bench "<prompt>" --max-coins <n> --user-approved`.'
24108
+ };
24109
+ }
24110
+ case "off":
24111
+ return {
24112
+ label: "Runtime",
24113
+ value: "in-game LLM lane off on this stand",
24114
+ fix: "generate() in a game answers 404 here \u2014 build the feature without a model, or ask the operator."
24115
+ };
24116
+ case "unavailable":
24117
+ return { label: "Runtime", value: "in-game LLM lane on, but its model list can't be served" };
24118
+ default:
24119
+ return { label: "Runtime", value: "couldn't be read from this server" };
24120
+ }
24121
+ }
23507
24122
  function shortDate(iso) {
23508
24123
  const d = new Date(iso);
23509
24124
  return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
@@ -23549,7 +24164,7 @@ async function fetchLegalStatus(apiUrl, token) {
23549
24164
  }
23550
24165
  async function firstSkillsMarker() {
23551
24166
  for (const target of resolveAgentTargets()) {
23552
- const marker = await readSkillsMarker(path34.join(target.baseDir, "skills"));
24167
+ const marker = await readSkillsMarker(path35.join(target.baseDir, "skills"));
23553
24168
  if (marker) return marker;
23554
24169
  }
23555
24170
  return null;
@@ -23600,8 +24215,8 @@ async function runTools(opts) {
23600
24215
  let totalNew = 0;
23601
24216
  let totalUpdated = 0;
23602
24217
  for (const t of targets) {
23603
- const dest = path35.join(t.baseDir, "skills");
23604
- const { copied, updated } = await copyTemplates(path35.join(templatesDir, "skills"), dest, {
24218
+ const dest = path36.join(t.baseDir, "skills");
24219
+ const { copied, updated } = await copyTemplates(path36.join(templatesDir, "skills"), dest, {
23605
24220
  filter: (rel) => skillFamilyFilter("tools")(`skills/${rel}`)
23606
24221
  });
23607
24222
  await pruneRemovedSkills(dest, log);
@@ -23611,211 +24226,1755 @@ async function runTools(opts) {
23611
24226
  totalUpdated += updated.length;
23612
24227
  log.dim(` ${t.label}: ${added} added, ${updated.length} refreshed \u2192 ${dest}`);
23613
24228
  }
23614
- log.success(
23615
- `Tool skills ready (${totalNew} added, ${totalUpdated} refreshed across ${targets.length} agent${targets.length === 1 ? "" : "s"}).`
23616
- );
23617
- const apiUrl = getApiUrl(opts.apiUrl);
23618
- const { path: markerPath } = await writeWorkspace({
23619
- mode: "tools",
24229
+ log.success(
24230
+ `Tool skills ready (${totalNew} added, ${totalUpdated} refreshed across ${targets.length} agent${targets.length === 1 ? "" : "s"}).`
24231
+ );
24232
+ const apiUrl = getApiUrl(opts.apiUrl);
24233
+ const { path: markerPath } = await writeWorkspace({
24234
+ mode: "tools",
24235
+ apiUrl,
24236
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
24237
+ });
24238
+ log.dim(` saved ${c.cyan(markerPath)}`);
24239
+ await writeGitignore(process.cwd(), log);
24240
+ if (await writeToolsContract(process.cwd())) {
24241
+ log.dim(" wrote the Genex Tools rules into AGENTS.md (managed block) + CLAUDE.md stub");
24242
+ }
24243
+ await healAncestorContractsAndReport(log, process.cwd());
24244
+ await ensureLocalCli(log, process.cwd(), { skip: opts.noInstall });
24245
+ log.plain("");
24246
+ const authBaseUrl = getAuthUrl(opts.authUrl);
24247
+ let token = await readUserToken(opts.envPath, apiUrl);
24248
+ if (!token) {
24249
+ try {
24250
+ token = await authorize(apiUrl, authBaseUrl, {
24251
+ envPath: opts.envPath,
24252
+ log,
24253
+ inlineWaitMs: opts.timeoutSec ? opts.timeoutSec * 1e3 : void 0
24254
+ });
24255
+ } catch (err) {
24256
+ if (err instanceof AuthPendingError) {
24257
+ printAuthHandoff(log, err);
24258
+ log.dim(
24259
+ "Everything else is set up \u2014 `genex auth` finishes the sign-in, then you can generate."
24260
+ );
24261
+ return;
24262
+ }
24263
+ log.error(err instanceof Error ? err.message : String(err));
24264
+ log.dim("The workspace is set up. Re-run `genex tools` to finish authorizing.");
24265
+ throw markPrinted(err);
24266
+ }
24267
+ const { path: tokenPath } = await writeUserToken(token, opts.envPath, apiUrl);
24268
+ log.success(`Authorized. Saved your token to ${c.cyan(tokenPath)}.`);
24269
+ log.plain("");
24270
+ }
24271
+ await reportToolsInit(apiUrl, token);
24272
+ await runDoctor({ apiUrl: opts.apiUrl, envPath: opts.envPath, quiet: opts.quiet });
24273
+ log.plain("");
24274
+ log.success("Genex Tools is ready. \u{1F9F0}");
24275
+ log.dim(" Assets land in ./assets/ as files you own \u2014 wire the local path into your game.");
24276
+ }
24277
+
24278
+ // src/commands/budget.ts
24279
+ import fs34 from "fs/promises";
24280
+ import path37 from "path";
24281
+ var BUDGET_SOURCE_LINE = "Live from your account; prices can change without a deploy.";
24282
+ var BUDGET_APPROVAL_REQUIRED = "STOP: raising the asset allowance requires the player's explicit approval. Re-run with --user-approved only after they agreed to the number.";
24283
+ function internalKind(kind) {
24284
+ return kind === "agent_session" || kind === "blender_seat" || kind.startsWith("cover_");
24285
+ }
24286
+ async function isGenexProject(cwd) {
24287
+ try {
24288
+ await fs34.access(path37.join(cwd, ".genex"));
24289
+ return true;
24290
+ } catch {
24291
+ return false;
24292
+ }
24293
+ }
24294
+ function runnableKinds(prices) {
24295
+ return Object.entries(prices).filter(([kind, credits]) => !internalKind(kind) && typeof credits === "number" && Number.isFinite(credits)).sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]));
24296
+ }
24297
+ function describeDefault(config) {
24298
+ const share = config.share === 0.5 ? "half" : `${Math.round(config.share * 100)}%`;
24299
+ return `default: ${share} of what was spendable at the first paid command, capped at ${config.cap}`;
24300
+ }
24301
+ function isoDay(iso) {
24302
+ const d = new Date(iso);
24303
+ return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 10);
24304
+ }
24305
+ async function runBudget(opts = {}) {
24306
+ const log = createLogger({ quiet: opts.quiet || opts.json });
24307
+ const cwd = opts.cwd ?? process.cwd();
24308
+ const config = budgetConfig();
24309
+ const fail4 = (message) => {
24310
+ if (opts.json) writeJson({ command: "budget", status: "failed", error: message });
24311
+ else log.error(message);
24312
+ process.exitCode = 1;
24313
+ };
24314
+ const setting = opts.assets !== void 0;
24315
+ if (setting && opts.userApproved !== true) {
24316
+ fail4(BUDGET_APPROVAL_REQUIRED);
24317
+ return;
24318
+ }
24319
+ if (setting && (!Number.isInteger(opts.assets) || opts.assets < 0)) {
24320
+ fail4(`--assets takes a whole number of credits, 0 or more (got ${String(opts.assets)}).`);
24321
+ return;
24322
+ }
24323
+ const meta = await readProject(cwd);
24324
+ const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
24325
+ const token = opts.token ?? await readUserToken(opts.envPath, apiUrl);
24326
+ if (!token) {
24327
+ fail4("Not authorized. Run `genex init` first to sign in.");
24328
+ return;
24329
+ }
24330
+ const snapshot = await fetchCreditsSnapshot(apiUrl, token);
24331
+ let store;
24332
+ let persisted = true;
24333
+ if (setting) {
24334
+ const previous = await readBudgetStore(cwd);
24335
+ store = {
24336
+ v: 1,
24337
+ allowanceCredits: opts.assets,
24338
+ setBy: "user",
24339
+ setAt: (/* @__PURE__ */ new Date()).toISOString(),
24340
+ spendableAtStart: previous?.spendableAtStart ?? snapshot?.spendable ?? 0,
24341
+ balanceAtStart: previous?.balanceAtStart ?? snapshot?.balance ?? 0
24342
+ };
24343
+ persisted = await writeBudgetStore(cwd, store);
24344
+ if (!persisted) {
24345
+ fail4("This folder is not a Genex project (no .genex/), so the allowance has nowhere to live. Run this inside the game folder.");
24346
+ return;
24347
+ }
24348
+ } else if (snapshot && !snapshot.unlimited && !budgetDisabled(config)) {
24349
+ store = await ensureBudgetStore(cwd, {
24350
+ balance: snapshot.balance,
24351
+ spendable: snapshot.spendable,
24352
+ config
24353
+ });
24354
+ persisted = await readBudgetStore(cwd) !== null;
24355
+ } else {
24356
+ store = await readBudgetStore(cwd);
24357
+ persisted = await isGenexProject(cwd);
24358
+ }
24359
+ const spent = spentCredits(await readLedgerRows(cwd));
24360
+ const disabled = budgetDisabled(config);
24361
+ const enforced = !disabled && !(snapshot?.unlimited ?? false);
24362
+ const allowance = store?.allowanceCredits ?? null;
24363
+ const remaining = allowance === null ? null : Math.max(0, allowance - spent);
24364
+ if (opts.json) {
24365
+ writeJson({
24366
+ command: "budget",
24367
+ ...snapshot ? {
24368
+ balance: snapshot.balance,
24369
+ reserved: snapshot.reserved,
24370
+ spendable: snapshot.spendable,
24371
+ unlimited: snapshot.unlimited
24372
+ } : { balance: null, reserved: null, spendable: null, unlimited: null },
24373
+ allowance: {
24374
+ credits: allowance,
24375
+ setBy: store?.setBy ?? null,
24376
+ setAt: store?.setAt ?? null,
24377
+ spendableAtStart: store?.spendableAtStart ?? null,
24378
+ balanceAtStart: store?.balanceAtStart ?? null,
24379
+ enforced,
24380
+ disabled,
24381
+ persisted
24382
+ },
24383
+ spent,
24384
+ remaining,
24385
+ prices: snapshot ? Object.fromEntries(runnableKinds(snapshot.prices)) : null,
24386
+ source: BUDGET_SOURCE_LINE,
24387
+ ...snapshot ? {} : { warning: `Balance and prices couldn't be read from ${apiUrl}.` }
24388
+ });
24389
+ if (!snapshot && !setting) process.exitCode = 1;
24390
+ return;
24391
+ }
24392
+ log.plain(c.bold("genex budget"));
24393
+ if (setting && store) {
24394
+ log.success(`Asset allowance set to ${store.allowanceCredits} credits (approved by the player).`);
24395
+ }
24396
+ if (snapshot) {
24397
+ log.plain(` Balance ${snapshot.balance} credits \xB7 ${snapshot.reserved} reserved \xB7 ${snapshot.spendable} spendable`);
24398
+ } else {
24399
+ log.warn(` Balance couldn't be read from ${apiUrl} \u2014 run ${c.cyan("npx genex doctor")}.`);
24400
+ }
24401
+ if (disabled) {
24402
+ log.plain(` Allowance off (GENEX_ASSET_BUDGET_CAP=0)`);
24403
+ } else if (snapshot?.unlimited) {
24404
+ log.plain(` Allowance not enforced (unlimited operator account)`);
24405
+ } else if (store) {
24406
+ const origin = store.setBy === "user" ? `set by you on ${isoDay(store.setAt)}` : describeDefault(config);
24407
+ log.plain(` Allowance ${store.allowanceCredits} credits (${origin})`);
24408
+ } else {
24409
+ log.plain(` Allowance not derived yet \u2014 it is sized from the wallet at the first paid command`);
24410
+ }
24411
+ log.plain(` Spent ${spent} credits this build (quoted; failed generations are refunded and excluded)`);
24412
+ if (remaining !== null && enforced) {
24413
+ log.plain(` Remaining ${remaining} credits`);
24414
+ }
24415
+ if (!persisted) {
24416
+ log.dim(" (not a Genex project folder \u2014 the allowance is not saved here)");
24417
+ }
24418
+ if (snapshot) {
24419
+ const kinds = runnableKinds(snapshot.prices);
24420
+ if (kinds.length > 0) {
24421
+ log.plain("");
24422
+ log.plain(c.bold(" Prices (credits)"));
24423
+ for (const [kind, credits] of kinds) {
24424
+ log.plain(` ${kind.padEnd(20)} ${String(credits).padStart(5)}`);
24425
+ }
24426
+ }
24427
+ }
24428
+ log.plain("");
24429
+ log.dim(` ${BUDGET_SOURCE_LINE}`);
24430
+ if (enforced && !setting) {
24431
+ log.dim(` More for this game? Ask the player, then: ${c.cyan("npx genex budget --assets <credits> --user-approved")}`);
24432
+ }
24433
+ if (!snapshot && !setting) process.exitCode = 1;
24434
+ }
24435
+
24436
+ // src/commands/player.ts
24437
+ import fs39 from "fs";
24438
+ import fsp2 from "fs/promises";
24439
+ import os12 from "os";
24440
+ import path43 from "path";
24441
+ import { spawn as spawn8 } from "child_process";
24442
+
24443
+ // src/lib/player/paths.ts
24444
+ import os8 from "os";
24445
+ import path38 from "path";
24446
+ var PLAYER_DRY_RUN_ENV = "GENEX_PLAYER_DRY_RUN";
24447
+ function playerDryRun() {
24448
+ const raw = process.env[PLAYER_DRY_RUN_ENV];
24449
+ return raw === "1" || raw === "true";
24450
+ }
24451
+ function playerPaths(home = os8.homedir()) {
24452
+ const dir = home === os8.homedir() ? getGenexDir() : path38.join(home, ".genex");
24453
+ const logDir = path38.join(dir, "logs");
24454
+ return {
24455
+ credential: path38.join(dir, "player.json"),
24456
+ state: path38.join(dir, "player-state.json"),
24457
+ relaunch: path38.join(dir, "player-relaunch.json"),
24458
+ logDir,
24459
+ log: path38.join(logDir, "player.log"),
24460
+ errorLog: path38.join(logDir, "player.error.log")
24461
+ };
24462
+ }
24463
+
24464
+ // src/lib/player/store.ts
24465
+ import fs35 from "fs/promises";
24466
+ import path39 from "path";
24467
+ async function readPlayerInstall(home) {
24468
+ try {
24469
+ const raw = await fs35.readFile(playerPaths(home).credential, "utf8");
24470
+ const parsed = JSON.parse(raw);
24471
+ if (!parsed || typeof parsed.token !== "string" || typeof parsed.apiUrl !== "string") return null;
24472
+ return parsed;
24473
+ } catch {
24474
+ return null;
24475
+ }
24476
+ }
24477
+ async function writePlayerInstall(install2, home) {
24478
+ const file = playerPaths(home).credential;
24479
+ const record = { ...install2, apiUrl: normalizeApiOrigin(install2.apiUrl) };
24480
+ await writeSecretFile(file, JSON.stringify(record, null, 2) + "\n");
24481
+ return file;
24482
+ }
24483
+ function installExpired(install2, now = Date.now()) {
24484
+ const at = Date.parse(install2.expiresAt);
24485
+ return Number.isFinite(at) && at <= now;
24486
+ }
24487
+ function describeInstall(install2) {
24488
+ return {
24489
+ apiUrl: install2.apiUrl,
24490
+ watcherId: install2.watcherId ?? null,
24491
+ label: install2.label ?? null,
24492
+ installedAt: install2.installedAt,
24493
+ version: install2.version,
24494
+ expiresAt: install2.expiresAt
24495
+ };
24496
+ }
24497
+ async function readPlayerState(home) {
24498
+ try {
24499
+ return JSON.parse(await fs35.readFile(playerPaths(home).state, "utf8"));
24500
+ } catch {
24501
+ return null;
24502
+ }
24503
+ }
24504
+ async function writePlayerState(state, home) {
24505
+ const file = playerPaths(home).state;
24506
+ try {
24507
+ await fs35.mkdir(path39.dirname(file), { recursive: true, mode: 448 });
24508
+ await fs35.writeFile(file, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
24509
+ } catch {
24510
+ }
24511
+ }
24512
+ async function removePlayerFiles(home) {
24513
+ const paths = playerPaths(home);
24514
+ const removed = [];
24515
+ for (const file of [paths.credential, paths.state, paths.relaunch]) {
24516
+ const existed = await fs35.stat(file).then(() => true, () => false);
24517
+ await fs35.rm(file, { force: true }).catch(() => {
24518
+ });
24519
+ if (existed) removed.push(file);
24520
+ }
24521
+ return removed;
24522
+ }
24523
+
24524
+ // src/lib/player/auth.ts
24525
+ var MIN_POLL_INTERVAL_MS2 = 1e3;
24526
+ var DEFAULT_PLAYER_WAIT_MS = 10 * 60 * 1e3;
24527
+ var REASSURE_EVERY_MS2 = 30 * 1e3;
24528
+ var PLAYER_NOT_APPROVED = "Nobody approved this machine, so nothing was installed. Re-run `genex player install` when you can open the link.";
24529
+ var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
24530
+ async function authorizePlayer(apiBaseUrl, options) {
24531
+ const apiUrl = normalizeApiOrigin(apiBaseUrl);
24532
+ const { log, waitMs = DEFAULT_PLAYER_WAIT_MS, open = openBrowser } = options;
24533
+ const started = await startPlayerDeviceAuth(apiUrl, options.label);
24534
+ log.plain("");
24535
+ log.step("Approve this machine for in-game model requests.");
24536
+ log.plain([
24537
+ "",
24538
+ ` ${c.cyan("\u2192")} Open: ${c.cyan(started.verifyUrl)}`,
24539
+ ` ${c.cyan("\u2192")} Code: ${c.bold(formatUserCode(started.userCode))}`,
24540
+ "",
24541
+ " The card names the Genex watcher, not a full account connection: it can",
24542
+ " answer the in-game requests you approve and spend nothing.",
24543
+ ""
24544
+ ].join("\n"));
24545
+ let warned = false;
24546
+ const warnManual = () => {
24547
+ if (warned) return;
24548
+ warned = true;
24549
+ log.dim(" (couldn't open a browser here \u2014 open the link above yourself)");
24550
+ };
24551
+ if (!open(started.verifyUrl, warnManual)) warnManual();
24552
+ const deadline = Date.now() + waitMs;
24553
+ let intervalMs = Math.max((started.interval || 2) * 1e3, MIN_POLL_INTERVAL_MS2);
24554
+ let nextReassureAt = Date.now() + REASSURE_EVERY_MS2;
24555
+ for (; ; ) {
24556
+ let res;
24557
+ try {
24558
+ res = await apiFetch(`${apiUrl}/api/cli/device/poll`, {
24559
+ method: "POST",
24560
+ headers: { "Content-Type": "application/json" },
24561
+ body: JSON.stringify({ deviceCode: started.deviceCode })
24562
+ }, { noTermsWait: true });
24563
+ } catch {
24564
+ res = new Response(null, { status: 599 });
24565
+ }
24566
+ if (res.status === 410) throw new Error("That code expired. Re-run `genex player install` for a fresh one.");
24567
+ if (res.ok) {
24568
+ const body = await res.json().catch(() => null);
24569
+ if (body?.status === "approved" && body.token) {
24570
+ if (body.scope !== void 0 && body.scope !== "player") {
24571
+ throw new Error("This Genex server minted a full account credential instead of a player one \u2014 it is too old for the watcher. Update the stand, or run `genex player install` against one that is current.");
24572
+ }
24573
+ return {
24574
+ token: bindToken(body.token, apiUrl),
24575
+ expiresAt: body.expiresAt ?? new Date(Date.now() + 30 * 24 * 36e5).toISOString()
24576
+ };
24577
+ }
24578
+ if (body?.status === "denied") throw new Error("That request was declined in the browser. Nothing was installed.");
24579
+ if (body?.interval) intervalMs = Math.max(body.interval * 1e3, MIN_POLL_INTERVAL_MS2);
24580
+ }
24581
+ if (Date.now() + intervalMs > deadline) throw new Error(PLAYER_NOT_APPROVED);
24582
+ if (Date.now() >= nextReassureAt) {
24583
+ log.dim(" still waiting for you to approve\u2026");
24584
+ nextReassureAt = Date.now() + REASSURE_EVERY_MS2;
24585
+ }
24586
+ await sleep4(intervalMs);
24587
+ }
24588
+ }
24589
+ async function startPlayerDeviceAuth(apiUrl, label) {
24590
+ const body = JSON.stringify({ ...label ? { label } : {}, scope: "player" });
24591
+ for (let attempt = 0; attempt < 2; attempt++) {
24592
+ const res = await apiFetch(`${apiUrl}/api/cli/device/start`, {
24593
+ method: "POST",
24594
+ headers: { "Content-Type": "application/json" },
24595
+ body
24596
+ }, { noTermsWait: true });
24597
+ if (res.status === 409) continue;
24598
+ if (res.status === 404 || res.status === 501) {
24599
+ throw new Error("This Genex server has no player sign-in \u2014 the watcher needs a current stand.");
24600
+ }
24601
+ if (!res.ok) throw new Error(`Couldn't start sign-in (HTTP ${res.status}). Check your connection and re-run this command.`);
24602
+ return await res.json();
24603
+ }
24604
+ throw new Error("Couldn't start sign-in \u2014 please re-run this command.");
24605
+ }
24606
+
24607
+ // src/lib/player/api.ts
24608
+ var PLAYER_VERSION_HEADER = "x-genex-player-version";
24609
+ var EXTERNAL_PROVIDER_HEADER = "X-Genex-External-Provider";
24610
+ var PlayerUpdateRequiredError = class extends Error {
24611
+ minVersion;
24612
+ latestVersion;
24613
+ constructor(minVersion, latestVersion) {
24614
+ super(`This watcher is below the server's floor (${minVersion}); the current version is ${latestVersion}.`);
24615
+ this.name = "PlayerUpdateRequiredError";
24616
+ this.minVersion = minVersion;
24617
+ this.latestVersion = latestVersion;
24618
+ }
24619
+ };
24620
+ var PlayerAuthError = class extends Error {
24621
+ status;
24622
+ code;
24623
+ constructor(status2, code) {
24624
+ super(`Genex refused this machine's credential (HTTP ${status2}${code ? `, ${code}` : ""}).`);
24625
+ this.name = "PlayerAuthError";
24626
+ this.status = status2;
24627
+ this.code = code;
24628
+ }
24629
+ };
24630
+ var PlayerApiError = class extends Error {
24631
+ status;
24632
+ code;
24633
+ constructor(status2, code) {
24634
+ super(`HTTP ${status2}${code ? ` ${code}` : ""}`);
24635
+ this.name = "PlayerApiError";
24636
+ this.status = status2;
24637
+ this.code = code;
24638
+ }
24639
+ };
24640
+ var PlayerClient = class {
24641
+ apiUrl;
24642
+ version;
24643
+ token;
24644
+ timeoutMs;
24645
+ constructor(options) {
24646
+ this.apiUrl = options.apiUrl;
24647
+ this.token = options.token;
24648
+ this.version = options.version ?? getCliVersion();
24649
+ this.timeoutMs = options.timeoutMs ?? 3e4;
24650
+ }
24651
+ async call(path44, init2 = {}, provider) {
24652
+ const headers = {
24653
+ Authorization: `Bearer ${this.token}`,
24654
+ [PLAYER_VERSION_HEADER]: this.version,
24655
+ ...provider ? { [EXTERNAL_PROVIDER_HEADER]: provider } : {},
24656
+ ...init2.body === void 0 ? {} : { "Content-Type": "application/json" }
24657
+ };
24658
+ const res = await apiFetch(`${this.apiUrl}${path44}`, {
24659
+ ...init2,
24660
+ headers,
24661
+ signal: AbortSignal.timeout(this.timeoutMs)
24662
+ }, { noTermsWait: true });
24663
+ const body = await res.json().catch(() => null);
24664
+ if (res.ok) return body ?? {};
24665
+ const code = typeof body?.error === "string" ? body.error : "";
24666
+ if (res.status === 426) {
24667
+ throw new PlayerUpdateRequiredError(
24668
+ typeof body?.minVersion === "string" ? body.minVersion : "unknown",
24669
+ typeof body?.latestVersion === "string" ? body.latestVersion : "latest"
24670
+ );
24671
+ }
24672
+ if (res.status === 401 || res.status === 403) throw new PlayerAuthError(res.status, code);
24673
+ throw new PlayerApiError(res.status, code);
24674
+ }
24675
+ /** The versioned runner policy — read on start and refreshed periodically. */
24676
+ async policy() {
24677
+ return await this.call("/api/runtime/player/policy");
24678
+ }
24679
+ /** "I am here, this is what I can run." Also how a watcher learns its id. */
24680
+ async heartbeat(input) {
24681
+ return await this.call("/api/runtime/player/watchers/heartbeat", {
24682
+ method: "POST",
24683
+ body: JSON.stringify({
24684
+ ...input.watcherId ? { watcherId: input.watcherId } : {},
24685
+ version: this.version,
24686
+ platform: input.platform,
24687
+ runners: input.runners,
24688
+ ...input.label ? { label: input.label } : {}
24689
+ })
24690
+ });
24691
+ }
24692
+ /** Everything this player has waiting on ONE funding lane. */
24693
+ async pending(provider) {
24694
+ const body = await this.call("/api/runtime/external/generations", {}, provider);
24695
+ return Array.isArray(body.generations) ? body.generations : [];
24696
+ }
24697
+ /** Take a row for a bounded lease. Idempotent for the holder; 409 otherwise.
24698
+ * Answers with the job, so a runner needs no second read. */
24699
+ async claim(id, provider, watcherId) {
24700
+ return await this.call(`/api/runtime/external/generations/${encodeURIComponent(id)}/claim`, {
24701
+ method: "POST",
24702
+ body: JSON.stringify({ watcherId })
24703
+ }, provider);
24704
+ }
24705
+ /** Hand a row back unfinished. `rate_limited` parks the budget behind it. */
24706
+ async release(id, provider, input) {
24707
+ return await this.call(`/api/runtime/external/generations/${encodeURIComponent(id)}/release`, {
24708
+ method: "POST",
24709
+ body: JSON.stringify({
24710
+ watcherId: input.watcherId,
24711
+ reason: input.reason,
24712
+ ...input.retryAfterSeconds ? { retryAfterSeconds: Math.min(86400, Math.max(1, Math.round(input.retryAfterSeconds))) } : {}
24713
+ })
24714
+ }, provider);
24715
+ }
24716
+ /** The answer. `watcherId` names the lease holder — only it may submit. */
24717
+ async submit(id, provider, watcherId, output) {
24718
+ return await this.call(`/api/runtime/external/generations/${encodeURIComponent(id)}/result`, {
24719
+ method: "POST",
24720
+ body: JSON.stringify({ output, watcherId })
24721
+ }, provider);
24722
+ }
24723
+ };
24724
+
24725
+ // src/lib/player/runners.ts
24726
+ import { spawn as spawn6, spawnSync } from "child_process";
24727
+ import fs36 from "fs/promises";
24728
+ import fsSync from "fs";
24729
+ import os9 from "os";
24730
+ import path40 from "path";
24731
+ import { randomUUID as randomUUID3 } from "crypto";
24732
+ var WATCHER_RUNNERS = ["claude", "codex"];
24733
+ var RUNNER_FOR_FAMILY = { claude: "claude", chatgpt: "codex" };
24734
+ var PROVIDER_FOR_RUNNER = { claude: "claude", codex: "chatgpt" };
24735
+ var FALLBACK_POLICY = {
24736
+ version: "fallback",
24737
+ minVersion: "0.0.0",
24738
+ latestVersion: "0.0.0",
24739
+ pollIntervalSeconds: 5,
24740
+ backoff: { initialSeconds: 5, maxSeconds: 300, factor: 2, jitterRatio: 0.2 },
24741
+ claude: { args: ["-p"], modelFlag: "--model" },
24742
+ codex: { args: ["exec"], modelFlag: "--model" }
24743
+ };
24744
+ var CLAUDE_DENIED_TOOLS = [
24745
+ "Bash",
24746
+ "Edit",
24747
+ "Write",
24748
+ "Read",
24749
+ "Glob",
24750
+ "Grep",
24751
+ "WebFetch",
24752
+ "WebSearch",
24753
+ "Task",
24754
+ "NotebookEdit",
24755
+ "TodoWrite",
24756
+ "SlashCommand",
24757
+ "KillShell",
24758
+ "BashOutput"
24759
+ ];
24760
+ var BYTES_PER_TOKEN = 4;
24761
+ var HARD_OUTPUT_BYTES = 2 * 1024 * 1024;
24762
+ var DEFAULT_TIMEOUT_MS = 10 * 6e4;
24763
+ function runnerBinaryEnv(runner) {
24764
+ return `GENEX_PLAYER_${runner.toUpperCase()}_BIN`;
24765
+ }
24766
+ function runnerBinary(runner, env = process.env, platform = process.platform) {
24767
+ const override = env[runnerBinaryEnv(runner)]?.trim();
24768
+ if (override) {
24769
+ try {
24770
+ return fsSync.statSync(override).isFile() ? override : null;
24771
+ } catch {
24772
+ return null;
24773
+ }
24774
+ }
24775
+ return resolveOnPath(runner, env, platform);
24776
+ }
24777
+ function detectRunners(env = process.env, platform = process.platform) {
24778
+ const found = [];
24779
+ for (const name of WATCHER_RUNNERS) if (runnerBinary(name, env, platform)) found.push(name);
24780
+ return found;
24781
+ }
24782
+ function resolveOnPath(name, env = process.env, platform = process.platform) {
24783
+ const win = platform === "win32";
24784
+ const dirs = (env.PATH ?? env.Path ?? "").split(win ? ";" : ":").filter(Boolean);
24785
+ const candidates = win ? [`${name}.cmd`, `${name}.exe`, `${name}.bat`, name] : [name];
24786
+ for (const dir of dirs) {
24787
+ for (const candidate of candidates) {
24788
+ const file = path40.join(dir, candidate);
24789
+ try {
24790
+ const stat = fsSync.statSync(file);
24791
+ if (!stat.isFile()) continue;
24792
+ if (!win) fsSync.accessSync(file, fsSync.constants.X_OK);
24793
+ return file;
24794
+ } catch {
24795
+ }
24796
+ }
24797
+ }
24798
+ return null;
24799
+ }
24800
+ function names(args, ...flags) {
24801
+ return args.some((a) => flags.includes(a) || flags.some((f) => a.startsWith(`${f}=`)));
24802
+ }
24803
+ function claudeArgv(job, policy = {}) {
24804
+ const base = [...policy.args ?? ["-p"]];
24805
+ const argv = [...base];
24806
+ const add = (flag, value, ...aliases) => {
24807
+ if (names(base, flag, ...aliases)) return;
24808
+ argv.push(flag);
24809
+ if (value !== void 0) argv.push(value);
24810
+ };
24811
+ if (!names(base, "-p", "--print")) argv.unshift("-p");
24812
+ if (policy.modelFlag && !names(base, policy.modelFlag)) argv.push(policy.modelFlag, job.model.cliModel);
24813
+ add("--output-format", "json");
24814
+ const structured = job.outputFormat === "json" && job.schema !== void 0;
24815
+ add("--max-turns", structured ? "2" : "1");
24816
+ add("--no-session-persistence");
24817
+ add("--permission-prompts", "none");
24818
+ add("--strict-mcp-config");
24819
+ argv.push("--append-system-prompt", job.system);
24820
+ if (structured && !names(base, "--json-schema")) {
24821
+ argv.push("--json-schema", JSON.stringify(job.schema));
24822
+ }
24823
+ if (!names(base, "--disallowedTools", "--disallowed-tools")) {
24824
+ argv.push("--disallowedTools", ...CLAUDE_DENIED_TOOLS);
24825
+ }
24826
+ return argv;
24827
+ }
24828
+ function codexArgv(job, policy = {}, files) {
24829
+ const base = [...policy.args ?? ["exec"]];
24830
+ const argv = [...base];
24831
+ const add = (flag, value) => {
24832
+ if (names(base, flag)) return;
24833
+ argv.push(flag);
24834
+ if (value !== void 0) argv.push(value);
24835
+ };
24836
+ if (!names(base, "exec") && base[0] !== "exec") argv.unshift("exec");
24837
+ add("--ephemeral");
24838
+ add("--ignore-user-config");
24839
+ add("--skip-git-repo-check");
24840
+ add("--sandbox", "read-only");
24841
+ add("-C", files.cwd);
24842
+ if (files.schemaFile && !names(base, "--output-schema")) argv.push("--output-schema", files.schemaFile);
24843
+ add("-o", files.outFile);
24844
+ add("--color", "never");
24845
+ if (policy.modelFlag && !names(base, policy.modelFlag)) argv.push(policy.modelFlag, job.model.cliModel);
24846
+ argv.push("-");
24847
+ return argv;
24848
+ }
24849
+ function classifyFailure(text) {
24850
+ const t = text.toLowerCase();
24851
+ if (/rate[ -]?limit|usage limit|quota|too many requests|429|\bresets?\b|limit reached/.test(t)) {
24852
+ const retry = parseRetrySeconds(t);
24853
+ return retry === void 0 ? { release: "rate_limited" } : { release: "rate_limited", retryAfterSeconds: retry };
24854
+ }
24855
+ if (/not logged in|please log ?in|\blogin\b|log in to|unauthorized|401|invalid api key|authentication (failed|required)|credentials/.test(t)) {
24856
+ return { release: "auth_expired" };
24857
+ }
24858
+ if (/enoent|command not found|no such file or directory|is not recognized/.test(t)) {
24859
+ return { release: "runner_unavailable" };
24860
+ }
24861
+ return { release: "failed" };
24862
+ }
24863
+ function parseRetrySeconds(text) {
24864
+ const header = /retry[ -]after:?\s*(\d+)/.exec(text);
24865
+ if (header?.[1]) return clampSeconds(Number(header[1]));
24866
+ const phrase = /(?:try again|retry|resets?|wait|available again)\D{0,20}?(\d+)\s*(second|sec|minute|min|hour|hr)s?/.exec(text);
24867
+ if (!phrase?.[1] || !phrase[2]) return void 0;
24868
+ const n = Number(phrase[1]);
24869
+ const unit = phrase[2];
24870
+ const seconds = unit.startsWith("h") ? n * 3600 : unit.startsWith("m") ? n * 60 : n;
24871
+ return clampSeconds(seconds);
24872
+ }
24873
+ function clampSeconds(value) {
24874
+ if (!Number.isFinite(value) || value <= 0) return void 0;
24875
+ return Math.min(86400, Math.max(1, Math.round(value)));
24876
+ }
24877
+ function outputByteCeiling(maxOutputTokens) {
24878
+ const fromTokens = Math.max(1024, Math.round(maxOutputTokens * BYTES_PER_TOKEN));
24879
+ return Math.min(HARD_OUTPUT_BYTES, fromTokens);
24880
+ }
24881
+ function parseClaudeEnvelope(stdout) {
24882
+ const trimmed = stdout.trim();
24883
+ if (!trimmed) return { ok: false, detail: "no_output" };
24884
+ const start = trimmed.lastIndexOf("\n{");
24885
+ const candidate = start >= 0 ? trimmed.slice(start + 1) : trimmed;
24886
+ let body;
24887
+ try {
24888
+ body = JSON.parse(candidate);
24889
+ } catch {
24890
+ return { ok: false, detail: "unparseable_envelope", text: trimmed };
24891
+ }
24892
+ const text = typeof body.result === "string" ? body.result : void 0;
24893
+ if (body.is_error === true || typeof body.subtype === "string" && body.subtype !== "success") {
24894
+ return { ok: false, detail: typeof body.subtype === "string" ? body.subtype : "is_error", text };
24895
+ }
24896
+ const structured = body.structured_output;
24897
+ return { ok: true, ...structured !== void 0 && structured !== null ? { result: structured } : {}, ...text !== void 0 ? { text } : {} };
24898
+ }
24899
+ function readOutput(value, job) {
24900
+ if (value.result !== void 0) {
24901
+ const size = Buffer.byteLength(JSON.stringify(value.result) ?? "");
24902
+ if (size > outputByteCeiling(job.maxOutputTokens)) return { ok: false, detail: "output_too_large" };
24903
+ return { ok: true, output: value.result };
24904
+ }
24905
+ const text = (value.text ?? "").trim();
24906
+ if (!text) return { ok: false, detail: "empty_output" };
24907
+ if (Buffer.byteLength(text) > outputByteCeiling(job.maxOutputTokens)) return { ok: false, detail: "output_too_large" };
24908
+ if (job.outputFormat !== "json") return { ok: true, output: text };
24909
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```$/.exec(text);
24910
+ try {
24911
+ return { ok: true, output: JSON.parse(fenced?.[1] ?? text) };
24912
+ } catch {
24913
+ return { ok: false, detail: "output_not_json" };
24914
+ }
24915
+ }
24916
+ function killRunner(child, platform = process.platform, killTreeFn) {
24917
+ const pid = child.pid;
24918
+ if (typeof pid !== "number") return;
24919
+ if (platform === "win32") {
24920
+ try {
24921
+ if (killTreeFn) killTreeFn(pid);
24922
+ else {
24923
+ const done = spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true });
24924
+ if (done.error || done.status !== 0) child.kill("SIGKILL");
24925
+ }
24926
+ } catch {
24927
+ try {
24928
+ child.kill("SIGKILL");
24929
+ } catch {
24930
+ }
24931
+ }
24932
+ return;
24933
+ }
24934
+ try {
24935
+ process.kill(-pid, "SIGKILL");
24936
+ } catch {
24937
+ }
24938
+ }
24939
+ async function runJob(job, options = {}) {
24940
+ const started = Date.now();
24941
+ const policy = options.policy ?? FALLBACK_POLICY;
24942
+ const runner = RUNNER_FOR_FAMILY[job.model.family];
24943
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
24944
+ const log = options.log ?? (() => {
24945
+ });
24946
+ const spawnFn = options.spawnFn ?? spawn6;
24947
+ const platform = options.platform ?? process.platform;
24948
+ const resolved = options.binary ?? runnerBinary(runner);
24949
+ if (!resolved) {
24950
+ return { ok: false, release: "runner_unavailable", detail: `missing:${runner}`, durationMs: Date.now() - started };
24951
+ }
24952
+ const cwd = path40.join(os9.tmpdir(), `genex-player-${randomUUID3()}`);
24953
+ await fs36.mkdir(cwd, { recursive: true, mode: 448 });
24954
+ const outFile = path40.join(cwd, "result.out");
24955
+ let argv;
24956
+ if (runner === "claude") {
24957
+ argv = claudeArgv(job, policy.claude ?? FALLBACK_POLICY.claude ?? {});
24958
+ } else {
24959
+ let schemaFile;
24960
+ if (job.outputFormat === "json" && job.schema !== void 0) {
24961
+ schemaFile = path40.join(cwd, "schema.json");
24962
+ await fs36.writeFile(schemaFile, JSON.stringify(job.schema), { mode: 384 });
24963
+ }
24964
+ argv = codexArgv(job, policy.codex ?? FALLBACK_POLICY.codex ?? {}, { cwd, outFile, ...schemaFile ? { schemaFile } : {} });
24965
+ }
24966
+ log(` ${runner} ${job.model.cliModel} (no tools)`);
24967
+ try {
24968
+ const child = spawnFn(resolved, argv, {
24969
+ cwd,
24970
+ stdio: ["pipe", "pipe", "pipe"],
24971
+ // The player's own environment, minus anything that would redirect the
24972
+ // binary off their subscription and onto a billed API account.
24973
+ env: {
24974
+ ...process.env,
24975
+ ANTHROPIC_API_KEY: void 0,
24976
+ ANTHROPIC_AUTH_TOKEN: void 0,
24977
+ ANTHROPIC_BASE_URL: void 0,
24978
+ OPENAI_API_KEY: void 0,
24979
+ OPENAI_BASE_URL: void 0
24980
+ },
24981
+ // A killable process GROUP on POSIX. On Windows it buys nothing — there
24982
+ // are no groups — and `killRunner` uses taskkill there instead.
24983
+ detached: platform !== "win32"
24984
+ });
24985
+ child.stdin?.end(job.user);
24986
+ const out = [];
24987
+ const err = [];
24988
+ child.stdout?.on("data", (d) => out.push(d.toString()));
24989
+ child.stderr?.on("data", (d) => err.push(d.toString()));
24990
+ const kill = () => killRunner(child, platform, options.killTreeFn);
24991
+ const code = await new Promise((resolve) => {
24992
+ const timer = setTimeout(() => {
24993
+ kill();
24994
+ resolve(null);
24995
+ }, timeoutMs);
24996
+ const onAbort = () => {
24997
+ kill();
24998
+ resolve("aborted");
24999
+ };
25000
+ options.signal?.addEventListener("abort", onAbort, { once: true });
25001
+ const done = (value) => {
25002
+ clearTimeout(timer);
25003
+ options.signal?.removeEventListener("abort", onAbort);
25004
+ resolve(value);
25005
+ };
25006
+ child.once("error", () => done("spawn-failed"));
25007
+ child.once("close", (c2) => done(c2));
25008
+ });
25009
+ const durationMs = Date.now() - started;
25010
+ if (code === "spawn-failed") return { ok: false, release: "runner_unavailable", detail: `missing:${runner}`, durationMs };
25011
+ if (code === "aborted") return { ok: false, release: "runner_unavailable", detail: "shutdown", exitCode: null, durationMs };
25012
+ if (code === null) return { ok: false, release: "failed", detail: "timeout", exitCode: null, durationMs };
25013
+ const stderrText = err.join("");
25014
+ if (runner === "claude") {
25015
+ const envelope = parseClaudeEnvelope(out.join(""));
25016
+ if (!envelope.ok) {
25017
+ const classified = classifyFailure(`${envelope.detail ?? ""} ${envelope.text ?? ""} ${stderrText}`);
25018
+ return { ok: false, ...classified, detail: envelope.detail ?? "failed", exitCode: code, durationMs };
25019
+ }
25020
+ const read2 = readOutput(envelope, job);
25021
+ if (!read2.ok) return { ok: false, release: "failed", detail: read2.detail, exitCode: code, durationMs };
25022
+ return { ok: true, output: read2.output, exitCode: code, durationMs };
25023
+ }
25024
+ const text = await fs36.readFile(outFile, "utf8").catch(() => "");
25025
+ if (code !== 0 && !text.trim()) {
25026
+ const classified = classifyFailure(`${stderrText} exit ${code}`);
25027
+ return { ok: false, ...classified, detail: `exit_${code}`, exitCode: code, durationMs };
25028
+ }
25029
+ const read = readOutput({ text }, job);
25030
+ if (!read.ok) return { ok: false, release: "failed", detail: read.detail, exitCode: code, durationMs };
25031
+ return { ok: true, output: read.output, exitCode: code, durationMs };
25032
+ } finally {
25033
+ await fs36.rm(cwd, { recursive: true, force: true }).catch(() => {
25034
+ });
25035
+ }
25036
+ }
25037
+
25038
+ // src/lib/player/service.ts
25039
+ import fs37 from "fs/promises";
25040
+ import os10 from "os";
25041
+ import path41 from "path";
25042
+ import { execFile } from "child_process";
25043
+ import { promisify } from "util";
25044
+ var run2 = promisify(execFile);
25045
+ var SERVICE_LABEL = "games.genex.player";
25046
+ var SYSTEMD_UNIT = "genex-player.service";
25047
+ var PLAYER_AUTOSTART_SPEC = "@genex-ai/cli-demo@latest";
25048
+ var SUPERVISED_ENV = "GENEX_PLAYER_SUPERVISED";
25049
+ function supervisedWatcher(env = process.env) {
25050
+ return env[SUPERVISED_ENV] === "1";
25051
+ }
25052
+ function servicePlatform(platform = process.platform) {
25053
+ if (platform === "darwin" || platform === "win32") return platform;
25054
+ return "linux";
25055
+ }
25056
+ function servicePaths(platform, home = os10.homedir()) {
25057
+ const { log, errorLog } = playerPaths(home);
25058
+ if (platform === "darwin") return { log, errorLog, unit: path41.join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`) };
25059
+ if (platform === "linux") return { log, errorLog, unit: path41.join(home, ".config", "systemd", "user", SYSTEMD_UNIT) };
25060
+ return { log, errorLog, unit: path41.join(home, "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "genex-player.cmd") };
25061
+ }
25062
+ function xmlEscape(value) {
25063
+ return value.replace(/[<>&'"]/g, (ch) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[ch]);
25064
+ }
25065
+ function unitFile(platform, input, paths) {
25066
+ if (platform === "darwin") {
25067
+ return `<?xml version="1.0" encoding="UTF-8"?>
25068
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
25069
+ <plist version="1.0">
25070
+ <dict>
25071
+ <key>Label</key><string>${SERVICE_LABEL}</string>
25072
+ <key>ProgramArguments</key>
25073
+ <array>
25074
+ ${[input.launcher, ...input.args].map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n")}
25075
+ </array>
25076
+ <key>EnvironmentVariables</key>
25077
+ <dict>
25078
+ <key>PATH</key><string>${xmlEscape(input.pathEnv)}</string>
25079
+ <key>HOME</key><string>${xmlEscape(input.home)}</string>
25080
+ <!-- Tells the watcher a supervisor started it, so an update is taken by
25081
+ stopping (exit 1, restarted below, resolving @latest again) rather
25082
+ than by spawning a detached grandchild: the parent's exit IS this
25083
+ job's exit, and that grandchild would have run unsupervised. -->
25084
+ <key>${SUPERVISED_ENV}</key><string>1</string>
25085
+ </dict>
25086
+ <key>RunAtLoad</key><true/>
25087
+ <!-- KeepAlive, but NOT unconditionally: exit 0 means the watcher decided to
25088
+ stop (a signal, or a credential only a person with a browser can
25089
+ replace), and restarting that every 30 seconds is an invisible loop on
25090
+ somebody else's machine. A crash comes back \u2014 and so does an update,
25091
+ which exits non-zero on purpose and is bounded by the relaunch cooldown. -->
25092
+ <key>KeepAlive</key>
25093
+ <dict><key>SuccessfulExit</key><false/></dict>
25094
+ <key>ThrottleInterval</key><integer>30</integer>
25095
+ <key>StandardOutPath</key><string>${xmlEscape(paths.log)}</string>
25096
+ <key>StandardErrorPath</key><string>${xmlEscape(paths.errorLog)}</string>
25097
+ </dict>
25098
+ </plist>
25099
+ `;
25100
+ }
25101
+ if (platform === "linux") {
25102
+ return `[Unit]
25103
+ Description=Genex player watcher \u2014 answer the in-game requests you approved, on your own subscription
25104
+ After=network-online.target
25105
+
25106
+ [Service]
25107
+ Type=simple
25108
+ ExecStart=${[input.launcher, ...input.args].map((a) => JSON.stringify(a)).join(" ")}
25109
+ Environment=PATH=${input.pathEnv}
25110
+ Environment=HOME=${input.home}
25111
+ # The supervisor marker: an update is taken by stopping and letting this unit
25112
+ # start the current version, never by spawning a child systemd is not watching.
25113
+ Environment=${SUPERVISED_ENV}=1
25114
+ # on-failure rather than always, for the reason spelled out in the plist above:
25115
+ # a clean exit is a decision (signal, dead credential) and must stay stopped; a
25116
+ # crash comes back, and so does an update, which exits non-zero on purpose.
25117
+ Restart=on-failure
25118
+ RestartSec=30
25119
+ StandardOutput=append:${paths.log}
25120
+ StandardError=append:${paths.errorLog}
25121
+
25122
+ [Install]
25123
+ WantedBy=default.target
25124
+ `;
25125
+ }
25126
+ return `@echo off\r
25127
+ set "PATH=${input.pathEnv}"\r
25128
+ start "" /b "${input.launcher}" ${input.args.map((a) => `"${a}"`).join(" ")} >> "${paths.log}" 2>> "${paths.errorLog}"\r
25129
+ `;
25130
+ }
25131
+ function autostartCommand(opts) {
25132
+ const npx = resolveOnPath("npx");
25133
+ const tail = ["player", "run", ...opts.apiUrl ? ["--api-url", opts.apiUrl] : []];
25134
+ if (npx) return { launcher: npx, args: ["-y", PLAYER_AUTOSTART_SPEC, ...tail], fallback: false };
25135
+ return { launcher: process.execPath, args: [process.argv[1] ?? "", ...tail], fallback: true };
25136
+ }
25137
+ async function installService(input, platform = servicePlatform()) {
25138
+ const paths = servicePaths(platform, input.home);
25139
+ await fs37.mkdir(path41.dirname(paths.unit), { recursive: true });
25140
+ await fs37.mkdir(path41.dirname(paths.log), { recursive: true, mode: 448 });
25141
+ await fs37.writeFile(paths.unit, unitFile(platform, input, paths), { mode: platform === "win32" ? 420 : 384 });
25142
+ if (playerDryRun()) return { unit: paths.unit, loaded: false, detail: "dry run \u2014 the unit was written, the system was not asked" };
25143
+ if (platform === "darwin") {
25144
+ const domain = `gui/${process.getuid?.() ?? 501}`;
25145
+ await run2("launchctl", ["bootout", domain, paths.unit]).catch(() => {
25146
+ });
25147
+ try {
25148
+ await run2("launchctl", ["bootstrap", domain, paths.unit]);
25149
+ } catch (error) {
25150
+ return { unit: paths.unit, loaded: false, detail: `launchctl refused it: ${String(error.message).split("\n")[0]}` };
25151
+ }
25152
+ const printed2 = await run2("launchctl", ["print", `${domain}/${SERVICE_LABEL}`]).then((r) => r.stdout).catch(() => "");
25153
+ return { unit: paths.unit, loaded: printed2.includes(SERVICE_LABEL), detail: printed2.includes("state = running") ? "running" : "registered" };
25154
+ }
25155
+ if (platform === "linux") {
25156
+ await run2("systemctl", ["--user", "daemon-reload"]).catch(() => {
25157
+ });
25158
+ try {
25159
+ await run2("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT]);
25160
+ } catch (error) {
25161
+ return { unit: paths.unit, loaded: false, detail: `systemctl refused it: ${String(error.message).split("\n")[0]}` };
25162
+ }
25163
+ const active = await run2("systemctl", ["--user", "is-active", SYSTEMD_UNIT]).then((r) => r.stdout.trim()).catch(() => "unknown");
25164
+ return { unit: paths.unit, loaded: active === "active", detail: active };
25165
+ }
25166
+ return { unit: paths.unit, loaded: true, detail: "starts at your next sign-in" };
25167
+ }
25168
+ async function serviceInstalled(home = os10.homedir(), platform = servicePlatform()) {
25169
+ const paths = servicePaths(platform, home);
25170
+ return await fs37.stat(paths.unit).then(() => paths.unit, () => null);
25171
+ }
25172
+ async function stopService(home = os10.homedir(), platform = servicePlatform()) {
25173
+ const paths = servicePaths(platform, home);
25174
+ const present = await serviceInstalled(home, platform);
25175
+ if (playerDryRun()) return { unit: paths.unit, loaded: false, detail: present ? "dry run \u2014 the system was not asked" : "nothing installed" };
25176
+ if (platform === "darwin") {
25177
+ const out2 = await run2("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, paths.unit]).then(() => "stopped").catch(() => "was not running");
25178
+ return { unit: paths.unit, loaded: false, detail: out2 };
25179
+ }
25180
+ if (platform === "linux") {
25181
+ const out2 = await run2("systemctl", ["--user", "stop", SYSTEMD_UNIT]).then(() => "stopped").catch(() => "was not running");
25182
+ return { unit: paths.unit, loaded: false, detail: out2 };
25183
+ }
25184
+ const out = await run2("taskkill", ["/IM", "genex-player.cmd", "/F"]).then(() => "stopped").catch(() => "was not running");
25185
+ return { unit: paths.unit, loaded: false, detail: out };
25186
+ }
25187
+ async function uninstallService(home = os10.homedir(), platform = servicePlatform()) {
25188
+ const paths = servicePaths(platform, home);
25189
+ const present = await serviceInstalled(home, platform);
25190
+ if (!playerDryRun()) {
25191
+ if (platform === "darwin") await run2("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, paths.unit]).catch(() => {
25192
+ });
25193
+ if (platform === "linux") {
25194
+ await run2("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT]).catch(() => {
25195
+ });
25196
+ await run2("systemctl", ["--user", "daemon-reload"]).catch(() => {
25197
+ });
25198
+ }
25199
+ }
25200
+ await fs37.rm(paths.unit, { force: true }).catch(() => {
25201
+ });
25202
+ return { unit: paths.unit, loaded: false, detail: present ? "removed" : "nothing installed" };
25203
+ }
25204
+
25205
+ // src/lib/player/relaunch.ts
25206
+ import fs38 from "fs/promises";
25207
+ import fsSync2 from "fs";
25208
+ import path42 from "path";
25209
+ import { spawn as spawn7 } from "child_process";
25210
+ var RELAUNCH_COOLDOWN_MS = 10 * 60 * 1e3;
25211
+ function relaunchPlan(input) {
25212
+ const base = autostartCommand({ ...input.apiUrl ? { apiUrl: input.apiUrl } : {} });
25213
+ if (base.fallback) {
25214
+ return { launcher: base.launcher, args: base.args };
25215
+ }
25216
+ const spec = input.version && input.version !== "latest" ? `@genex-ai/cli-demo@${input.version}` : PLAYER_AUTOSTART_SPEC;
25217
+ const sub = input.sub ?? "run";
25218
+ return {
25219
+ launcher: base.launcher,
25220
+ args: [
25221
+ "-y",
25222
+ spec,
25223
+ "player",
25224
+ sub,
25225
+ "--relaunched",
25226
+ ...input.extraArgs ?? [],
25227
+ ...input.apiUrl ? ["--api-url", input.apiUrl] : []
25228
+ ]
25229
+ };
25230
+ }
25231
+ function relaunchPossible() {
25232
+ return !autostartCommand({}).fallback;
25233
+ }
25234
+ async function relaunchOnCooldown(home, now = Date.now()) {
25235
+ try {
25236
+ const raw = await fs38.readFile(playerPaths(home).relaunch, "utf8");
25237
+ const at = Date.parse(JSON.parse(raw).at ?? "");
25238
+ return Number.isFinite(at) && now - at < RELAUNCH_COOLDOWN_MS;
25239
+ } catch {
25240
+ return false;
25241
+ }
25242
+ }
25243
+ async function markRelaunch(version, home) {
25244
+ const file = playerPaths(home).relaunch;
25245
+ try {
25246
+ await fs38.mkdir(path42.dirname(file), { recursive: true, mode: 448 });
25247
+ await fs38.writeFile(file, JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), version }) + "\n", { mode: 384 });
25248
+ } catch {
25249
+ }
25250
+ }
25251
+ async function relaunchWatcher(input) {
25252
+ const plan = relaunchPlan(input);
25253
+ const log = input.log ?? (() => {
25254
+ });
25255
+ if (input.relaunched) return { launched: false, plan, reason: "relaunched_child" };
25256
+ if (!relaunchPossible()) return { launched: false, plan, reason: "no_npx" };
25257
+ if (await relaunchOnCooldown(input.home)) return { launched: false, plan, reason: "cooldown" };
25258
+ await markRelaunch(input.version, input.home);
25259
+ if (supervisedWatcher()) {
25260
+ log(`player: stopping so the login item restarts as ${input.version} (it resolves ${PLAYER_AUTOSTART_SPEC} each start)`);
25261
+ return { launched: false, plan, reason: "supervisor_restarts" };
25262
+ }
25263
+ if (playerDryRun()) {
25264
+ log(`player: would relaunch: ${JSON.stringify([plan.launcher, ...plan.args])}`);
25265
+ return { launched: true, plan, reason: "dry_run" };
25266
+ }
25267
+ let out;
25268
+ let err;
25269
+ const paths = playerPaths(input.home);
25270
+ try {
25271
+ await fs38.mkdir(paths.logDir, { recursive: true, mode: 448 });
25272
+ out = fsSync2.openSync(paths.log, "a", 384);
25273
+ err = fsSync2.openSync(paths.errorLog, "a", 384);
25274
+ } catch {
25275
+ }
25276
+ try {
25277
+ const child = spawn7(plan.launcher, plan.args, {
25278
+ detached: true,
25279
+ stdio: ["ignore", out ?? "ignore", err ?? "ignore"]
25280
+ });
25281
+ child.on("error", (reason) => log(`player: relaunch could not start (${reason.message})`));
25282
+ child.unref();
25283
+ } finally {
25284
+ for (const fd of [out, err]) if (fd !== void 0) try {
25285
+ fsSync2.closeSync(fd);
25286
+ } catch {
25287
+ }
25288
+ }
25289
+ log(`player: relaunching as ${input.version}`);
25290
+ return { launched: true, plan };
25291
+ }
25292
+
25293
+ // src/lib/player/loop.ts
25294
+ import os11 from "os";
25295
+ var POLICY_REFRESH_MS = 5 * 6e4;
25296
+ var MAX_HEARTBEAT_MS = 6e4;
25297
+ var LEASE_RENEW_MS = 45e3;
25298
+ var JOB_TIMEOUT_MS = 10 * 6e4;
25299
+ var SHUTDOWN_RETRY_SECONDS = 5;
25300
+ var MAX_PROVIDER_COOLDOWN_MS = 60 * 6e4;
25301
+ var sleep5 = (ms) => new Promise((r) => setTimeout(r, ms));
25302
+ function platformTag(platform = process.platform) {
25303
+ return platform.toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 40) || "unknown";
25304
+ }
25305
+ function backoffMs(policy, attempt, random = Math.random) {
25306
+ const b = policy.backoff ?? FALLBACK_POLICY.backoff;
25307
+ const raw = Math.min(b.maxSeconds, b.initialSeconds * Math.pow(b.factor, Math.max(0, attempt - 1)));
25308
+ const jitter = 1 + (random() * 2 - 1) * b.jitterRatio;
25309
+ return Math.max(1e3, Math.round(raw * jitter * 1e3));
25310
+ }
25311
+ function isNewer(a, b) {
25312
+ const pa = /^(\d+)\.(\d+)\.(\d+)/.exec(a);
25313
+ const pb = /^(\d+)\.(\d+)\.(\d+)/.exec(b);
25314
+ if (!pa || !pb) return false;
25315
+ for (let i = 1; i <= 3; i++) {
25316
+ const x = Number(pa[i]);
25317
+ const y = Number(pb[i]);
25318
+ if (x !== y) return x > y;
25319
+ }
25320
+ return false;
25321
+ }
25322
+ function workable(row, watcherId, now = Date.now()) {
25323
+ if (!row.runner) return false;
25324
+ if (!row.claimedBy || row.claimedBy === watcherId) return true;
25325
+ const until = Date.parse(row.leaseExpiresAt ?? "");
25326
+ return Number.isFinite(until) && until <= now;
25327
+ }
25328
+ async function runWatcherLoop(options) {
25329
+ const { client, install: install2, log } = options;
25330
+ const runJobFn = options.runJobFn ?? runJob;
25331
+ const keepGoing = options.keepGoing ?? (() => true);
25332
+ const relaunchReady = options.relaunchReady ?? (() => true);
25333
+ const label = install2.label;
25334
+ let policy = FALLBACK_POLICY;
25335
+ let runners = detectRunners();
25336
+ let watcherId = install2.watcherId ?? "";
25337
+ let policyAt = 0;
25338
+ let heartbeatAt = 0;
25339
+ let failures = 0;
25340
+ let warnedNoRunners = false;
25341
+ let warnedStuckVersion = false;
25342
+ let stop2 = null;
25343
+ const held = { row: null };
25344
+ const cooldown = /* @__PURE__ */ new Map();
25345
+ const state = {
25346
+ v: 1,
25347
+ pid: process.pid,
25348
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
25349
+ lastHeartbeatAt: null,
25350
+ lastPollAt: null,
25351
+ lastJobAt: null,
25352
+ watcherId: watcherId || null,
25353
+ policyVersion: null,
25354
+ runners,
25355
+ jobsCompleted: 0,
25356
+ jobsReleased: 0,
25357
+ lastError: null
25358
+ };
25359
+ const shutdown = new AbortController();
25360
+ const onSignal = (signal) => {
25361
+ if (stop2) return;
25362
+ stop2 = { kind: "signal", signal };
25363
+ shutdown.abort();
25364
+ log(`player: ${signal} \u2014 handing back anything in flight`);
25365
+ };
25366
+ const sigint = () => onSignal("SIGINT");
25367
+ const sigterm = () => onSignal("SIGTERM");
25368
+ process.on("SIGINT", sigint);
25369
+ process.on("SIGTERM", sigterm);
25370
+ try {
25371
+ for (; ; ) {
25372
+ if (stop2 || !keepGoing()) break;
25373
+ const now = Date.now();
25374
+ if (now >= policyAt) {
25375
+ try {
25376
+ policy = await client.policy();
25377
+ policyAt = now + POLICY_REFRESH_MS;
25378
+ state.policyVersion = policy.version ?? null;
25379
+ runners = detectRunners();
25380
+ state.runners = runners;
25381
+ warnedNoRunners = false;
25382
+ failures = 0;
25383
+ } catch (error) {
25384
+ const fatal = classifyFatal(error);
25385
+ if (fatal) {
25386
+ stop2 = fatal;
25387
+ break;
25388
+ }
25389
+ failures += 1;
25390
+ policyAt = now + POLICY_REFRESH_MS;
25391
+ log(`player: policy unreadable (${describe(error)}); using the built-in one`);
25392
+ }
25393
+ }
25394
+ if (now >= heartbeatAt) {
25395
+ try {
25396
+ const beat = await client.heartbeat({
25397
+ ...watcherId ? { watcherId } : {},
25398
+ platform: platformTag(),
25399
+ runners,
25400
+ ...label ? { label } : {}
25401
+ });
25402
+ watcherId = beat.watcherId;
25403
+ state.watcherId = watcherId;
25404
+ state.lastHeartbeatAt = (/* @__PURE__ */ new Date()).toISOString();
25405
+ state.policyVersion = beat.policyVersion ?? state.policyVersion;
25406
+ heartbeatAt = now + Math.min(MAX_HEARTBEAT_MS, Math.max(5e3, (beat.pollIntervalSeconds || 5) * 1e3 * 6));
25407
+ failures = 0;
25408
+ if (isNewer(beat.latestVersion, client.version)) {
25409
+ if (await relaunchReady(beat.latestVersion)) {
25410
+ stop2 = { kind: "update_available", latestVersion: beat.latestVersion };
25411
+ break;
25412
+ }
25413
+ if (!warnedStuckVersion) {
25414
+ warnedStuckVersion = true;
25415
+ log(`player: ${beat.latestVersion} is current and this is ${client.version}, but nothing here can fetch it \u2014 still answering`);
25416
+ }
25417
+ }
25418
+ } catch (error) {
25419
+ const fatal = classifyFatal(error);
25420
+ if (fatal) {
25421
+ stop2 = fatal;
25422
+ break;
25423
+ }
25424
+ failures += 1;
25425
+ state.lastError = describe(error);
25426
+ heartbeatAt = now + backoffMs(policy, failures);
25427
+ log(`player: heartbeat failed (${describe(error)})`);
25428
+ await sleep5(backoffMs(policy, failures));
25429
+ continue;
25430
+ }
25431
+ }
25432
+ const providers = [...new Set(runners.map((r) => PROVIDER_FOR_RUNNER[r]))];
25433
+ if (providers.length === 0 && !warnedNoRunners) {
25434
+ warnedNoRunners = true;
25435
+ log("player: no claude or codex binary here \u2014 nothing can be answered yet");
25436
+ }
25437
+ let worked = false;
25438
+ for (const provider of providers) {
25439
+ if (stop2) break;
25440
+ const until = cooldown.get(provider) ?? 0;
25441
+ if (until > Date.now()) continue;
25442
+ if (until) cooldown.delete(provider);
25443
+ let pending;
25444
+ try {
25445
+ pending = await client.pending(provider);
25446
+ state.lastPollAt = (/* @__PURE__ */ new Date()).toISOString();
25447
+ failures = 0;
25448
+ } catch (error) {
25449
+ const fatal = classifyFatal(error);
25450
+ if (fatal) {
25451
+ stop2 = fatal;
25452
+ break;
25453
+ }
25454
+ failures += 1;
25455
+ state.lastError = describe(error);
25456
+ log(`player: poll failed (${describe(error)})`);
25457
+ await sleep5(backoffMs(policy, failures));
25458
+ continue;
25459
+ }
25460
+ for (const row of pending) {
25461
+ if (stop2) break;
25462
+ if (!workable(row, watcherId)) continue;
25463
+ if (await handleOne(row, provider)) worked = true;
25464
+ break;
25465
+ }
25466
+ }
25467
+ if (stop2) break;
25468
+ await writePlayerState(state, options.home);
25469
+ if (options.once) {
25470
+ stop2 = { kind: "once" };
25471
+ break;
25472
+ }
25473
+ const interval = options.pollIntervalMsOverride ?? Math.max(1e3, (policy.pollIntervalSeconds || 5) * 1e3);
25474
+ if (!worked) await sleep5(interval);
25475
+ }
25476
+ } finally {
25477
+ process.off("SIGINT", sigint);
25478
+ process.off("SIGTERM", sigterm);
25479
+ const stranded = held.row;
25480
+ if (stranded && watcherId) {
25481
+ await client.release(stranded.id, stranded.provider, {
25482
+ watcherId,
25483
+ reason: "runner_unavailable",
25484
+ retryAfterSeconds: SHUTDOWN_RETRY_SECONDS
25485
+ }).catch(() => {
25486
+ });
25487
+ held.row = null;
25488
+ state.jobsReleased += 1;
25489
+ log(`player: released ${stranded.id} (shutdown)`);
25490
+ }
25491
+ await writePlayerState(state, options.home);
25492
+ }
25493
+ return { stop: stop2 ?? { kind: "once" }, state };
25494
+ async function handleOne(row, provider) {
25495
+ const job = row.runner;
25496
+ let claimed;
25497
+ try {
25498
+ claimed = await client.claim(row.id, provider, watcherId);
25499
+ } catch (error) {
25500
+ if (error instanceof PlayerApiError && (error.code === "already_claimed" || error.code === "external_request_not_pending")) {
25501
+ log(`player: ${row.id} taken by another machine`);
25502
+ return false;
25503
+ }
25504
+ const fatal = classifyFatal(error);
25505
+ if (fatal) {
25506
+ stop2 = fatal;
25507
+ return false;
25508
+ }
25509
+ failures += 1;
25510
+ log(`player: could not claim ${row.id} (${describe(error)})`);
25511
+ await sleep5(backoffMs(policy, failures));
25512
+ return false;
25513
+ }
25514
+ held.row = { id: row.id, provider };
25515
+ const live = claimed.runner ?? job;
25516
+ const renew = setInterval(() => {
25517
+ void client.claim(row.id, provider, watcherId).catch(() => {
25518
+ });
25519
+ }, LEASE_RENEW_MS);
25520
+ renew.unref?.();
25521
+ log(`player: ${row.id} ${live.mode} ${live.model.cliModel} running`);
25522
+ let outcome2;
25523
+ try {
25524
+ outcome2 = await runJobFn(live, {
25525
+ policy,
25526
+ timeoutMs: Math.min(JOB_TIMEOUT_MS, Math.max(3e4, (live.leaseSeconds || 120) * 1e3 * 5)),
25527
+ signal: shutdown.signal,
25528
+ log
25529
+ });
25530
+ } finally {
25531
+ clearInterval(renew);
25532
+ }
25533
+ state.lastJobAt = (/* @__PURE__ */ new Date()).toISOString();
25534
+ const shuttingDown = stop2 !== null;
25535
+ if (!outcome2.ok || shuttingDown) {
25536
+ const reason = shuttingDown ? "runner_unavailable" : outcome2.release ?? "failed";
25537
+ const retryAfterSeconds = shuttingDown ? SHUTDOWN_RETRY_SECONDS : outcome2.retryAfterSeconds ?? (reason === "rate_limited" ? (policy.backoff ?? FALLBACK_POLICY.backoff).maxSeconds : void 0);
25538
+ state.jobsReleased += 1;
25539
+ state.lastError = reason;
25540
+ try {
25541
+ await client.release(row.id, provider, { watcherId, reason, ...retryAfterSeconds ? { retryAfterSeconds } : {} });
25542
+ held.row = null;
25543
+ } catch (error) {
25544
+ const fatal = classifyFatal(error);
25545
+ if (fatal) {
25546
+ stop2 = fatal;
25547
+ held.row = null;
25548
+ return true;
25549
+ }
25550
+ log(`player: release of ${row.id} failed (${describe(error)})`);
25551
+ }
25552
+ log(`player: ${row.id} released ${reason}${outcome2.detail ? ` (${outcome2.detail})` : ""} after ${outcome2.durationMs}ms`);
25553
+ if (reason === "rate_limited" || reason === "auth_expired") {
25554
+ const waitMs = Math.min(MAX_PROVIDER_COOLDOWN_MS, Math.max(
25555
+ 1e3,
25556
+ (retryAfterSeconds ?? (policy.backoff ?? FALLBACK_POLICY.backoff).maxSeconds) * 1e3
25557
+ ));
25558
+ cooldown.set(provider, Date.now() + waitMs);
25559
+ log(`player: ${provider} waits ${Math.round(waitMs / 1e3)}s before the next request`);
25560
+ }
25561
+ if (reason === "auth_expired") {
25562
+ const binary = live.model.family === "claude" ? "claude" : "codex";
25563
+ log(`player: your ${binary} CLI is not signed in \u2014 run \`${binary} login\` on this machine`);
25564
+ }
25565
+ return true;
25566
+ }
25567
+ try {
25568
+ const receipt = await client.submit(row.id, provider, watcherId, outcome2.output);
25569
+ held.row = null;
25570
+ state.jobsCompleted += 1;
25571
+ log(`player: ${row.id} delivered in ${outcome2.durationMs}ms (${receipt.status})`);
25572
+ } catch (error) {
25573
+ const fatal = classifyFatal(error);
25574
+ if (fatal) {
25575
+ stop2 = fatal;
25576
+ return true;
25577
+ }
25578
+ state.lastError = describe(error);
25579
+ log(`player: ${row.id} could not be submitted (${describe(error)})`);
25580
+ await client.release(row.id, provider, { watcherId, reason: "failed" }).catch(() => {
25581
+ });
25582
+ held.row = null;
25583
+ state.jobsReleased += 1;
25584
+ }
25585
+ return true;
25586
+ }
25587
+ }
25588
+ function classifyFatal(error) {
25589
+ if (error instanceof PlayerUpdateRequiredError) {
25590
+ return { kind: "update_required", minVersion: error.minVersion, latestVersion: error.latestVersion };
25591
+ }
25592
+ if (error instanceof PlayerAuthError) return { kind: "auth", status: error.status, code: error.code };
25593
+ return null;
25594
+ }
25595
+ function describe(error) {
25596
+ if (error instanceof PlayerApiError) return `${error.status}${error.code ? ` ${error.code}` : ""}`;
25597
+ if (error instanceof Error) return error.name === "TimeoutError" ? "timeout" : error.name;
25598
+ return "error";
25599
+ }
25600
+ function defaultLabel2() {
25601
+ try {
25602
+ return os11.hostname().replace(/[^\w .@-]/g, "").trim().slice(0, 64) || "This machine";
25603
+ } catch {
25604
+ return "This machine";
25605
+ }
25606
+ }
25607
+
25608
+ // src/commands/player.ts
25609
+ var SUBS4 = ["install", "run", "status", "stop", "uninstall"];
25610
+ var PLAYER_NOT_INSTALLED = "This machine is not set up to answer in-game model requests \u2014 run `genex player install` first.";
25611
+ var PLAYER_REAUTH_REQUIRED = "Genex no longer accepts this machine's approval \u2014 run `genex player install` again.";
25612
+ var PLAYER_NO_RUNNERS = "Neither `claude` nor `codex` is on this machine's PATH, so nothing can be answered yet. Install the one whose subscription you pay for, sign into it, and this watcher picks it up on its own.";
25613
+ async function runPlayer(opts = {}) {
25614
+ const log = createLogger({ quiet: opts.quiet || opts.json });
25615
+ const sub = opts.name?.trim() || "status";
25616
+ if (!SUBS4.includes(sub)) {
25617
+ fail3(opts, log, `player ${sub}`, `Unknown subcommand ${sub}. Use: ${SUBS4.join(", ")}.`);
25618
+ return;
25619
+ }
25620
+ if (opts.dryRun) process.env[PLAYER_DRY_RUN_ENV] = "1";
25621
+ if (sub === "install") return await install(opts, log);
25622
+ if (sub === "run") return await run3(opts, log);
25623
+ if (sub === "status") return await status(opts, log);
25624
+ if (sub === "stop") return await stop(opts, log);
25625
+ return await uninstall(opts, log);
25626
+ }
25627
+ async function install(opts, log) {
25628
+ const apiUrl = normalizeApiOrigin(getApiUrl(opts.apiUrl));
25629
+ const label = opts.playerLabel?.trim() || defaultLabel2();
25630
+ let approval;
25631
+ try {
25632
+ approval = await authorizePlayer(apiUrl, {
25633
+ log,
25634
+ label,
25635
+ ...opts.timeoutSec ? { waitMs: opts.timeoutSec * 1e3 } : {}
25636
+ });
25637
+ } catch (error) {
25638
+ fail3(opts, log, "player install", error instanceof Error ? error.message : String(error));
25639
+ return;
25640
+ }
25641
+ let record = {
25642
+ v: 1,
23620
25643
  apiUrl,
23621
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
23622
- });
23623
- log.dim(` saved ${c.cyan(markerPath)}`);
23624
- await writeGitignore(process.cwd(), log);
23625
- if (await writeToolsContract(process.cwd())) {
23626
- log.dim(" wrote the Genex Tools rules into AGENTS.md (managed block) + CLAUDE.md stub");
23627
- }
23628
- await healAncestorContractsAndReport(log, process.cwd());
23629
- await ensureLocalCli(log, process.cwd(), { skip: opts.noInstall });
23630
- log.plain("");
23631
- const authBaseUrl = getAuthUrl(opts.authUrl);
23632
- let token = await readUserToken(opts.envPath, apiUrl);
23633
- if (!token) {
23634
- try {
23635
- token = await authorize(apiUrl, authBaseUrl, {
23636
- envPath: opts.envPath,
23637
- log,
23638
- inlineWaitMs: opts.timeoutSec ? opts.timeoutSec * 1e3 : void 0
25644
+ token: approval.token,
25645
+ expiresAt: approval.expiresAt,
25646
+ label,
25647
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
25648
+ version: getCliVersion()
25649
+ };
25650
+ const credentialFile = await writePlayerInstall(record);
25651
+ const runners = detectRunners();
25652
+ const client = new PlayerClient({ apiUrl, token: approval.token });
25653
+ try {
25654
+ const beat = await client.heartbeat({ platform: platformTag(), runners, label });
25655
+ record = { ...record, watcherId: beat.watcherId };
25656
+ await writePlayerInstall(record);
25657
+ } catch (error) {
25658
+ if (error instanceof PlayerUpdateRequiredError) {
25659
+ const outcome2 = await relaunchWatcher({
25660
+ version: error.latestVersion,
25661
+ apiUrl,
25662
+ sub: "install",
25663
+ // The flags the refused install carried, so the newer one finishes
25664
+ // the same install rather than a default one.
25665
+ extraArgs: [
25666
+ ...opts.playerLabel ? ["--label", opts.playerLabel] : [],
25667
+ ...opts.noAutostart ? ["--no-autostart"] : []
25668
+ ],
25669
+ relaunched: Boolean(opts.relaunched),
25670
+ log: (line) => log.plain(line)
23639
25671
  });
23640
- } catch (err) {
23641
- if (err instanceof AuthPendingError) {
23642
- printAuthHandoff(log, err);
23643
- log.dim(
23644
- "Everything else is set up \u2014 `genex auth` finishes the sign-in, then you can generate."
23645
- );
25672
+ log.warn(`This Genex needs player watcher ${error.minVersion} or newer; ${error.latestVersion} is current.`);
25673
+ if (!outcome2.launched) {
25674
+ log.plain(` ${c.cyan("\u2192")} Update and re-run: ${c.cyan(`npx -y @genex-ai/cli-demo@${error.latestVersion} player install`)}`);
25675
+ fail3(opts, log, "player install", "player_update_required", { minVersion: error.minVersion, latestVersion: error.latestVersion });
23646
25676
  return;
23647
25677
  }
23648
- log.error(err instanceof Error ? err.message : String(err));
23649
- log.dim("The workspace is set up. Re-run `genex tools` to finish authorizing.");
23650
- throw markPrinted(err);
25678
+ log.plain(` ${c.cyan("\u2192")} Picked up by ${c.cyan(`@genex-ai/cli-demo@${error.latestVersion}`)} \u2014 it finishes the install.`);
25679
+ return;
23651
25680
  }
23652
- const { path: tokenPath } = await writeUserToken(token, opts.envPath, apiUrl);
23653
- log.success(`Authorized. Saved your token to ${c.cyan(tokenPath)}.`);
23654
- log.plain("");
25681
+ fail3(opts, log, "player install", error instanceof PlayerAuthError ? "Genex refused the approval this machine just collected. Re-run `genex player install`." : `Couldn't reach Genex to register this machine (${error instanceof Error ? error.message : String(error)}).`);
25682
+ return;
25683
+ }
25684
+ const platform = servicePlatform();
25685
+ const paths = servicePaths(platform);
25686
+ let unit = null;
25687
+ let unitDetail = "not installed (--no-autostart)";
25688
+ let supervised = false;
25689
+ if (!opts.noAutostart) {
25690
+ const command = autostartCommand({ ...usesNonDefaultStand(opts, apiUrl) ? { apiUrl } : {} });
25691
+ const result = await installService({
25692
+ launcher: command.launcher,
25693
+ args: command.args,
25694
+ // See lib/player/service.ts: a login agent inherits almost no PATH, and
25695
+ // this program exists to run binaries that are rarely on the default one.
25696
+ pathEnv: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
25697
+ home: os12.homedir()
25698
+ }, platform);
25699
+ unit = result.unit;
25700
+ unitDetail = result.detail;
25701
+ supervised = result.loaded;
25702
+ if (command.fallback) {
25703
+ log.warn("No `npx` on this machine's PATH, so the login entry runs THIS copy of the CLI and will not update itself.");
25704
+ }
25705
+ }
25706
+ let started = supervised;
25707
+ if (!started && !playerDryRun()) started = await spawnDetachedRun(apiUrl, opts);
25708
+ if (opts.json) {
25709
+ writeJsonLine2({
25710
+ command: "player install",
25711
+ status: "ok",
25712
+ error: null,
25713
+ ...describeInstall(record),
25714
+ runners,
25715
+ credentialFile,
25716
+ autostart: unit,
25717
+ autostartDetail: unitDetail,
25718
+ running: started,
25719
+ log: paths.log
25720
+ });
25721
+ return;
23655
25722
  }
23656
- await reportToolsInit(apiUrl, token);
23657
- await runDoctor({ apiUrl: opts.apiUrl, envPath: opts.envPath, quiet: opts.quiet });
23658
25723
  log.plain("");
23659
- log.success("Genex Tools is ready. \u{1F9F0}");
23660
- log.dim(" Assets land in ./assets/ as files you own \u2014 wire the local path into your game.");
25724
+ log.success(`This machine can now answer the in-game model requests you approve, on your own subscription.`);
25725
+ log.plain("");
25726
+ log.plain(oneParagraph({ record, runners, unit, unitDetail, started, logFile: paths.log, credentialFile }));
25727
+ if (runners.length === 0) log.warn(PLAYER_NO_RUNNERS);
23661
25728
  }
23662
-
23663
- // src/commands/budget.ts
23664
- import fs33 from "fs/promises";
23665
- import path36 from "path";
23666
- var BUDGET_SOURCE_LINE = "Live from your account; prices can change without a deploy.";
23667
- var BUDGET_APPROVAL_REQUIRED = "STOP: raising the asset allowance requires the player's explicit approval. Re-run with --user-approved only after they agreed to the number.";
23668
- function internalKind(kind) {
23669
- return kind === "agent_session" || kind === "blender_seat" || kind.startsWith("cover_");
25729
+ function oneParagraph(input) {
25730
+ const runnerText = input.runners.length === 0 ? "no runner yet" : input.runners.map((r) => r === "claude" ? "Claude Code" : "Codex").join(" and ");
25731
+ return [
25732
+ ` Genex approval stored in ${input.credentialFile} (only this machine can read it). It can`,
25733
+ " answer approved requests and spend nothing \u2014 no coins, no credits, no",
25734
+ " account changes. Your Claude or ChatGPT sign-in is never read, copied or",
25735
+ " sent anywhere; the watcher only starts the official CLI you already use.",
25736
+ ` Runners ${runnerText}`,
25737
+ input.unit ? ` Starts at login ${input.unit} (${input.unitDetail})` : " Starts at login no \u2014 run `genex player run` yourself",
25738
+ ` Running now ${input.started ? "yes" : "no \u2014 start it with `genex player run`"}`,
25739
+ ` Log ${input.logFile} (ids and timings only, never what a game asked for)`,
25740
+ " Stop it genex player stop \xB7 and for good: genex player uninstall",
25741
+ "",
25742
+ " You never need to paste anything again: approve a request in a game and the answer",
25743
+ " comes back on its own. Revoke this machine any time from your Genex dashboard."
25744
+ ].join("\n");
23670
25745
  }
23671
- async function isGenexProject(cwd) {
25746
+ async function spawnDetachedRun(apiUrl, opts) {
25747
+ let out;
25748
+ let err;
23672
25749
  try {
23673
- await fs33.access(path36.join(cwd, ".genex"));
25750
+ const paths = playerPaths();
25751
+ await fsp2.mkdir(paths.logDir, { recursive: true, mode: 448 });
25752
+ out = fs39.openSync(paths.log, "a", 384);
25753
+ err = fs39.openSync(paths.errorLog, "a", 384);
25754
+ const args = [process.argv[1] ?? "", "player", "run", ...opts.apiUrl ? ["--api-url", apiUrl] : []];
25755
+ const child = spawn8(process.execPath, args, { detached: true, stdio: ["ignore", out, err] });
25756
+ child.on("error", () => {
25757
+ });
25758
+ child.unref();
23674
25759
  return true;
23675
25760
  } catch {
23676
25761
  return false;
25762
+ } finally {
25763
+ for (const fd of [out, err]) if (fd !== void 0) try {
25764
+ fs39.closeSync(fd);
25765
+ } catch {
25766
+ }
23677
25767
  }
23678
25768
  }
23679
- function runnableKinds(prices) {
23680
- return Object.entries(prices).filter(([kind, credits]) => !internalKind(kind) && typeof credits === "number" && Number.isFinite(credits)).sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]));
23681
- }
23682
- function describeDefault(config) {
23683
- const share = config.share === 0.5 ? "half" : `${Math.round(config.share * 100)}%`;
23684
- return `default: ${share} of what was spendable at the first paid command, capped at ${config.cap}`;
23685
- }
23686
- function isoDay(iso) {
23687
- const d = new Date(iso);
23688
- return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 10);
25769
+ function usesNonDefaultStand(opts, apiUrl) {
25770
+ return apiUrl !== normalizeApiOrigin(getApiUrl()) || Boolean(opts.apiUrl);
23689
25771
  }
23690
- async function runBudget(opts = {}) {
23691
- const log = createLogger({ quiet: opts.quiet || opts.json });
23692
- const cwd = opts.cwd ?? process.cwd();
23693
- const config = budgetConfig();
23694
- const fail3 = (message) => {
23695
- if (opts.json) writeJson({ command: "budget", status: "failed", error: message });
23696
- else log.error(message);
23697
- process.exitCode = 1;
23698
- };
23699
- const setting = opts.assets !== void 0;
23700
- if (setting && opts.userApproved !== true) {
23701
- fail3(BUDGET_APPROVAL_REQUIRED);
25772
+ async function run3(opts, log) {
25773
+ const record = await readPlayerInstall();
25774
+ if (!record) {
25775
+ fail3(opts, log, "player run", PLAYER_NOT_INSTALLED);
23702
25776
  return;
23703
25777
  }
23704
- if (setting && (!Number.isInteger(opts.assets) || opts.assets < 0)) {
23705
- fail3(`--assets takes a whole number of credits, 0 or more (got ${String(opts.assets)}).`);
25778
+ if (installExpired(record)) {
25779
+ fail3(opts, log, "player run", `This machine's Genex approval expired on ${record.expiresAt} \u2014 run \`genex player install\` again.`);
23706
25780
  return;
23707
25781
  }
23708
- const meta = await readProject(cwd);
23709
- const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
23710
- const token = opts.token ?? await readUserToken(opts.envPath, apiUrl);
23711
- if (!token) {
23712
- fail3("Not authorized. Run `genex init` first to sign in.");
23713
- return;
25782
+ const apiUrl = opts.apiUrl ? normalizeApiOrigin(getApiUrl(opts.apiUrl)) : record.apiUrl;
25783
+ bindToken(record.token, apiUrl);
25784
+ const client = new PlayerClient({ apiUrl, token: record.token });
25785
+ const line = (text) => {
25786
+ process.stdout.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${text}
25787
+ `);
25788
+ };
25789
+ if (opts.foreground) {
25790
+ log.plain(c.bold("genex player run"));
25791
+ log.dim(` ${apiUrl} \xB7 watcher ${record.watcherId ?? "(unregistered)"} \xB7 v${getCliVersion()}`);
25792
+ log.dim(" Approve a request in a game and it runs here. Ctrl-C to stop.");
25793
+ }
25794
+ const result = await runWatcherLoop({
25795
+ install: record,
25796
+ client,
25797
+ ...opts.once ? { once: true } : {},
25798
+ log: line,
25799
+ // Only break off answering requests for a merely AVAILABLE update when a
25800
+ // relaunch will actually follow. A person watching a foreground run is
25801
+ // told about it instead — their terminal is not a thing to restart behind
25802
+ // their back.
25803
+ relaunchReady: async () => !opts.foreground && !opts.relaunched && relaunchPossible() && !await relaunchOnCooldown()
25804
+ });
25805
+ if (result.state.watcherId && result.state.watcherId !== record.watcherId) {
25806
+ await writePlayerInstall({ ...record, watcherId: result.state.watcherId }).catch(() => {
25807
+ });
23714
25808
  }
23715
- const snapshot = await fetchCreditsSnapshot(apiUrl, token);
23716
- let store;
23717
- let persisted = true;
23718
- if (setting) {
23719
- const previous = await readBudgetStore(cwd);
23720
- store = {
23721
- v: 1,
23722
- allowanceCredits: opts.assets,
23723
- setBy: "user",
23724
- setAt: (/* @__PURE__ */ new Date()).toISOString(),
23725
- spendableAtStart: previous?.spendableAtStart ?? snapshot?.spendable ?? 0,
23726
- balanceAtStart: previous?.balanceAtStart ?? snapshot?.balance ?? 0
23727
- };
23728
- persisted = await writeBudgetStore(cwd, store);
23729
- if (!persisted) {
23730
- fail3("This folder is not a Genex project (no .genex/), so the allowance has nowhere to live. Run this inside the game folder.");
25809
+ const stop2 = result.stop;
25810
+ if (stop2.kind === "update_required" || stop2.kind === "update_available") {
25811
+ const version = stop2.kind === "update_required" ? stop2.latestVersion : stop2.latestVersion;
25812
+ if (opts.foreground) {
25813
+ line(`player: version ${version} is current; this is ${getCliVersion()} \u2014 restart with npx -y @genex-ai/cli-demo@latest player run`);
23731
25814
  return;
23732
25815
  }
23733
- } else if (snapshot && !snapshot.unlimited && !budgetDisabled(config)) {
23734
- store = await ensureBudgetStore(cwd, {
23735
- balance: snapshot.balance,
23736
- spendable: snapshot.spendable,
23737
- config
25816
+ const outcome2 = await relaunchWatcher({
25817
+ version,
25818
+ apiUrl,
25819
+ sub: "run",
25820
+ relaunched: Boolean(opts.relaunched),
25821
+ log: line
23738
25822
  });
23739
- persisted = await readBudgetStore(cwd) !== null;
23740
- } else {
23741
- store = await readBudgetStore(cwd);
23742
- persisted = await isGenexProject(cwd);
25823
+ if (outcome2.reason === "supervisor_restarts") {
25824
+ process.exitCode = 1;
25825
+ return;
25826
+ }
25827
+ if (!outcome2.launched) line(`player: not relaunching (${outcome2.reason ?? "unknown"}) \u2014 still ${getCliVersion()}, current is ${version}`);
25828
+ return;
25829
+ }
25830
+ if (stop2.kind === "auth") {
25831
+ line(`player: ${PLAYER_REAUTH_REQUIRED}`);
25832
+ if (opts.foreground) process.exitCode = 1;
25833
+ return;
25834
+ }
25835
+ if (stop2.kind === "signal") line("player: stopped");
25836
+ }
25837
+ async function status(opts, log) {
25838
+ const record = await readPlayerInstall();
25839
+ const state = await readPlayerState();
25840
+ const runners = detectRunners();
25841
+ const platform = servicePlatform();
25842
+ const unit = await serviceInstalled(os12.homedir(), platform);
25843
+ const paths = playerPaths();
25844
+ const running = state?.pid ? pidAlive(state.pid) : null;
25845
+ const server = { reachable: false, policyVersion: null, minVersion: null, latestVersion: null, pending: null, error: null };
25846
+ if (record && !installExpired(record)) {
25847
+ const apiUrl = opts.apiUrl ? normalizeApiOrigin(getApiUrl(opts.apiUrl)) : record.apiUrl;
25848
+ bindToken(record.token, apiUrl);
25849
+ const client = new PlayerClient({ apiUrl, token: record.token, timeoutMs: 8e3 });
25850
+ try {
25851
+ const policy = await client.policy();
25852
+ server.reachable = true;
25853
+ server.policyVersion = policy.version ?? null;
25854
+ server.minVersion = policy.minVersion ?? null;
25855
+ server.latestVersion = policy.latestVersion ?? null;
25856
+ let pending = 0;
25857
+ for (const provider of new Set(runners.map((r) => PROVIDER_FOR_RUNNER[r]))) {
25858
+ pending += (await client.pending(provider)).length;
25859
+ }
25860
+ server.pending = pending;
25861
+ } catch (error) {
25862
+ server.error = error instanceof PlayerApiError ? `${error.status} ${error.code}`.trim() : error instanceof PlayerAuthError ? "credential_rejected" : error instanceof PlayerUpdateRequiredError ? "player_update_required" : "unreachable";
25863
+ }
23743
25864
  }
23744
- const spent = spentCredits(await readLedgerRows(cwd));
23745
- const disabled = budgetDisabled(config);
23746
- const enforced = !disabled && !(snapshot?.unlimited ?? false);
23747
- const allowance = store?.allowanceCredits ?? null;
23748
- const remaining = allowance === null ? null : Math.max(0, allowance - spent);
23749
25865
  if (opts.json) {
23750
- writeJson({
23751
- command: "budget",
23752
- ...snapshot ? {
23753
- balance: snapshot.balance,
23754
- reserved: snapshot.reserved,
23755
- spendable: snapshot.spendable,
23756
- unlimited: snapshot.unlimited
23757
- } : { balance: null, reserved: null, spendable: null, unlimited: null },
23758
- allowance: {
23759
- credits: allowance,
23760
- setBy: store?.setBy ?? null,
23761
- setAt: store?.setAt ?? null,
23762
- spendableAtStart: store?.spendableAtStart ?? null,
23763
- balanceAtStart: store?.balanceAtStart ?? null,
23764
- enforced,
23765
- disabled,
23766
- persisted
23767
- },
23768
- spent,
23769
- remaining,
23770
- prices: snapshot ? Object.fromEntries(runnableKinds(snapshot.prices)) : null,
23771
- source: BUDGET_SOURCE_LINE,
23772
- ...snapshot ? {} : { warning: `Balance and prices couldn't be read from ${apiUrl}.` }
25866
+ const described2 = record ? describeInstall(record) : null;
25867
+ writeJsonLine2({
25868
+ command: "player status",
25869
+ status: record ? "ok" : "not_installed",
25870
+ error: record ? null : "not_installed",
25871
+ installed: Boolean(record),
25872
+ apiUrl: described2?.apiUrl ?? null,
25873
+ watcherId: described2?.watcherId ?? null,
25874
+ label: described2?.label ?? null,
25875
+ installedAt: described2?.installedAt ?? null,
25876
+ version: described2?.version ?? null,
25877
+ cliVersion: getCliVersion(),
25878
+ credentialExpiresAt: described2?.expiresAt ?? null,
25879
+ credentialExpired: record ? installExpired(record) : null,
25880
+ runners,
25881
+ autostart: unit,
25882
+ running,
25883
+ pid: state?.pid ?? null,
25884
+ startedAt: state?.startedAt ?? null,
25885
+ lastHeartbeatAt: state?.lastHeartbeatAt ?? null,
25886
+ lastPollAt: state?.lastPollAt ?? null,
25887
+ lastJobAt: state?.lastJobAt ?? null,
25888
+ jobsCompleted: state?.jobsCompleted ?? null,
25889
+ jobsReleased: state?.jobsReleased ?? null,
25890
+ lastError: state?.lastError ?? null,
25891
+ logFile: paths.log,
25892
+ server
23773
25893
  });
23774
- if (!snapshot && !setting) process.exitCode = 1;
25894
+ if (!record) process.exitCode = 1;
23775
25895
  return;
23776
25896
  }
23777
- log.plain(c.bold("genex budget"));
23778
- if (setting && store) {
23779
- log.success(`Asset allowance set to ${store.allowanceCredits} credits (approved by the player).`);
23780
- }
23781
- if (snapshot) {
23782
- log.plain(` Balance ${snapshot.balance} credits \xB7 ${snapshot.reserved} reserved \xB7 ${snapshot.spendable} spendable`);
23783
- } else {
23784
- log.warn(` Balance couldn't be read from ${apiUrl} \u2014 run ${c.cyan("npx genex doctor")}.`);
25897
+ log.plain(c.bold("genex player status"));
25898
+ if (!record) {
25899
+ log.plain("");
25900
+ log.plain(` ${PLAYER_NOT_INSTALLED}`);
25901
+ process.exitCode = 1;
25902
+ return;
23785
25903
  }
23786
- if (disabled) {
23787
- log.plain(` Allowance off (GENEX_ASSET_BUDGET_CAP=0)`);
23788
- } else if (snapshot?.unlimited) {
23789
- log.plain(` Allowance not enforced (unlimited operator account)`);
23790
- } else if (store) {
23791
- const origin = store.setBy === "user" ? `set by you on ${isoDay(store.setAt)}` : describeDefault(config);
23792
- log.plain(` Allowance ${store.allowanceCredits} credits (${origin})`);
23793
- } else {
23794
- log.plain(` Allowance not derived yet \u2014 it is sized from the wallet at the first paid command`);
25904
+ const described = describeInstall(record);
25905
+ log.dim(` ${described.apiUrl}`);
25906
+ log.plain("");
25907
+ log.plain(` Watcher ${described.watcherId ?? "not registered yet"}${described.label ? ` \xB7 ${described.label}` : ""}`);
25908
+ log.plain(` Installed ${described.installedAt} by v${described.version} (running v${getCliVersion()})`);
25909
+ log.plain(` Approval ${installExpired(record) ? c.red("expired") : "valid"} until ${described.expiresAt}`);
25910
+ log.plain(` Runners ${runners.length ? runners.join(", ") : "none on PATH"}`);
25911
+ log.plain(` Login entry ${unit ?? "none"}`);
25912
+ log.plain(` Process ${running === null ? "never started here" : running ? `running (pid ${state?.pid})` : "not running"}`);
25913
+ if (state) {
25914
+ log.plain(` Last beat ${state.lastHeartbeatAt ?? "never"} poll ${state.lastPollAt ?? "never"} job ${state.lastJobAt ?? "never"}`);
25915
+ log.plain(` Answered ${state.jobsCompleted} \xB7 handed back ${state.jobsReleased}${state.lastError ? ` \xB7 last issue ${state.lastError}` : ""}`);
25916
+ }
25917
+ log.plain(` Server ${server.reachable ? `reachable \xB7 policy ${server.policyVersion} \xB7 needs \u2265 ${server.minVersion}, current ${server.latestVersion} \xB7 ${server.pending ?? 0} waiting` : `not reachable (${server.error ?? "unknown"})`}`);
25918
+ log.plain(` Log ${paths.log}`);
25919
+ if (installExpired(record) || server.error === "credential_rejected") process.exitCode = 1;
25920
+ }
25921
+ function pidAlive(pid) {
25922
+ try {
25923
+ process.kill(pid, 0);
25924
+ return true;
25925
+ } catch (error) {
25926
+ return error.code === "EPERM";
23795
25927
  }
23796
- log.plain(` Spent ${spent} credits this build (quoted; failed generations are refunded and excluded)`);
23797
- if (remaining !== null && enforced) {
23798
- log.plain(` Remaining ${remaining} credits`);
25928
+ }
25929
+ async function stop(opts, log) {
25930
+ const platform = servicePlatform();
25931
+ const result = await stopService(os12.homedir(), platform);
25932
+ const state = await readPlayerState();
25933
+ let signalled = false;
25934
+ if (state?.pid && pidAlive(state.pid) && state.pid !== process.pid && !playerDryRun()) {
25935
+ try {
25936
+ process.kill(state.pid, "SIGTERM");
25937
+ signalled = true;
25938
+ } catch {
25939
+ }
23799
25940
  }
23800
- if (!persisted) {
23801
- log.dim(" (not a Genex project folder \u2014 the allowance is not saved here)");
25941
+ if (opts.json) {
25942
+ writeJsonLine2({ command: "player stop", status: "ok", error: null, unit: result.unit, detail: result.detail, signalledPid: signalled ? state?.pid ?? null : null });
25943
+ return;
23802
25944
  }
23803
- if (snapshot) {
23804
- const kinds = runnableKinds(snapshot.prices);
23805
- if (kinds.length > 0) {
23806
- log.plain("");
23807
- log.plain(c.bold(" Prices (credits)"));
23808
- for (const [kind, credits] of kinds) {
23809
- log.plain(` ${kind.padEnd(20)} ${String(credits).padStart(5)}`);
23810
- }
25945
+ log.success(`Stopped: ${result.detail}${signalled ? ` (and signalled pid ${state?.pid})` : ""}`);
25946
+ log.dim(` The installation is still here \u2014 start it again with ${c.cyan("genex player run")}, or remove it with ${c.cyan("genex player uninstall")}.`);
25947
+ }
25948
+ async function uninstall(opts, log) {
25949
+ const platform = servicePlatform();
25950
+ const state = await readPlayerState();
25951
+ if (state?.pid && pidAlive(state.pid) && state.pid !== process.pid && !playerDryRun()) {
25952
+ try {
25953
+ process.kill(state.pid, "SIGTERM");
25954
+ } catch {
23811
25955
  }
23812
25956
  }
23813
- log.plain("");
23814
- log.dim(` ${BUDGET_SOURCE_LINE}`);
23815
- if (enforced && !setting) {
23816
- log.dim(` More for this game? Ask the player, then: ${c.cyan("npx genex budget --assets <credits> --user-approved")}`);
25957
+ const service = await uninstallService(os12.homedir(), platform);
25958
+ const removed = await removePlayerFiles();
25959
+ if (opts.json) {
25960
+ writeJsonLine2({ command: "player uninstall", status: "ok", error: null, unit: service.unit, detail: service.detail, removed });
25961
+ return;
23817
25962
  }
23818
- if (!snapshot && !setting) process.exitCode = 1;
25963
+ log.success(`Removed: ${service.detail} (${path43.basename(service.unit)})`);
25964
+ for (const file of removed) log.dim(` deleted ${file}`);
25965
+ if (removed.length === 0) log.dim(" no credential was stored here");
25966
+ log.plain("");
25967
+ log.plain(" This machine can no longer answer in-game requests. The approval itself can also be");
25968
+ log.plain(" revoked from your Genex dashboard, which ends it everywhere at once.");
25969
+ }
25970
+ function fail3(opts, log, command, message, extra = {}) {
25971
+ if (opts.json) writeJsonLine2({ command, status: "failed", error: message, ...extra });
25972
+ else log.error(message);
25973
+ process.exitCode = 1;
25974
+ }
25975
+ function writeJsonLine2(value) {
25976
+ process.stdout.write(`${JSON.stringify(value)}
25977
+ `);
23819
25978
  }
23820
25979
 
23821
25980
  // src/lib/costs.ts
@@ -23879,6 +26038,27 @@ ${c.bold("Usage")}
23879
26038
  the asset table against it. --assets <credits>
23880
26039
  --user-approved raises it, only once the player
23881
26040
  has agreed to the number.
26041
+ genex llm <sub> [options] In-game model calls: models | bench | price.
26042
+ "models" says whether this stand serves the
26043
+ runtime LLM lane at all, and at what provider
26044
+ rates. "bench" runs the real model on YOUR OWN
26045
+ coin and reports what each attempt was charged;
26046
+ it needs --max-coins <n> --user-approved, like
26047
+ any spend the player has to agree to. "price"
26048
+ reprints the last run's recommended
26049
+ estimateCoins. Declare a game's price from a
26050
+ bench, never from a guess: a started attempt is
26051
+ charged in full, including one that fails.
26052
+ genex player <sub> [options] Answer the in-game model requests you approve, on your
26053
+ OWN Claude or ChatGPT subscription: install | run |
26054
+ status | stop | uninstall. "install" signs this machine
26055
+ in (a player-scoped approval that can spend nothing),
26056
+ finds the official claude / codex CLI you already
26057
+ use, and starts a small background program at login.
26058
+ It never reads, stores or forwards your Claude or
26059
+ ChatGPT sign-in \u2014 it only starts the CLI you installed.
26060
+ Approve a request in a game and the answer comes back
26061
+ on its own; nothing to paste, ever again.
23882
26062
  genex auth [options] Connect this machine to your Genex account, or
23883
26063
  finish a sign-in another command started \u2014 it
23884
26064
  resumes the same code, so an interrupted
@@ -24301,6 +26481,12 @@ ${c.bold("Examples")}
24301
26481
  genex shop set sku_123 --icon https://assets.genex.technology/generations/abc123/image-main
24302
26482
  genex shop test sku_123
24303
26483
  genex shop remove sku_123
26484
+ genex llm models
26485
+ genex llm bench "Reply with one short taunt." --samples 5 --max-coins 20 --user-approved
26486
+ genex llm price
26487
+ genex player install
26488
+ genex player status --json
26489
+ genex player uninstall
24304
26490
  genex publish --no-push --title "My Game"
24305
26491
  genex model "weathered wooden barrel with iron bands"
24306
26492
  genex model --image ./concept.png
@@ -24530,6 +26716,73 @@ ${c.bold("How the allowance works")}
24530
26716
  and GENEX_ASSET_BUDGET_CAP (credits; 0 turns the budget off).
24531
26717
 
24532
26718
  Every number printed is live from your account; prices can change without a deploy.
26719
+ `,
26720
+ llm: `${c.bold("genex llm")} \u2014 in-game model calls: is the lane live, what does one call cost, what should the game declare?
26721
+
26722
+ ${c.bold("Usage")}
26723
+ genex llm models [--json] Which models this stand serves, their provider rates per
26724
+ million tokens, and the headroom it recommends over a
26725
+ benchmark. Off on this stand: it says so and exits 0 \u2014
26726
+ build the feature without a model rather than promising
26727
+ a 404.
26728
+ genex llm bench "<prompt>" --max-coins <n> --user-approved [options]
26729
+ Run the real model <n> times through the development
26730
+ lane and report what each attempt was CHARGED. Refused
26731
+ without both flags, before any network call: this spends
26732
+ COIN, a wallet the build's asset allowance cannot see.
26733
+ Needs a folder linked to a game you own.
26734
+ genex llm price [--json] The recommendation from the last bench in this folder.
26735
+ Reads a file; spends nothing.
26736
+
26737
+ ${c.bold("Bench options")}
26738
+ --model <id> One of the ids \`genex llm models\` prints (default: the first one).
26739
+ --samples <n> How many real attempts (default ${DEFAULT_BENCH_SAMPLES}). More samples, better p95.
26740
+ --schema <file> A JSON file holding the output schema; implies JSON output.
26741
+ --json-output Ask for JSON without a schema. --text Ask for text (the default).
26742
+ --max-coins <n> The per-attempt ceiling the player approved. The run's worst case is
26743
+ that number times --samples, and it is printed before anything starts.
26744
+ --timeout <seconds> Bounds the wait for each attempt.
26745
+ --json One machine-readable object; every key is always present, null when
26746
+ the CLI could not learn it.
26747
+
26748
+ ${c.bold("Why a benchmark and not an estimate")}
26749
+ \`estimateCoins\` is a PRICE, not a guess: declaring it charges it, in full, even when the
26750
+ attempt fails, is cancelled, or stops at its budget. And three layers sit between the
26751
+ provider's rate card and that number \u2014 the provider's cost, the platform's tariff (already
26752
+ inside every charged coin), and your headroom. Adding a margin to the provider's rate
26753
+ silently drops the tariff and under-prices every call, so the recommendation is built from
26754
+ CHARGED COINS and the headroom comes from the server. A stand that serves no headroom gets
26755
+ no recommendation, because the multiplier is not the CLI's to invent.
26756
+
26757
+ Results land in ${LLM_BENCH_FILE} \u2014 gitignored, machine-local, re-read by \`genex llm price\`.
26758
+ `,
26759
+ player: `${c.bold("genex player")} \u2014 answer the in-game model requests you approve, on your own Claude or ChatGPT subscription.
26760
+
26761
+ ${c.bold("Usage")}
26762
+ genex player install [--label <name>] [--no-autostart] [--dry-run] [--json]
26763
+ Sign this machine in, find the official CLI you already
26764
+ use, and start a small background program at login.
26765
+ genex player run [--once] [--foreground]
26766
+ The loop itself: poll, take one request, run it, send the
26767
+ answer back. Started for you by \`install\`; run it yourself
26768
+ with --foreground to watch it work.
26769
+ genex player status [--json] What is installed here, what the server says, and when this
26770
+ machine last spoke to it.
26771
+ genex player stop Stop the background program; keep the installation.
26772
+ genex player uninstall Stop it, remove the login entry, delete the approval.
26773
+
26774
+ ${c.bold("What it does and does not touch")}
26775
+ It NEVER reads, stores, forwards or intermediates your Claude or ChatGPT sign-in \u2014 no token,
26776
+ no keychain entry, no session file. It starts the official CLI you installed and signed into,
26777
+ hands it the request on stdin with no tools and an empty scratch directory, and reads what it
26778
+ prints. Genex never sees your vendor account.
26779
+
26780
+ Its GENEX approval is a player-scoped one: it can answer requests you already approved and can
26781
+ spend nothing \u2014 no coins, no credits, no account changes. It lives in ~/.genex/player.json,
26782
+ separate from your creator sign-in, and the dashboard can revoke it at any time.
26783
+
26784
+ The log under ~/.genex/logs/ carries ids, timings and outcomes \u2014 never what a game asked for
26785
+ and never what the model answered.
24533
26786
  `,
24534
26787
  remix: `genex remix <slug-or-Genex-page-URL> [directory] [--preview | --source-only] [--json]
24535
26788
 
@@ -24714,6 +26967,16 @@ function parseArgs(argv) {
24714
26967
  "--limit",
24715
26968
  // `genex budget --assets <credits>` — the allowance the player approved.
24716
26969
  "--assets",
26970
+ // `genex llm bench` value flags. `--max-coins` is the COIN half of the
26971
+ // same approval shape as `--assets` (a different economy, its own gate).
26972
+ "--model",
26973
+ "--samples",
26974
+ "--schema",
26975
+ "--max-coins",
26976
+ // `genex player install --label <name>` — what this machine is called in
26977
+ // the dashboard. Its own key rather than the generic 2nd positional,
26978
+ // which `player` spends on the subcommand.
26979
+ "--label",
24717
26980
  // `genex motion` value flags.
24718
26981
  "--takes",
24719
26982
  "--seeds",
@@ -24791,6 +27054,33 @@ function parseArgs(argv) {
24791
27054
  case "--user-approved":
24792
27055
  parsed.options.userApproved = true;
24793
27056
  break;
27057
+ // `genex player` switches.
27058
+ case "--no-autostart":
27059
+ parsed.options.noAutostart = true;
27060
+ break;
27061
+ case "--once":
27062
+ parsed.options.once = true;
27063
+ break;
27064
+ case "--foreground":
27065
+ parsed.options.foreground = true;
27066
+ break;
27067
+ // Set by a watcher that re-executed ITSELF on a newer version, so the
27068
+ // child cannot relaunch again and spin (lib/player/relaunch.ts).
27069
+ case "--relaunched":
27070
+ parsed.options.relaunched = true;
27071
+ break;
27072
+ case "--dry-run":
27073
+ parsed.options.dryRun = true;
27074
+ break;
27075
+ // `genex llm bench` output shape. Named `--json-output` because `--json`
27076
+ // already means "print one machine-readable object" everywhere in this
27077
+ // CLI, and the model's answer format is a different question.
27078
+ case "--json-output":
27079
+ parsed.options.jsonOutput = true;
27080
+ break;
27081
+ case "--text":
27082
+ parsed.options.textOutput = true;
27083
+ break;
24794
27084
  case "--direct-text":
24795
27085
  parsed.options.directText = true;
24796
27086
  break;
@@ -24948,6 +27238,8 @@ function parseArgs(argv) {
24948
27238
  } else {
24949
27239
  (parsed.options.selectors ??= []).push(arg);
24950
27240
  }
27241
+ } else if (parsed.command === "llm") {
27242
+ parsed.options.benchPrompt = parsed.options.benchPrompt ? `${parsed.options.benchPrompt} ${arg}` : arg;
24951
27243
  } else if (parsed.command === "shop") {
24952
27244
  if (!parsed.options.hostname) parsed.options.hostname = arg;
24953
27245
  else {
@@ -25173,6 +27465,31 @@ function applyValueFlag(options, flag, value) {
25173
27465
  options.assets = n;
25174
27466
  break;
25175
27467
  }
27468
+ case "--model":
27469
+ options.modelId = value;
27470
+ break;
27471
+ case "--label":
27472
+ options.playerLabel = value;
27473
+ break;
27474
+ case "--schema":
27475
+ options.schemaPath = value;
27476
+ break;
27477
+ case "--samples": {
27478
+ const n = Number(value);
27479
+ if (!Number.isInteger(n) || n < 1 || n > 25) {
27480
+ throw new Error(`Invalid --samples value: ${value} (expected 1-25)`);
27481
+ }
27482
+ options.samples = n;
27483
+ break;
27484
+ }
27485
+ case "--max-coins": {
27486
+ const n = Number(value);
27487
+ if (!Number.isInteger(n) || n < 1) {
27488
+ throw new Error(`Invalid --max-coins value: ${value} (whole coin, 1 or more)`);
27489
+ }
27490
+ options.maxCoins = n;
27491
+ break;
27492
+ }
25176
27493
  case "--timeout": {
25177
27494
  const n = Number(value);
25178
27495
  if (!Number.isFinite(n) || n <= 0) {
@@ -25384,9 +27701,11 @@ async function main() {
25384
27701
  return;
25385
27702
  }
25386
27703
  const isBlenderMcp = parsed.command === "blender" && parsed.options.name === "mcp";
25387
- const quietUpdates = Boolean(parsed.options.quiet || parsed.options.json || isBlenderMcp);
27704
+ const isPlayerLoop = parsed.command === "player" && (parsed.options.name ?? "status") === "run";
27705
+ const quietUpdates = Boolean(parsed.options.quiet || parsed.options.json || isBlenderMcp || isPlayerLoop);
25388
27706
  const updateLog = createLogger({ quiet: quietUpdates });
25389
- if (parsed.command !== "init" && parsed.command !== "tools" && parsed.command !== "remix") {
27707
+ const ownsItsOwnInstall = parsed.command === "init" || parsed.command === "tools" || parsed.command === "remix";
27708
+ if (!ownsItsOwnInstall && parsed.command !== "player") {
25390
27709
  await syncSkills(updateLog, workspace);
25391
27710
  }
25392
27711
  const updateCheck = startUpdateCheck();
@@ -25459,6 +27778,20 @@ async function main() {
25459
27778
  case "budget":
25460
27779
  await runBudget(parsed.options);
25461
27780
  break;
27781
+ // The in-game model lane: is it live here, what does one call really
27782
+ // cost, what should the game declare. `bench` is the only CLI command
27783
+ // that spends COIN, so it carries its own --max-coins/--user-approved
27784
+ // gate — the credits allowance cannot see that wallet.
27785
+ case "llm":
27786
+ await runLlm(parsed.options);
27787
+ break;
27788
+ // The PLAYER's own program, not a builder's: it answers the in-game
27789
+ // model requests they approved by running the official `claude` /
27790
+ // `codex` CLI they signed into themselves, and never touches a vendor
27791
+ // credential. The second positional is the subcommand.
27792
+ case "player":
27793
+ await runPlayer(parsed.options);
27794
+ break;
25462
27795
  case "auth":
25463
27796
  await runAuth(parsed.options);
25464
27797
  break;