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

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)
@@ -914,12 +916,18 @@ var TOOLS_CONTRACT_OFFER = `5. **When the game is built and playable \u2014 neve
914
916
  var TOOLS_CONTRACT_HOSTED_LAWS = `5. **This game is hosted on Genex now, and publishing is glue \u2014 never a rebuild.** The game ships exactly as the user built it: \`initEmbed()\` first in the boot code (the \`genex-threejs-embed-auth\` card has the call; the renderer draws before any \`await waitForPlayer()\`), a static build into \`dist/\` with relative asset paths, then \`npx genex preview\`. Never restructure, reformat or "improve" the game to ship it, never scaffold a new app around it, and never start a design document or a build plan for it \u2014 the user's own process is theirs. Load the \`genex-tool-publish\` card before the first preview; it owns the vocabulary, the links and the limits.
915
917
  6. **Ship first, then report.** \`preview\` prints a preflight \u2014 phone memory, a missing volume slider, an asset nobody wired, a viewport line. It never blocks a deploy, so push the build as it is, hand over the link, THEN relay each preflight line to the user in one plain sentence with an offer to fix it, and fix one only on their yes. The preflight is a report for the user, not a to-do list for you.
916
918
  7. **The link is the game's page, and there are two versions.** After every preview give the user \`<dashboard>/draft/<slug>\` \u2014 \`dashboardOrigins[0]\` and \`slug\` from \`.genex/project.json\` \u2014 never localhost, a file path or the bare play origin. \`preview\` updates the draft and never touches what players are on; the first release is \`npx genex publish\` (it lists the game); after that "publish it", "update it" and "yes" all mean \`npx genex promote\` \u2014 the exact draft build, no rebuild. Ask once per round of work, in one line, and keep working while you wait.`;
919
+ var TOOLS_CONTRACT_LLM_OFFER = `A model running while people play is built into the Genex platform \u2014 the player pays, with Genex coins or their own Claude/ChatGPT subscription, and approves it on a Genex sheet; your game just calls \`generate()\`. Want it that way?`;
920
+ function toolsContractLlmLaw(n, hosted) {
921
+ const onYes = hosted ? "On a yes, load `$genex-tool-llm` and then `$genex-llm-in-games`, which owns the build." : "On a yes, load `$genex-tool-llm`, which owns the rest: it checks the lane with `npx genex llm models` first, then runs `npx genex init --convert` (a hosted game is a static browser build \u2014 a local server holding a key can never ship).";
922
+ return `${n}. **A model running while people PLAY is a platform feature \u2014 never something you wire onto the user's own meter.** When a request implies one (NPCs that talk or decide in their own words, content written from what the player types, a prompt box in the game, "let the player pick a model"), recognise it, say in ONE line: "${TOOLS_CONTRACT_LLM_OFFER}" \u2014 then ASK and wait. ${onYes} On a no, build the authored version instead. Either way, never ship a game that calls a model on a key or an account of the user's own: every visitor would spend their money with nobody approving it, and the credential is readable in the bundle. Player-funded generation is text and JSON only \u2014 3D, images, video and audio stay the asset lanes above, on the user's meter.`;
923
+ }
917
924
  function toolsContractBlock(opts) {
918
925
  return `${CONTRACT_BEGIN}
919
926
  ${TOOLS_CONTRACT_HEAD}${opts.hosted ? " This game is also hosted on Genex: `preview` \xB7 `promote` \xB7 `publish` \xB7 `rollback` \xB7 `rename` work here (the `genex-tool-publish` card owns them)." : ""}
920
927
 
921
928
  ${TOOLS_CONTRACT_LAWS_1_TO_4}
922
929
  ${opts.hosted ? TOOLS_CONTRACT_HOSTED_LAWS : TOOLS_CONTRACT_OFFER}
930
+ ${toolsContractLlmLaw(opts.hosted ? 8 : 6, opts.hosted)}
923
931
  ${CONTRACT_END}
924
932
  `;
925
933
  }
@@ -1001,7 +1009,7 @@ async function writeToolsContract(projectDir, opts = {}) {
1001
1009
  }
1002
1010
  async function healAncestorContracts(projectDir, stopDir = os3.homedir()) {
1003
1011
  const findings = [];
1004
- const stop = path5.resolve(stopDir);
1012
+ const stop2 = path5.resolve(stopDir);
1005
1013
  let dir = path5.dirname(path5.resolve(projectDir));
1006
1014
  for (let depth = 0; depth < 20; depth++) {
1007
1015
  try {
@@ -1043,7 +1051,7 @@ async function healAncestorContracts(projectDir, stopDir = os3.homedir()) {
1043
1051
  }
1044
1052
  } catch {
1045
1053
  }
1046
- if (dir === stop) break;
1054
+ if (dir === stop2) break;
1047
1055
  const parent = path5.dirname(dir);
1048
1056
  if (parent === dir) break;
1049
1057
  dir = parent;
@@ -1225,13 +1233,18 @@ async function hasGenexSkills(skillsDir) {
1225
1233
  }
1226
1234
  }
1227
1235
  var TOOL_SKILL_PREFIX = "genex-tool-";
1228
- var HOSTED_TOOL_SKILLS = ["genex-tool-publish", "genex-threejs-embed-auth"];
1236
+ var HOSTED_TOOL_SKILLS = [
1237
+ "genex-tool-publish",
1238
+ "genex-threejs-embed-auth",
1239
+ "genex-llm-in-games"
1240
+ ];
1229
1241
  var REMIX_EXCLUDED_SKILLS = [
1230
1242
  "genex-game-director",
1231
1243
  "genex-getting-started",
1232
1244
  "genex-tool-audio",
1233
1245
  "genex-tool-character",
1234
1246
  "genex-tool-image",
1247
+ "genex-tool-llm",
1235
1248
  "genex-tool-model",
1236
1249
  "genex-tool-publish",
1237
1250
  "genex-tool-texture",
@@ -1485,8 +1498,8 @@ async function installedPackageVersion(cwd, name) {
1485
1498
  return null;
1486
1499
  }
1487
1500
  }
1488
- function formatNudge(label, latest, installed, install) {
1489
- return `\u2B06 Genex ${label} ${latest} available (installed ${installed}) \u2014 run: ${install}`;
1501
+ function formatNudge(label, latest, installed, install2) {
1502
+ return `\u2B06 Genex ${label} ${latest} available (installed ${installed}) \u2014 run: ${install2}`;
1490
1503
  }
1491
1504
  async function reportUpdateNudges(check, log, cwd = process.cwd(), cachePath = getUpdateCachePath()) {
1492
1505
  try {
@@ -1704,8 +1717,8 @@ async function ensureLocalCli(log, cwd = process.cwd(), opts = {}) {
1704
1717
  const spec = `${CLI_PACKAGE}@${CLI_CHANNEL}`;
1705
1718
  const { command, args } = installArgs(pm, spec);
1706
1719
  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);
1720
+ const run4 = opts.runner ?? spawnInstall;
1721
+ const result = await run4(command, args, cwd);
1709
1722
  if (result.ok) {
1710
1723
  log.success(`${c.cyan("npx genex")} now resolves in this folder.`);
1711
1724
  } else {
@@ -1746,10 +1759,10 @@ function sourceCrc32(bytes, previous = 0) {
1746
1759
  var RemixError = class extends Error {
1747
1760
  code;
1748
1761
  status;
1749
- constructor(code, message, status) {
1762
+ constructor(code, message, status2) {
1750
1763
  super(message);
1751
1764
  this.code = code;
1752
- this.status = status;
1765
+ this.status = status2;
1753
1766
  }
1754
1767
  };
1755
1768
  var badArchive = (message) => {
@@ -1926,8 +1939,8 @@ function draftPageUrl(dashboard, slug) {
1926
1939
  function publicPageUrl(dashboard, slug) {
1927
1940
  return `${dashboard.replace(/\/+$/, "")}/${slug}`;
1928
1941
  }
1929
- function gamePageUrl(dashboard, slug, status) {
1930
- return status === "published" ? publicPageUrl(dashboard, slug) : draftPageUrl(dashboard, slug);
1942
+ function gamePageUrl(dashboard, slug, status2) {
1943
+ return status2 === "published" ? publicPageUrl(dashboard, slug) : draftPageUrl(dashboard, slug);
1931
1944
  }
1932
1945
 
1933
1946
  // src/lib/deploy.ts
@@ -2005,10 +2018,10 @@ async function readLedger(cwd = process.cwd()) {
2005
2018
  }
2006
2019
  return [...entries.values()];
2007
2020
  }
2008
- async function recordTerminal(id, status, urls, cwd = process.cwd()) {
2021
+ async function recordTerminal(id, status2, urls, cwd = process.cwd()) {
2009
2022
  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 } : {} });
2023
+ if (existing && existing.status === status2) return;
2024
+ await append(cwd, { t: "done", id, status: status2, ...urls?.length ? { urls } : {} });
2012
2025
  }
2013
2026
  async function recordShipped(commit, cwd = process.cwd()) {
2014
2027
  await append(cwd, { t: "ship", at: (/* @__PURE__ */ new Date()).toISOString(), ...commit ? { commit } : {} });
@@ -2035,13 +2048,13 @@ async function countFailed(kind, cwd = process.cwd()) {
2035
2048
  }
2036
2049
  async function trailingFailures(kind, cwd = process.cwd()) {
2037
2050
  const rows = (await readLedger(cwd)).filter((e) => e.kind === kind);
2038
- let run2 = 0;
2051
+ let run4 = 0;
2039
2052
  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;
2053
+ const status2 = rows[i].status;
2054
+ if (status2 === "completed") break;
2055
+ if (status2 === "failed") run4 += 1;
2043
2056
  }
2044
- return run2;
2057
+ return run4;
2045
2058
  }
2046
2059
  async function countOutcomes(kind, cwd = process.cwd()) {
2047
2060
  const rows = (await readLedger(cwd)).filter((e) => e.kind === kind);
@@ -2578,25 +2591,25 @@ function embedBootNudges(log, d) {
2578
2591
  }
2579
2592
  }
2580
2593
  function generationNudges(log, g) {
2581
- const describe = (refs) => refs.map((r) => `${r.kind} ${r.id} ("${r.prompt.slice(0, 48)}")`).join("; ");
2594
+ const describe2 = (refs) => refs.map((r) => `${r.kind} ${r.id} ("${r.prompt.slice(0, 48)}")`).join("; ");
2582
2595
  if (g.unpicked.length) {
2583
2596
  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".`
2597
+ `${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
2598
  );
2586
2599
  }
2587
2600
  if (g.unresolved.length) {
2588
2601
  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.`
2602
+ `${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
2603
  );
2591
2604
  }
2592
2605
  if (g.deadWired.length) {
2593
2606
  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.`
2607
+ `${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
2608
  );
2596
2609
  }
2597
2610
  if (g.unwired.length) {
2598
2611
  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.`
2612
+ `${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
2613
  );
2601
2614
  }
2602
2615
  if (g.musicNoSlider) {
@@ -2760,14 +2773,14 @@ function classify(cells) {
2760
2773
  const name = cells[0] ?? "";
2761
2774
  if (/^<.*>$/.test(name)) return null;
2762
2775
  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 };
2776
+ const status2 = (STATUS.exec(rest) ?? [""])[0].toLowerCase();
2777
+ if (PROCEDURAL_ROW.test(rest)) return { name, detail: rest, lane: null, status: status2, procedural: true };
2765
2778
  const token = new RegExp(LANE_TOKEN.source).exec(rest)?.[1];
2766
2779
  const fromSkill = token ? LANE_OF_SKILL[token] : void 0;
2767
- if (fromSkill) return { name, detail: rest, lane: fromSkill, status, procedural: false };
2780
+ if (fromSkill) return { name, detail: rest, lane: fromSkill, status: status2, procedural: false };
2768
2781
  if (!PAID_ROW.test(rest) && !/https?:\/\//.test(rest) && !/generating\s*\(/i.test(rest)) return null;
2769
2782
  const lane = LANE_WORDS.find(([re]) => re.test(rest))?.[1] ?? null;
2770
- return { name, detail: rest, lane, status, procedural: false };
2783
+ return { name, detail: rest, lane, status: status2, procedural: false };
2771
2784
  }
2772
2785
  function auditGenerationPlan(input) {
2773
2786
  const { design, ledger } = input;
@@ -2836,8 +2849,8 @@ function auditGenerationPlan(input) {
2836
2849
  if (lanesRun.has(lane) || lanesPending.has(lane) || owed.has(lane) || lane === "skybox") continue;
2837
2850
  if (!rows.some((r) => r.lane === lane)) owed.set(lane, ["declared as this subsystem's lane"]);
2838
2851
  }
2839
- for (const [lane, names] of owed) {
2840
- const shown = names.slice(0, 3).join(", ") + (names.length > 3 ? `, +${names.length - 3} more` : "");
2852
+ for (const [lane, names2] of owed) {
2853
+ const shown = names2.slice(0, 3).join(", ") + (names2.length > 3 ? `, +${names2.length - 3} more` : "");
2841
2854
  warnings.push(
2842
2855
  `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
2856
  );
@@ -3487,8 +3500,8 @@ function requireOpt(value, flag, sub) {
3487
3500
  async function uiExtract(opts, log) {
3488
3501
  const input = requireOpt(opts.input, "--in", "extract");
3489
3502
  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).");
3503
+ const names2 = (opts.names ?? "").split(",").map((s) => s.trim()).filter(Boolean);
3504
+ if (names2.length === 0) fail("Missing --names (comma-separated element names, reading order).");
3492
3505
  const minPixels = opts.minPixels ?? 2e3;
3493
3506
  const padding = opts.padding ?? 8;
3494
3507
  const chroma = opts.chroma ?? 240;
@@ -3497,7 +3510,7 @@ async function uiExtract(opts, log) {
3497
3510
  const { width: W, height: H, data } = sheet;
3498
3511
  await fs16.mkdir(outDir, { recursive: true });
3499
3512
  log.plain(c.bold("genex ui extract"));
3500
- log.dim(` ${input} (${W}x${H}) \u2192 ${outDir}, ${names.length} names`);
3513
+ log.dim(` ${input} (${W}x${H}) \u2192 ${outDir}, ${names2.length} names`);
3501
3514
  let hasTransparency = false;
3502
3515
  for (let i = 0; i < W * H; i++) {
3503
3516
  if (data[i * 4 + 3] < 250) {
@@ -3533,32 +3546,32 @@ async function uiExtract(opts, log) {
3533
3546
  if (dilatePx > 0) {
3534
3547
  const tmp = new Uint8Array(W * H);
3535
3548
  for (let y = 0; y < H; y++) {
3536
- let run2 = 0;
3549
+ let run4 = 0;
3537
3550
  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;
3551
+ if (subject[y * W + x]) run4 = dilatePx;
3552
+ else if (run4 > 0) run4--;
3553
+ tmp[y * W + x] = run4 > 0 || subject[y * W + x] ? 1 : 0;
3541
3554
  }
3542
- run2 = 0;
3555
+ run4 = 0;
3543
3556
  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;
3557
+ if (subject[y * W + x]) run4 = dilatePx;
3558
+ else if (run4 > 0) run4--;
3559
+ if (run4 > 0) tmp[y * W + x] = 1;
3547
3560
  }
3548
3561
  }
3549
3562
  const tmp2 = new Uint8Array(W * H);
3550
3563
  for (let x = 0; x < W; x++) {
3551
- let run2 = 0;
3564
+ let run4 = 0;
3552
3565
  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;
3566
+ if (tmp[y * W + x]) run4 = dilatePx;
3567
+ else if (run4 > 0) run4--;
3568
+ tmp2[y * W + x] = run4 > 0 || tmp[y * W + x] ? 1 : 0;
3556
3569
  }
3557
- run2 = 0;
3570
+ run4 = 0;
3558
3571
  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;
3572
+ if (tmp[y * W + x]) run4 = dilatePx;
3573
+ else if (run4 > 0) run4--;
3574
+ if (run4 > 0) tmp2[y * W + x] = 1;
3562
3575
  }
3563
3576
  }
3564
3577
  connect = tmp2;
@@ -3604,9 +3617,9 @@ async function uiExtract(opts, log) {
3604
3617
  }
3605
3618
  );
3606
3619
  }
3607
- if (big.length < names.length) {
3620
+ if (big.length < names2.length) {
3608
3621
  log.warn(
3609
- `Only ${big.length} components \u2265 ${minPixels}px; expected ${names.length}. Lower --min-pixels or check the sheet.`
3622
+ `Only ${big.length} components \u2265 ${minPixels}px; expected ${names2.length}. Lower --min-pixels or check the sheet.`
3610
3623
  );
3611
3624
  }
3612
3625
  for (const comp of big) {
@@ -3648,7 +3661,7 @@ async function uiExtract(opts, log) {
3648
3661
  band.items.sort((a, b) => a.cx - b.cx);
3649
3662
  sorted.push(...band.items);
3650
3663
  }
3651
- const nameOf = (i) => i < names.length ? names[i] : `extra-${i - names.length}`;
3664
+ const nameOf = (i) => i < names2.length ? names2[i] : `extra-${i - names2.length}`;
3652
3665
  for (let i = 0; i < sorted.length; i++) {
3653
3666
  const comp = sorted[i];
3654
3667
  const x0 = Math.max(0, comp.minX - padding);
@@ -3678,7 +3691,7 @@ async function uiExtract(opts, log) {
3678
3691
  const extractions = [];
3679
3692
  for (let i = 0; i < sorted.length; i++) {
3680
3693
  const comp = sorted[i];
3681
- const name = i < names.length ? names[i] : `extra-${i - names.length}`;
3694
+ const name = i < names2.length ? names2[i] : `extra-${i - names2.length}`;
3682
3695
  const padX0 = Math.max(0, comp.minX - padding);
3683
3696
  const padY0 = Math.max(0, comp.minY - padding);
3684
3697
  const padX1 = Math.min(W, comp.maxX + padding + 1);
@@ -3775,7 +3788,7 @@ async function uiExtract(opts, log) {
3775
3788
  2
3776
3789
  )
3777
3790
  );
3778
- const missing = names.filter((n) => !extractions.some((e) => e.name === n));
3791
+ const missing = names2.filter((n) => !extractions.some((e) => e.name === n));
3779
3792
  log.plain("");
3780
3793
  log.success(`Extracted ${extractions.length} elements \u2192 ${outDir} (+ .bbox.json sidecars).`);
3781
3794
  if (missing.length > 0) {
@@ -4105,13 +4118,13 @@ function autoDetectPairs(image, opts, log) {
4105
4118
  lastCy = cell.cy;
4106
4119
  }
4107
4120
  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) {
4121
+ const names2 = (opts.names ?? "").split(",").map((s) => s.trim()).filter(Boolean);
4122
+ if (names2.length > 0 && names2.length !== annotatedCells.length) {
4110
4123
  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.`
4124
+ `--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
4125
  );
4113
4126
  }
4114
- for (const name of names) {
4127
+ for (const name of names2) {
4115
4128
  if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) fail("Invalid pair name.", { name });
4116
4129
  }
4117
4130
  const scoreTwin = (a, c2) => {
@@ -4187,7 +4200,7 @@ function autoDetectPairs(image, opts, log) {
4187
4200
  h: boxH
4188
4201
  });
4189
4202
  out.push({
4190
- name: names[i] ?? `meter-${i + 1}`,
4203
+ name: names2[i] ?? `meter-${i + 1}`,
4191
4204
  cleanBox: place(best.cell.minX, best.cell.minY, cw, ch),
4192
4205
  annotatedBox: place(ann.minX, ann.minY, aw, ah),
4193
4206
  // D6: --auto used to leave the component count ungated (null). --expect N
@@ -5688,9 +5701,9 @@ import crypto3 from "crypto";
5688
5701
  async function createDraftProject(opts) {
5689
5702
  const { apiUrl, token, dashboardUrl, log } = opts;
5690
5703
  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];
5704
+ const names2 = [opts.name, `${opts.name}-${randomSuffix()}`];
5705
+ for (let i = 0; i < names2.length; i++) {
5706
+ const name = names2[i];
5694
5707
  let res;
5695
5708
  try {
5696
5709
  res = await apiFetch(`${apiUrl}/api/projects`, {
@@ -7287,9 +7300,9 @@ async function runDomain(opts) {
7287
7300
  return;
7288
7301
  }
7289
7302
  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.`);
7303
+ const status2 = String(data.status ?? "");
7304
+ if (status2 === "active") log.success(`${hostname} is live.`);
7305
+ else if (status2 === "failed") log.error(`${hostname} is stopped \u2014 its verification record is missing.`);
7293
7306
  else log.plain(`${hostname} isn't verified yet \u2014 DNS changes can take a few minutes to spread.`);
7294
7307
  return;
7295
7308
  }
@@ -7352,7 +7365,7 @@ async function runShop(opts) {
7352
7365
  const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
7353
7366
  const call = (url, init2) => apiFetch(url, { ...init2, headers: { ...auth, ...init2?.headers ?? {} } });
7354
7367
  const projectSkus = `${apiUrl}/api/coin/projects/${encodeURIComponent(meta.id)}/skus`;
7355
- async function fail3(res2, what) {
7368
+ async function fail4(res2, what) {
7356
7369
  if (printedStructuredError(res2)) {
7357
7370
  process.exitCode = 1;
7358
7371
  return;
@@ -7373,7 +7386,7 @@ async function runShop(opts) {
7373
7386
  }
7374
7387
  if (sub === "list") {
7375
7388
  const res2 = await call(projectSkus);
7376
- if (!res2.ok) return fail3(res2, "read your shop");
7389
+ if (!res2.ok) return fail4(res2, "read your shop");
7377
7390
  const body = await res2.json();
7378
7391
  if (opts.json) {
7379
7392
  log.plain(JSON.stringify(body, null, 2));
@@ -7418,7 +7431,7 @@ async function runShop(opts) {
7418
7431
  ...opts.resellable !== void 0 ? { resellable: opts.resellable } : {}
7419
7432
  })
7420
7433
  });
7421
- if (!res2.ok) return fail3(res2, "add that item");
7434
+ if (!res2.ok) return fail4(res2, "add that item");
7422
7435
  const sku = await res2.json();
7423
7436
  if (opts.json) {
7424
7437
  log.plain(JSON.stringify(sku, null, 2));
@@ -7439,7 +7452,7 @@ async function runShop(opts) {
7439
7452
  const skuUrl = `${apiUrl}/api/coin/skus/${encodeURIComponent(skuId)}`;
7440
7453
  if (sub === "test") {
7441
7454
  const res2 = await call(`${skuUrl}/test-grant`, { method: "POST" });
7442
- if (!res2.ok) return fail3(res2, "grant yourself a test copy");
7455
+ if (!res2.ok) return fail4(res2, "grant yourself a test copy");
7443
7456
  const got = await res2.json();
7444
7457
  log.success(`You now own ${c.cyan(got.name)} in this game.`);
7445
7458
  log.dim(" It cost nothing and no sale was recorded \u2014 it is a test copy.");
@@ -7462,14 +7475,14 @@ async function runShop(opts) {
7462
7475
  return;
7463
7476
  }
7464
7477
  const res2 = await call(skuUrl, { method: "PATCH", body: JSON.stringify(patch) });
7465
- if (!res2.ok) return fail3(res2, "update that item");
7478
+ if (!res2.ok) return fail4(res2, "update that item");
7466
7479
  const sku = await res2.json();
7467
7480
  log.success(`Updated ${c.cyan(sku.name)} \u2014 ${sku.priceCoins} coin.`);
7468
7481
  if (opts.resellable === false) log.dim(" Listings already on the market stay up.");
7469
7482
  return;
7470
7483
  }
7471
7484
  const res = await call(skuUrl, { method: "DELETE" });
7472
- if (!res.ok) return fail3(res, "remove that item");
7485
+ if (!res.ok) return fail4(res, "remove that item");
7473
7486
  log.success("Removed from the shop.");
7474
7487
  log.dim(" Players who already bought it keep it.");
7475
7488
  }
@@ -8475,6 +8488,7 @@ function tokenize(cmd) {
8475
8488
  function openUrl(url) {
8476
8489
  let command;
8477
8490
  let args;
8491
+ if (process.env.GENEX_NO_BROWSER === "1") return;
8478
8492
  const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
8479
8493
  const customParts = custom ? tokenize(custom) : [];
8480
8494
  if (customParts.length > 0) {
@@ -9297,10 +9311,10 @@ function reportLocalFiles(result, log, json) {
9297
9311
  }
9298
9312
  }
9299
9313
  function laneFailureAdvice(kind, outcomes) {
9300
- const run2 = outcomes.trailingFailed ?? (outcomes.completed > 0 ? 0 : outcomes.failed);
9301
- if (run2 < 2) return null;
9314
+ const run4 = outcomes.trailingFailed ?? (outcomes.completed > 0 ? 0 : outcomes.failed);
9315
+ if (run4 < 2) return null;
9302
9316
  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.`;
9317
+ 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
9318
  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
9319
  }
9306
9320
  function videoFailureAdvice(failures) {
@@ -9610,7 +9624,7 @@ function reportCharacterPreviewTerminal(view, log, opts = {}) {
9610
9624
  const intent = requestedBuildOf(view);
9611
9625
  const nextCommand = characterFinalizeCommand(view.id, intent);
9612
9626
  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.`;
9627
+ 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
9628
  if (opts.open) openUrl(views.front);
9615
9629
  if (opts.json) {
9616
9630
  writeJson({
@@ -9639,7 +9653,7 @@ function reportCharacterPreviewTerminal(view, log, opts = {}) {
9639
9653
  if (sourceUrl) log.plain(` High-detail source: ${sourceUrl}`);
9640
9654
  log.plain("");
9641
9655
  log.plain(` ${c.bold("\u{1F441} Show these to the user")} \u2014 include all four views.`);
9642
- log.plain(` ${c.bold(stop)}`);
9656
+ log.plain(` ${c.bold(stop2)}`);
9643
9657
  log.plain(` After approval: ${c.cyan(nextCommand)}`);
9644
9658
  log.plain("");
9645
9659
  }
@@ -19416,8 +19430,8 @@ async function runAnims(opts) {
19416
19430
  `${parts.join(", ")} \u2192 ${c.cyan(ANIMS_DEST + path28.sep)}${addedBytes > 0 ? ` (+${formatMb(addedBytes)})` : ""}`
19417
19431
  );
19418
19432
  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(", ")}`);
19433
+ const names2 = entries.filter((e) => !coreNames.has(e.name)).map((e) => e.name);
19434
+ if (names2.length > 0) log.dim(` ${selector}: ${names2.join(", ")}`);
19421
19435
  }
19422
19436
  log.info(
19423
19437
  `Wiring: load the ${c.cyan("genex-threejs-character-controller")} skill \u2192 "Animation packs" (loadCharacterClips picks these up automatically).`
@@ -20829,22 +20843,22 @@ async function context2(opts) {
20829
20843
  if (!token) return null;
20830
20844
  return { token, apiUrl };
20831
20845
  }
20832
- async function readVideo(path37, log) {
20846
+ async function readVideo(path44, log) {
20833
20847
  let bytes;
20834
20848
  try {
20835
- bytes = await readFile(path37);
20849
+ bytes = await readFile(path44);
20836
20850
  } catch {
20837
- log.error(`Can't read ${path37}.`);
20851
+ log.error(`Can't read ${path44}.`);
20838
20852
  return null;
20839
20853
  }
20840
20854
  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.`);
20855
+ log.error(`${basename(path44)} is ${(bytes.byteLength / 1e6).toFixed(0)} MB \u2014 the limit is ${MAX_VIDEO_BYTES / 1e6} MB.`);
20842
20856
  return null;
20843
20857
  }
20844
20858
  return bytes;
20845
20859
  }
20846
- async function uploadVideo(apiUrl, token, characterId, path37, bytes, log) {
20847
- const contentType = /\.mov$/i.test(path37) ? "video/quicktime" : "video/mp4";
20860
+ async function uploadVideo(apiUrl, token, characterId, path44, bytes, log) {
20861
+ const contentType = /\.mov$/i.test(path44) ? "video/quicktime" : "video/mp4";
20848
20862
  const minted = await apiFetch(
20849
20863
  `${apiUrl}/api/characters/${encodeURIComponent(characterId)}/motions/video-url`,
20850
20864
  {
@@ -20859,7 +20873,7 @@ async function uploadVideo(apiUrl, token, characterId, path37, bytes, log) {
20859
20873
  return null;
20860
20874
  }
20861
20875
  const { uploadUrl, videoUrl } = await minted.json();
20862
- log.dim(` uploading ${basename(path37)} (${(bytes.byteLength / 1e6).toFixed(1)} MB)\u2026`);
20876
+ log.dim(` uploading ${basename(path44)} (${(bytes.byteLength / 1e6).toFixed(1)} MB)\u2026`);
20863
20877
  const put = await fetch(uploadUrl, {
20864
20878
  method: "PUT",
20865
20879
  headers: { "Content-Type": contentType, "Content-Length": String(bytes.byteLength) },
@@ -21859,11 +21873,11 @@ function mAimhold(C, w) {
21859
21873
  const r = yawMed * Math.PI / 180;
21860
21874
  const axis = [Math.sin(r), 0, Math.cos(r)];
21861
21875
  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;
21876
+ let run4 = 0, best = 0;
21863
21877
  for (let i = 0; i < fwdF.length; i++) {
21864
21878
  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);
21879
+ run4 = ok ? run4 + 1 : 0;
21880
+ best = Math.max(best, run4);
21867
21881
  }
21868
21882
  let holdBest = 0, holdBestScore = -Infinity;
21869
21883
  for (let i = 0; i < fwdF.length; i++) {
@@ -22217,15 +22231,15 @@ function cutOneshot(t, cfg) {
22217
22231
  const cc = folded(t);
22218
22232
  const air = cc.map((c2) => c2[0] === 0 && c2[1] === 0);
22219
22233
  let best = null;
22220
- let run2 = null;
22234
+ let run4 = null;
22221
22235
  for (let f = 0; f < air.length; f++) {
22222
- if (air[f]) run2 = run2 ? [run2[0], f] : [f, f];
22236
+ if (air[f]) run4 = run4 ? [run4[0], f] : [f, f];
22223
22237
  else {
22224
- if (run2 && (!best || run2[1] - run2[0] > best[1] - best[0])) best = run2;
22225
- run2 = null;
22238
+ if (run4 && (!best || run4[1] - run4[0] > best[1] - best[0])) best = run4;
22239
+ run4 = null;
22226
22240
  }
22227
22241
  }
22228
- if (run2 && (!best || run2[1] - run2[0] > best[1] - best[0])) best = run2;
22242
+ if (run4 && (!best || run4[1] - run4[0] > best[1] - best[0])) best = run4;
22229
22243
  let s, e;
22230
22244
  if (best) {
22231
22245
  s = best[0];
@@ -22469,8 +22483,8 @@ async function expandTakes(selectors) {
22469
22483
  for (const sel of selectors) {
22470
22484
  const st = await fs30.stat(sel).catch(() => null);
22471
22485
  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));
22486
+ const names2 = await fs30.readdir(sel);
22487
+ for (const n of names2.sort()) if (n.endsWith(".npz")) out.push(path31.join(sel, n));
22474
22488
  } else if (st?.isFile()) {
22475
22489
  out.push(sel);
22476
22490
  } else {
@@ -22758,7 +22772,7 @@ async function runBlender(opts) {
22758
22772
  return 1;
22759
22773
  }
22760
22774
  if (sub === "serve") {
22761
- const { serveLocalBlender } = await import("./blender-serve-66OHC4ML.js");
22775
+ const { serveLocalBlender } = await import("./blender-serve-EH2MOW3U.js");
22762
22776
  const port = Number(process.env.GENEX_BLENDER_PORT ?? 8088);
22763
22777
  log.step(`Starting a local Blender service on port ${port}`);
22764
22778
  log.plain(
@@ -22767,7 +22781,7 @@ async function runBlender(opts) {
22767
22781
  return serveLocalBlender({ port, log });
22768
22782
  }
22769
22783
  if (sub === "mcp") {
22770
- const { runBlenderMcp } = await import("./blender-mcp-6TEAE65U.js");
22784
+ const { runBlenderMcp } = await import("./blender-mcp-HKT7N5BQ.js");
22771
22785
  return runBlenderMcp();
22772
22786
  }
22773
22787
  if (sub === "seat") {
@@ -23265,10 +23279,618 @@ async function runAssetNew(options) {
23265
23279
  }
23266
23280
 
23267
23281
  // src/commands/tools.ts
23268
- import path35 from "path";
23282
+ import path36 from "path";
23269
23283
 
23270
23284
  // src/commands/doctor.ts
23285
+ import path35 from "path";
23286
+
23287
+ // src/commands/llm.ts
23288
+ import fs33 from "fs/promises";
23271
23289
  import path34 from "path";
23290
+ import { randomUUID as randomUUID2 } from "crypto";
23291
+ var SUBS3 = ["models", "bench", "price"];
23292
+ var LLM_BENCH_APPROVAL_REQUIRED = "STOP: `genex llm bench` runs the real model and spends YOUR OWN coin \u2014 your wallet, not a player's, and one this build's asset allowance cannot see. Re-run with --max-coins <n> --user-approved only after the person at the keyboard agreed to the number.";
23293
+ 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.";
23294
+ var LLM_TOOLS_CONVERT_HINT = [
23295
+ "In-game model calls need a hosted game. This folder is a Genex Tools workspace, so nothing here",
23296
+ "can call a model at play time yet \u2014 and the player, not you, is who pays for one that does.",
23297
+ "Ask the user first; on their yes run `npx genex init --convert`, then load the genex-tool-llm card."
23298
+ ];
23299
+ var LLM_BENCH_FILE = ".genex/llm-bench.json";
23300
+ var DEFAULT_BENCH_SAMPLES = 3;
23301
+ var DEFAULT_SAMPLE_TIMEOUT_SEC = 180;
23302
+ async function readRuntimeLane(apiUrl, token) {
23303
+ const empty = (state) => ({
23304
+ state,
23305
+ models: null,
23306
+ recommendedDeclaredHeadroomBps: null,
23307
+ externalProviders: null
23308
+ });
23309
+ try {
23310
+ const res = await apiFetch(`${apiUrl}/api/runtime/models`, {
23311
+ ...token ? { headers: { Authorization: `Bearer ${token}` } } : {},
23312
+ signal: AbortSignal.timeout(6e3)
23313
+ });
23314
+ if (res.status === 404) return empty("off");
23315
+ if (res.status === 503) return empty("unavailable");
23316
+ if (!res.ok) return empty("unknown");
23317
+ const body = await res.json().catch(() => null);
23318
+ if (!body || !Array.isArray(body.models)) return empty("unknown");
23319
+ return {
23320
+ state: "live",
23321
+ models: body.models,
23322
+ recommendedDeclaredHeadroomBps: typeof body.recommendedDeclaredHeadroomBps === "number" && Number.isFinite(body.recommendedDeclaredHeadroomBps) ? body.recommendedDeclaredHeadroomBps : null,
23323
+ externalProviders: Array.isArray(body.externalProviders) ? body.externalProviders : null
23324
+ };
23325
+ } catch {
23326
+ return empty("unknown");
23327
+ }
23328
+ }
23329
+ function percentile2(values, p) {
23330
+ if (values.length === 0) return null;
23331
+ const sorted = [...values].sort((a, b) => a - b);
23332
+ const rank2 = Math.ceil(p / 100 * sorted.length);
23333
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank2 - 1))];
23334
+ }
23335
+ function recommendEstimateCoins(p95, headroomBps) {
23336
+ return Math.max(1, Math.ceil(p95 * (1e4 + headroomBps) / 1e4));
23337
+ }
23338
+ function recommendPerCallMaxCoins(p95, max, headroomBps) {
23339
+ return Math.max(recommendEstimateCoins(p95, headroomBps), recommendEstimateCoins(max, headroomBps));
23340
+ }
23341
+ function usdPerMillion(value) {
23342
+ return `$${value.toFixed(value < 1 ? 4 : 2)}`;
23343
+ }
23344
+ function usd(value) {
23345
+ return value === null ? "unknown" : `$${value.toFixed(6)}`;
23346
+ }
23347
+ async function runLlm(opts = {}) {
23348
+ const log = createLogger({ quiet: opts.quiet || opts.json });
23349
+ const cwd = opts.cwd ?? process.cwd();
23350
+ const sub = opts.name?.trim() || "models";
23351
+ const fail4 = (command, message, extra = {}) => {
23352
+ if (opts.json) writeJsonLine({ command, status: "failed", error: message, ...extra });
23353
+ else log.error(message);
23354
+ process.exitCode = 1;
23355
+ };
23356
+ if (!SUBS3.includes(sub)) {
23357
+ fail4(`llm ${sub}`, `Unknown subcommand ${sub}. Use: ${SUBS3.join(", ")}.`);
23358
+ return;
23359
+ }
23360
+ if (sub === "price") {
23361
+ await reportSavedPrice(cwd, opts, log);
23362
+ return;
23363
+ }
23364
+ const ws = await resolveWorkspace(cwd);
23365
+ const toolsOnly = ws.mode === "tools" && !ws.hosted;
23366
+ if (sub === "bench") {
23367
+ if (opts.maxCoins === void 0 || opts.userApproved !== true) {
23368
+ fail4("llm bench", LLM_BENCH_APPROVAL_REQUIRED);
23369
+ return;
23370
+ }
23371
+ if (!Number.isInteger(opts.maxCoins) || opts.maxCoins < 1) {
23372
+ fail4("llm bench", `--max-coins takes a whole number of coin, 1 or more (got ${String(opts.maxCoins)}).`);
23373
+ return;
23374
+ }
23375
+ if (!opts.benchPrompt?.trim()) {
23376
+ fail4("llm bench", '`genex llm bench` needs the prompt your game would send, e.g. genex llm bench "<prompt>" --max-coins <n> --user-approved.');
23377
+ return;
23378
+ }
23379
+ if (opts.jsonOutput && opts.textOutput) {
23380
+ fail4("llm bench", "Pass --json-output or --text, not both.");
23381
+ return;
23382
+ }
23383
+ if (toolsOnly) {
23384
+ if (opts.json) writeJsonLine({ command: "llm bench", status: "failed", error: "`genex llm bench` is not part of Genex Tools." });
23385
+ else {
23386
+ reportPlatformRefused(log, "llm bench");
23387
+ log.plain(` ${c.cyan("\u2192")} A model that runs while people PLAY is a platform feature: the player pays and approves it.`);
23388
+ log.plain(` Ask the user, and on their yes: ${c.cyan("npx genex init --convert")} \u2014 then the ${c.cyan("genex-tool-llm")} card.`);
23389
+ }
23390
+ process.exitCode = 1;
23391
+ return;
23392
+ }
23393
+ }
23394
+ const meta = await readProject(cwd);
23395
+ if (sub === "bench" && !meta?.id) {
23396
+ fail4(
23397
+ "llm bench",
23398
+ "This folder isn't linked to a game \u2014 a benchmark is billed to a project you own."
23399
+ );
23400
+ if (!opts.json) {
23401
+ log.dim(` Run ${c.cyan("genex link")} (or ${c.cyan("genex list")} to find the slug) first.`);
23402
+ }
23403
+ return;
23404
+ }
23405
+ const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
23406
+ let token = opts.token ?? await readUserToken(opts.envPath, apiUrl);
23407
+ if (!token) {
23408
+ if (opts.noAuth) {
23409
+ fail4(`llm ${sub}`, "Not signed in. Re-run without --no-auth to connect.");
23410
+ return;
23411
+ }
23412
+ log.plain("Not signed in \u2014 connecting\u2026");
23413
+ try {
23414
+ assertAuthorizationOrigin(apiUrl, getApiUrl(opts.apiUrl));
23415
+ token = await authorize(apiUrl, getAuthUrl(opts.authUrl), {
23416
+ envPath: opts.envPath,
23417
+ log,
23418
+ inlineWaitMs: opts.timeoutSec ? opts.timeoutSec * 1e3 : void 0
23419
+ });
23420
+ } catch (err) {
23421
+ if (err instanceof AuthPendingError) {
23422
+ printAuthHandoff(log, err);
23423
+ log.dim(`Then re-run ${c.cyan(`genex llm ${sub}`)}.`);
23424
+ return;
23425
+ }
23426
+ fail4(`llm ${sub}`, `Sign-in didn't complete: ${err instanceof Error ? err.message : String(err)}`);
23427
+ return;
23428
+ }
23429
+ await writeUserToken(token, opts.envPath, apiUrl);
23430
+ }
23431
+ if (sub === "models") {
23432
+ await reportModels(apiUrl, token, opts, log, toolsOnly);
23433
+ return;
23434
+ }
23435
+ await runBench({ apiUrl, token, projectId: meta.id, cwd, opts, log });
23436
+ }
23437
+ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
23438
+ const probe = await apiFetch(`${apiUrl}/api/runtime/models`, {
23439
+ headers: { Authorization: `Bearer ${token}` }
23440
+ }).catch(() => null);
23441
+ if (probe?.status === 403) {
23442
+ const body = await probe.json().catch(() => ({}));
23443
+ if (!printedStructuredError(probe)) {
23444
+ log.error(
23445
+ body.error === "credential_scope" ? "This credential can't reach the runtime lane." : "The server refused this credential for the runtime lane."
23446
+ );
23447
+ log.dim(" An API key or an MCP connector token is scoped to the asset routes and can never");
23448
+ log.dim(" benchmark. Sign this machine in with the CLI's own device-code flow instead:");
23449
+ log.dim(` ${c.cyan("genex auth")} \u2014 it prints a short link and a code; approve it on any device.`);
23450
+ }
23451
+ if (opts.json) {
23452
+ writeJsonLine({
23453
+ command: "llm models",
23454
+ status: "failed",
23455
+ error: body.error ?? "forbidden",
23456
+ enabled: null,
23457
+ models: null,
23458
+ externalProviders: null,
23459
+ recommendedDeclaredHeadroomBps: null,
23460
+ convertHint: toolsOnly ? LLM_TOOLS_CONVERT_HINT.join(" ") : null
23461
+ });
23462
+ }
23463
+ process.exitCode = 1;
23464
+ return;
23465
+ }
23466
+ const lane = await readRuntimeLane(apiUrl, token);
23467
+ if (opts.json) {
23468
+ writeJsonLine({
23469
+ command: "llm models",
23470
+ status: lane.state,
23471
+ error: null,
23472
+ enabled: lane.state === "live",
23473
+ models: lane.models,
23474
+ externalProviders: lane.externalProviders,
23475
+ recommendedDeclaredHeadroomBps: lane.recommendedDeclaredHeadroomBps,
23476
+ // Always present, `null` off a tools workspace — the same
23477
+ // every-key-always-present rule the other keys follow, so a reader can
23478
+ // tell "no hint" from "a CLI that predates the hint".
23479
+ convertHint: toolsOnly ? LLM_TOOLS_CONVERT_HINT.join(" ") : null
23480
+ });
23481
+ if (lane.state === "unknown") process.exitCode = 1;
23482
+ return;
23483
+ }
23484
+ const printConvertHint = () => {
23485
+ if (!toolsOnly) return;
23486
+ log.plain("");
23487
+ for (const line of LLM_TOOLS_CONVERT_HINT) log.dim(` ${line}`);
23488
+ };
23489
+ log.plain(c.bold("genex llm models"));
23490
+ log.dim(` ${apiUrl}`);
23491
+ log.plain("");
23492
+ if (lane.state === "off") {
23493
+ log.plain(` ${LLM_LANE_OFF_LINE}`);
23494
+ log.dim(" Build the feature without an in-game model, or ask the operator to turn the lane on.");
23495
+ printConvertHint();
23496
+ return;
23497
+ }
23498
+ if (lane.state === "unavailable") {
23499
+ log.warn(" This stand has the lane on but cannot serve its model list.");
23500
+ log.dim(" That is a server configuration problem, not yours \u2014 nothing here can be benchmarked yet.");
23501
+ printConvertHint();
23502
+ return;
23503
+ }
23504
+ if (lane.state === "unknown" || !lane.models) {
23505
+ log.error(` Couldn't read the runtime lane from ${apiUrl}.`);
23506
+ log.dim(` Check the network, then ${c.cyan("genex doctor")}.`);
23507
+ process.exitCode = 1;
23508
+ return;
23509
+ }
23510
+ log.plain(` ${lane.models.length} model${lane.models.length === 1 ? "" : "s"} live:`);
23511
+ for (const m of lane.models) {
23512
+ log.plain(
23513
+ ` ${c.cyan(m.id)} ${m.label} \u2014 ${usdPerMillion(m.inputUsdPerMillion)} in / ${usdPerMillion(m.outputUsdPerMillion)} out per million tokens`
23514
+ );
23515
+ }
23516
+ if (lane.externalProviders && lane.externalProviders.length > 0) {
23517
+ log.plain("");
23518
+ log.plain(` Personal plans offered here: ${lane.externalProviders.map((p) => p.label).join(", ")}`);
23519
+ }
23520
+ log.plain("");
23521
+ if (lane.recommendedDeclaredHeadroomBps === null) {
23522
+ log.dim(" This stand serves no recommended headroom, so no price can be recommended from a bench.");
23523
+ } else {
23524
+ log.dim(` Recommended headroom over benchmarked coins on this stand: ${lane.recommendedDeclaredHeadroomBps} bps.`);
23525
+ }
23526
+ if (toolsOnly) {
23527
+ printConvertHint();
23528
+ return;
23529
+ }
23530
+ log.dim(" Those per-million rates are the PROVIDER's; what your game is charged is the platform");
23531
+ log.dim(` tariff on top, which only a real call reveals: ${c.cyan('genex llm bench "<prompt>" --max-coins <n> --user-approved')}`);
23532
+ }
23533
+ async function runBench(args) {
23534
+ const { apiUrl, token, projectId, cwd, opts, log } = args;
23535
+ const maxCoins = opts.maxCoins;
23536
+ const samples = opts.samples ?? DEFAULT_BENCH_SAMPLES;
23537
+ const prompt = opts.benchPrompt.trim();
23538
+ const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
23539
+ const call = (url, init2) => apiFetch(url, { ...init2, headers: { ...auth, ...init2?.headers ?? {} } });
23540
+ let schema;
23541
+ if (opts.schemaPath) {
23542
+ try {
23543
+ schema = JSON.parse(await fs33.readFile(path34.resolve(cwd, opts.schemaPath), "utf8"));
23544
+ } catch (err) {
23545
+ benchFailed(opts, log, `Couldn't read --schema ${opts.schemaPath}: ${err instanceof Error ? err.message : String(err)}`);
23546
+ return;
23547
+ }
23548
+ }
23549
+ const outputFormat = schema !== void 0 || opts.jsonOutput ? "json" : "text";
23550
+ if (schema !== void 0 && opts.textOutput) {
23551
+ benchFailed(opts, log, "--schema asks for a structured answer, so --text cannot apply. Drop one of them.");
23552
+ return;
23553
+ }
23554
+ const lane = await readRuntimeLane(apiUrl, token);
23555
+ if (lane.state !== "live" || !lane.models || lane.models.length === 0) {
23556
+ if (lane.state === "off") {
23557
+ if (opts.json) writeJsonLine({ ...emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoins, opts), status: "off", error: null });
23558
+ else {
23559
+ log.plain(LLM_LANE_OFF_LINE);
23560
+ log.dim(" Nothing was spent.");
23561
+ }
23562
+ return;
23563
+ }
23564
+ benchFailed(
23565
+ opts,
23566
+ log,
23567
+ 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}.`
23568
+ );
23569
+ return;
23570
+ }
23571
+ const modelId = opts.modelId ?? lane.models[0].id;
23572
+ if (!lane.models.some((m) => m.id === modelId)) {
23573
+ benchFailed(opts, log, `This stand does not serve ${modelId}. Run \`genex llm models\` for the ids it does.`);
23574
+ return;
23575
+ }
23576
+ const balanceBefore = await readCoinBalance(apiUrl, token);
23577
+ if (!opts.json) {
23578
+ log.plain(c.bold("genex llm bench"));
23579
+ log.dim(` ${apiUrl}`);
23580
+ log.plain("");
23581
+ log.plain(` Model ${c.cyan(modelId)}`);
23582
+ log.plain(` Samples ${samples} real attempt${samples === 1 ? "" : "s"}, ${outputFormat} output`);
23583
+ log.plain(` Approved ${maxCoins} coin per attempt \u2014 at worst ${maxCoins * samples} coin for this run`);
23584
+ log.plain(
23585
+ ` Balance ${balanceBefore === null ? "couldn't be read" : `${balanceBefore} coin spendable`}`
23586
+ );
23587
+ log.plain("");
23588
+ }
23589
+ const base = `${apiUrl}/api/runtime/development/projects/${encodeURIComponent(projectId)}/generations`;
23590
+ const results = [];
23591
+ let inflight = null;
23592
+ const onSigint = () => {
23593
+ const target = inflight;
23594
+ inflight = null;
23595
+ if (!opts.json) log.warn("Stopping \u2014 cancelling the attempt in flight.");
23596
+ void (async () => {
23597
+ if (target) {
23598
+ await call(`${base}/${encodeURIComponent(target)}/cancel`, { method: "POST" }).catch(() => {
23599
+ });
23600
+ }
23601
+ process.exit(130);
23602
+ })();
23603
+ };
23604
+ process.once("SIGINT", onSigint);
23605
+ try {
23606
+ for (let i = 0; i < samples; i++) {
23607
+ const res = await call(base, {
23608
+ method: "POST",
23609
+ body: JSON.stringify({
23610
+ maxCoins,
23611
+ request: {
23612
+ idempotencyKey: `bench-${randomUUID2()}`,
23613
+ modelId,
23614
+ prompt,
23615
+ outputFormat,
23616
+ ...schema !== void 0 ? { schema } : {}
23617
+ }
23618
+ })
23619
+ });
23620
+ if (!res.ok) {
23621
+ const stop2 = await explainBenchRefusal(res, log, opts, i, results.length);
23622
+ if (stop2) break;
23623
+ continue;
23624
+ }
23625
+ const started = await res.json().catch(() => ({}));
23626
+ if (!started.id) {
23627
+ results.push({ id: null, status: null, billingStatus: null, chargedCoins: null, costUsd: null, error: "no_generation_id" });
23628
+ continue;
23629
+ }
23630
+ inflight = started.id;
23631
+ const settled = await pollSettled(call, `${base}/${encodeURIComponent(started.id)}`, opts.timeoutSec ?? DEFAULT_SAMPLE_TIMEOUT_SEC);
23632
+ inflight = null;
23633
+ const row = {
23634
+ id: started.id,
23635
+ status: settled?.status ?? null,
23636
+ billingStatus: settled?.billingStatus ?? null,
23637
+ chargedCoins: typeof settled?.chargedCoins === "number" ? settled.chargedCoins : null,
23638
+ costUsd: typeof settled?.usage?.costUsd === "number" ? settled.usage.costUsd : null,
23639
+ error: settled?.error ?? (settled ? null : "timed_out")
23640
+ };
23641
+ results.push(row);
23642
+ if (!opts.json) {
23643
+ log.plain(
23644
+ ` ${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}` : ""}`
23645
+ );
23646
+ }
23647
+ }
23648
+ } finally {
23649
+ process.removeListener("SIGINT", onSigint);
23650
+ }
23651
+ const charged = results.filter((r) => r.billingStatus === "final" && typeof r.chargedCoins === "number").map((r) => r.chargedCoins);
23652
+ const settledCount = charged.length;
23653
+ const p50 = percentile2(charged, 50);
23654
+ const p95 = percentile2(charged, 95);
23655
+ const max = charged.length ? Math.max(...charged) : null;
23656
+ const headroomBps = lane.recommendedDeclaredHeadroomBps;
23657
+ const recommended = p95 !== null && headroomBps !== null ? recommendEstimateCoins(p95, headroomBps) : null;
23658
+ const ceiling = p95 !== null && max !== null && headroomBps !== null ? recommendPerCallMaxCoins(p95, max, headroomBps) : null;
23659
+ const balanceAfter = await readCoinBalance(apiUrl, token);
23660
+ const record = {
23661
+ v: 1,
23662
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
23663
+ apiUrl,
23664
+ projectId,
23665
+ modelId,
23666
+ prompt,
23667
+ outputFormat,
23668
+ samples,
23669
+ completed: settledCount,
23670
+ chargedCoins: charged,
23671
+ p50,
23672
+ p95,
23673
+ max,
23674
+ recommendedDeclaredHeadroomBps: headroomBps,
23675
+ recommendedEstimateCoins: recommended,
23676
+ recommendedPerCallMaxCoins: ceiling
23677
+ };
23678
+ const savedTo = await saveBench(cwd, record);
23679
+ if (opts.json) {
23680
+ writeJsonLine({
23681
+ command: "llm bench",
23682
+ status: charged.length > 0 ? "ok" : "failed",
23683
+ error: charged.length > 0 ? null : "no_sample_settled",
23684
+ apiUrl,
23685
+ projectId,
23686
+ modelId,
23687
+ prompt,
23688
+ outputFormat,
23689
+ schemaPath: opts.schemaPath ?? null,
23690
+ samples,
23691
+ completed: settledCount,
23692
+ maxCoinsPerSample: maxCoins,
23693
+ balanceBefore,
23694
+ balanceAfter,
23695
+ results,
23696
+ chargedCoins: { p50, p95, max },
23697
+ recommendedDeclaredHeadroomBps: headroomBps,
23698
+ recommendedEstimateCoins: recommended,
23699
+ recommendedPerCallMaxCoins: ceiling,
23700
+ savedTo
23701
+ });
23702
+ if (charged.length === 0) process.exitCode = 1;
23703
+ return;
23704
+ }
23705
+ log.plain("");
23706
+ if (charged.length === 0) {
23707
+ log.error(" No sample settled, so there is nothing to price from.");
23708
+ process.exitCode = 1;
23709
+ return;
23710
+ }
23711
+ log.plain(c.bold(" Charged coins"));
23712
+ log.plain(` p50 ${p50} p95 ${p95} max ${max} (${charged.length} of ${samples} settled)`);
23713
+ if (balanceBefore !== null && balanceAfter !== null) {
23714
+ log.plain(` Balance ${balanceBefore} \u2192 ${balanceAfter} coin spendable`);
23715
+ }
23716
+ log.plain("");
23717
+ printRecommendation(log, record);
23718
+ if (savedTo) log.dim(` Saved to ${savedTo} \u2014 re-read it any time with ${c.cyan("genex llm price")}.`);
23719
+ }
23720
+ function printRecommendation(log, record) {
23721
+ if (record.recommendedEstimateCoins === null || record.p95 === null) {
23722
+ log.warn(" No price recommended.");
23723
+ log.dim(
23724
+ 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"
23725
+ );
23726
+ log.dim(" declaring a number from this run would be a guess dressed as a measurement.");
23727
+ return;
23728
+ }
23729
+ log.plain(c.bold(` Declare estimateCoins: ${record.recommendedEstimateCoins}`));
23730
+ log.dim(
23731
+ ` = p95 of charged coins (${record.p95}) plus this stand's recommended headroom (${record.recommendedDeclaredHeadroomBps} bps),`
23732
+ );
23733
+ log.dim(` measured over ${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}.`);
23734
+ log.dim(" That number is a PRICE: a started attempt is charged in full, including one that fails.");
23735
+ if (record.recommendedPerCallMaxCoins != null) {
23736
+ log.plain(c.bold(` Grant perCallMaxCoins: ${record.recommendedPerCallMaxCoins}`));
23737
+ log.dim(` = the same headroom over the worst sample (${record.max}), and never below the price above it.`);
23738
+ log.dim(" The ceiling is the price's room to be wrong; declaring one under the price is refused.");
23739
+ }
23740
+ }
23741
+ async function explainBenchRefusal(res, log, opts, index, settledSoFar) {
23742
+ if (printedStructuredError(res)) return true;
23743
+ const body = await res.json().catch(() => ({}));
23744
+ if (opts.json) return res.status !== 429;
23745
+ if (res.status === 404 && body.error === "not_found") {
23746
+ log.error(` ${LLM_LANE_OFF_LINE}`);
23747
+ return true;
23748
+ }
23749
+ if (res.status === 403 && body.error === "credential_scope") {
23750
+ log.error(" This credential can't reach the runtime lane \u2014 an API key or MCP token is scoped out.");
23751
+ log.dim(` Sign in with ${c.cyan("genex auth")} and re-run.`);
23752
+ return true;
23753
+ }
23754
+ if (res.status === 403 && body.error === "project_owner_required") {
23755
+ log.error(" A benchmark is billed to a project YOU own, and this one isn't yours.");
23756
+ return true;
23757
+ }
23758
+ if (res.status === 402) {
23759
+ log.error(" Not enough coin to start the attempt.");
23760
+ return true;
23761
+ }
23762
+ log.error(
23763
+ ` Sample ${index + 1} was refused: ${body.message ?? body.error ?? `HTTP ${res.status}`}${settledSoFar > 0 ? " (earlier samples still count)" : ""}`
23764
+ );
23765
+ return res.status >= 500 || res.status === 401 || res.status === 403;
23766
+ }
23767
+ async function pollSettled(call, url, timeoutSec) {
23768
+ const deadline = Date.now() + timeoutSec * 1e3;
23769
+ let last = null;
23770
+ while (Date.now() < deadline) {
23771
+ const res = await call(url).catch(() => null);
23772
+ if (res?.ok) {
23773
+ last = await res.json().catch(() => null);
23774
+ if (last?.billingStatus === "final") return last;
23775
+ }
23776
+ await new Promise((r) => setTimeout(r, 1500));
23777
+ }
23778
+ return last;
23779
+ }
23780
+ async function readCoinBalance(apiUrl, token) {
23781
+ try {
23782
+ const res = await apiFetch(`${apiUrl}/api/coin/balance`, {
23783
+ headers: { Authorization: `Bearer ${token}` },
23784
+ signal: AbortSignal.timeout(6e3)
23785
+ });
23786
+ if (!res.ok) return null;
23787
+ const body = await res.json().catch(() => null);
23788
+ return typeof body?.spendable === "number" ? body.spendable : null;
23789
+ } catch {
23790
+ return null;
23791
+ }
23792
+ }
23793
+ async function saveBench(cwd, record) {
23794
+ try {
23795
+ const file = path34.join(cwd, LLM_BENCH_FILE);
23796
+ await fs33.mkdir(path34.dirname(file), { recursive: true });
23797
+ await fs33.writeFile(file, JSON.stringify(record, null, 2) + "\n");
23798
+ return LLM_BENCH_FILE;
23799
+ } catch {
23800
+ return null;
23801
+ }
23802
+ }
23803
+ function benchFailed(opts, log, message) {
23804
+ if (opts.json) writeJsonLine({ command: "llm bench", status: "failed", error: message });
23805
+ else log.error(message);
23806
+ process.exitCode = 1;
23807
+ }
23808
+ function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoins, opts) {
23809
+ return {
23810
+ command: "llm bench",
23811
+ apiUrl,
23812
+ projectId,
23813
+ modelId: opts.modelId ?? null,
23814
+ prompt,
23815
+ outputFormat,
23816
+ schemaPath: opts.schemaPath ?? null,
23817
+ samples,
23818
+ completed: 0,
23819
+ maxCoinsPerSample: maxCoins,
23820
+ balanceBefore: null,
23821
+ balanceAfter: null,
23822
+ results: [],
23823
+ chargedCoins: { p50: null, p95: null, max: null },
23824
+ recommendedDeclaredHeadroomBps: null,
23825
+ recommendedEstimateCoins: null,
23826
+ recommendedPerCallMaxCoins: null,
23827
+ savedTo: null
23828
+ };
23829
+ }
23830
+ async function reportSavedPrice(cwd, opts, log) {
23831
+ let record = null;
23832
+ try {
23833
+ record = JSON.parse(await fs33.readFile(path34.join(cwd, LLM_BENCH_FILE), "utf8"));
23834
+ } catch {
23835
+ record = null;
23836
+ }
23837
+ if (!record) {
23838
+ if (opts.json) {
23839
+ writeJsonLine({
23840
+ command: "llm price",
23841
+ status: "failed",
23842
+ error: "no_bench",
23843
+ ranAt: null,
23844
+ modelId: null,
23845
+ samples: null,
23846
+ completed: null,
23847
+ p50: null,
23848
+ p95: null,
23849
+ max: null,
23850
+ recommendedDeclaredHeadroomBps: null,
23851
+ recommendedEstimateCoins: null,
23852
+ recommendedPerCallMaxCoins: null,
23853
+ savedTo: null
23854
+ });
23855
+ } else {
23856
+ log.error("Nothing has been benchmarked in this folder yet.");
23857
+ log.dim(` Run ${c.cyan('genex llm bench "<prompt>" --max-coins <n> --user-approved')} first.`);
23858
+ }
23859
+ process.exitCode = 1;
23860
+ return;
23861
+ }
23862
+ if (opts.json) {
23863
+ writeJsonLine({
23864
+ command: "llm price",
23865
+ status: record.recommendedEstimateCoins === null ? "failed" : "ok",
23866
+ error: record.recommendedEstimateCoins === null ? "no_recommendation" : null,
23867
+ ranAt: record.ranAt ?? null,
23868
+ modelId: record.modelId ?? null,
23869
+ samples: record.samples ?? null,
23870
+ completed: record.completed ?? null,
23871
+ p50: record.p50 ?? null,
23872
+ p95: record.p95 ?? null,
23873
+ max: record.max ?? null,
23874
+ recommendedDeclaredHeadroomBps: record.recommendedDeclaredHeadroomBps ?? null,
23875
+ recommendedEstimateCoins: record.recommendedEstimateCoins ?? null,
23876
+ recommendedPerCallMaxCoins: record.recommendedPerCallMaxCoins ?? null,
23877
+ savedTo: LLM_BENCH_FILE
23878
+ });
23879
+ if (record.recommendedEstimateCoins === null) process.exitCode = 1;
23880
+ return;
23881
+ }
23882
+ log.plain(c.bold("genex llm price"));
23883
+ log.dim(` from ${LLM_BENCH_FILE}, benchmarked ${record.ranAt}`);
23884
+ log.plain("");
23885
+ printRecommendation(log, record);
23886
+ if (record.recommendedEstimateCoins === null) process.exitCode = 1;
23887
+ }
23888
+ function writeJsonLine(value) {
23889
+ process.stdout.write(`${JSON.stringify(value)}
23890
+ `);
23891
+ }
23892
+
23893
+ // src/commands/doctor.ts
23272
23894
  var LANE_ORDER = [
23273
23895
  "model",
23274
23896
  "image",
@@ -23339,15 +23961,18 @@ async function runDoctor(opts = {}) {
23339
23961
  let credits = null;
23340
23962
  let lanes = null;
23341
23963
  let legal = null;
23964
+ let runtime = null;
23342
23965
  if (authState === "ok" && token) {
23343
- [credits, lanes, legal] = await Promise.all([
23966
+ [credits, lanes, legal, runtime] = await Promise.all([
23344
23967
  fetchCredits(apiUrl, token),
23345
23968
  fetchLanes(apiUrl, token),
23346
- fetchLegalStatus(apiUrl, token)
23969
+ fetchLegalStatus(apiUrl, token),
23970
+ readRuntimeLane(apiUrl, token)
23347
23971
  ]);
23348
23972
  }
23349
23973
  rows.push(termsRow(authState, legal));
23350
23974
  rows.push(creditsRow(authState, credits));
23975
+ rows.push(runtimeRow(authState, runtime, mode === "tools"));
23351
23976
  if (opts.json) {
23352
23977
  process.stdout.write(
23353
23978
  JSON.stringify(
@@ -23366,6 +23991,10 @@ async function runDoctor(opts = {}) {
23366
23991
  terms: legal,
23367
23992
  credits,
23368
23993
  lanes: lanes ? { paused: lanes.paused, lanes: lanes.lanes } : null,
23994
+ // Same always-present rule as `assetsMode`: a consumer must be able
23995
+ // to tell a CLI that KNOWS the runtime lane (key present, object or
23996
+ // null) from one that predates it (no key at all).
23997
+ runtime,
23369
23998
  ok: !rows.some((r) => r.bad)
23370
23999
  },
23371
24000
  null,
@@ -23504,6 +24133,30 @@ function creditsRow(state, info) {
23504
24133
  fix: info.balance <= 0 ? "Out of credits \u2014 generation is refused until the refill or a top-up." : void 0
23505
24134
  };
23506
24135
  }
24136
+ function runtimeRow(state, lane, toolsOnly = false) {
24137
+ if (state !== "ok") return { label: "Runtime", value: "\u2014 (sign in first)" };
24138
+ const convertFix = "In-game calls need a hosted game, and the PLAYER pays for them \u2014 ask the user, then `npx genex init --convert`; the genex-tool-llm card has the offer to make.";
24139
+ switch (lane?.state) {
24140
+ case "live": {
24141
+ const n = lane.models?.length ?? 0;
24142
+ return {
24143
+ label: "Runtime",
24144
+ value: `in-game LLM lane live \xB7 ${n} model${n === 1 ? "" : "s"}`,
24145
+ fix: toolsOnly ? convertFix : 'Price a call before a game declares one: `npx genex llm bench "<prompt>" --max-coins <n> --user-approved`.'
24146
+ };
24147
+ }
24148
+ case "off":
24149
+ return {
24150
+ label: "Runtime",
24151
+ value: "in-game LLM lane off on this stand",
24152
+ fix: "generate() in a game answers 404 here \u2014 build the feature without a model, or ask the operator."
24153
+ };
24154
+ case "unavailable":
24155
+ return { label: "Runtime", value: "in-game LLM lane on, but its model list can't be served" };
24156
+ default:
24157
+ return { label: "Runtime", value: "couldn't be read from this server" };
24158
+ }
24159
+ }
23507
24160
  function shortDate(iso) {
23508
24161
  const d = new Date(iso);
23509
24162
  return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
@@ -23549,7 +24202,7 @@ async function fetchLegalStatus(apiUrl, token) {
23549
24202
  }
23550
24203
  async function firstSkillsMarker() {
23551
24204
  for (const target of resolveAgentTargets()) {
23552
- const marker = await readSkillsMarker(path34.join(target.baseDir, "skills"));
24205
+ const marker = await readSkillsMarker(path35.join(target.baseDir, "skills"));
23553
24206
  if (marker) return marker;
23554
24207
  }
23555
24208
  return null;
@@ -23600,8 +24253,8 @@ async function runTools(opts) {
23600
24253
  let totalNew = 0;
23601
24254
  let totalUpdated = 0;
23602
24255
  for (const t of targets) {
23603
- const dest = path35.join(t.baseDir, "skills");
23604
- const { copied, updated } = await copyTemplates(path35.join(templatesDir, "skills"), dest, {
24256
+ const dest = path36.join(t.baseDir, "skills");
24257
+ const { copied, updated } = await copyTemplates(path36.join(templatesDir, "skills"), dest, {
23605
24258
  filter: (rel) => skillFamilyFilter("tools")(`skills/${rel}`)
23606
24259
  });
23607
24260
  await pruneRemovedSkills(dest, log);
@@ -23661,8 +24314,8 @@ async function runTools(opts) {
23661
24314
  }
23662
24315
 
23663
24316
  // src/commands/budget.ts
23664
- import fs33 from "fs/promises";
23665
- import path36 from "path";
24317
+ import fs34 from "fs/promises";
24318
+ import path37 from "path";
23666
24319
  var BUDGET_SOURCE_LINE = "Live from your account; prices can change without a deploy.";
23667
24320
  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
24321
  function internalKind(kind) {
@@ -23670,7 +24323,7 @@ function internalKind(kind) {
23670
24323
  }
23671
24324
  async function isGenexProject(cwd) {
23672
24325
  try {
23673
- await fs33.access(path36.join(cwd, ".genex"));
24326
+ await fs34.access(path37.join(cwd, ".genex"));
23674
24327
  return true;
23675
24328
  } catch {
23676
24329
  return false;
@@ -23691,25 +24344,25 @@ async function runBudget(opts = {}) {
23691
24344
  const log = createLogger({ quiet: opts.quiet || opts.json });
23692
24345
  const cwd = opts.cwd ?? process.cwd();
23693
24346
  const config = budgetConfig();
23694
- const fail3 = (message) => {
24347
+ const fail4 = (message) => {
23695
24348
  if (opts.json) writeJson({ command: "budget", status: "failed", error: message });
23696
24349
  else log.error(message);
23697
24350
  process.exitCode = 1;
23698
24351
  };
23699
24352
  const setting = opts.assets !== void 0;
23700
24353
  if (setting && opts.userApproved !== true) {
23701
- fail3(BUDGET_APPROVAL_REQUIRED);
24354
+ fail4(BUDGET_APPROVAL_REQUIRED);
23702
24355
  return;
23703
24356
  }
23704
24357
  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)}).`);
24358
+ fail4(`--assets takes a whole number of credits, 0 or more (got ${String(opts.assets)}).`);
23706
24359
  return;
23707
24360
  }
23708
24361
  const meta = await readProject(cwd);
23709
24362
  const apiUrl = getApiUrl(opts.apiUrl ?? meta?.apiUrl);
23710
24363
  const token = opts.token ?? await readUserToken(opts.envPath, apiUrl);
23711
24364
  if (!token) {
23712
- fail3("Not authorized. Run `genex init` first to sign in.");
24365
+ fail4("Not authorized. Run `genex init` first to sign in.");
23713
24366
  return;
23714
24367
  }
23715
24368
  const snapshot = await fetchCreditsSnapshot(apiUrl, token);
@@ -23727,7 +24380,7 @@ async function runBudget(opts = {}) {
23727
24380
  };
23728
24381
  persisted = await writeBudgetStore(cwd, store);
23729
24382
  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.");
24383
+ fail4("This folder is not a Genex project (no .genex/), so the allowance has nowhere to live. Run this inside the game folder.");
23731
24384
  return;
23732
24385
  }
23733
24386
  } else if (snapshot && !snapshot.unlimited && !budgetDisabled(config)) {
@@ -23818,16 +24471,1560 @@ async function runBudget(opts = {}) {
23818
24471
  if (!snapshot && !setting) process.exitCode = 1;
23819
24472
  }
23820
24473
 
23821
- // src/lib/costs.ts
23822
- var TYPICAL_CREDITS = {
23823
- model: 35,
23824
- // 46 from a reference image; --texture standard 23, --no-texture 12, --geometry detailed 58, --quad 41, --low-poly 46, --parts 58 quote off the estimate
23825
- image: 7,
23826
- // GPT Image 2.5 at high, the default; --quality xhigh/max, --candidates, --edit and 4K quote higher, --quality low/medium and --transparent quote lower
23827
- texture: 9,
23828
- // gemini-3.1-flash-image, 1K square
23829
- sfx: 2,
23830
- music: 27,
24474
+ // src/commands/player.ts
24475
+ import fs39 from "fs";
24476
+ import fsp2 from "fs/promises";
24477
+ import os12 from "os";
24478
+ import path43 from "path";
24479
+ import { spawn as spawn8 } from "child_process";
24480
+
24481
+ // src/lib/player/paths.ts
24482
+ import os8 from "os";
24483
+ import path38 from "path";
24484
+ var PLAYER_DRY_RUN_ENV = "GENEX_PLAYER_DRY_RUN";
24485
+ function playerDryRun() {
24486
+ const raw = process.env[PLAYER_DRY_RUN_ENV];
24487
+ return raw === "1" || raw === "true";
24488
+ }
24489
+ function playerPaths(home = os8.homedir()) {
24490
+ const dir = home === os8.homedir() ? getGenexDir() : path38.join(home, ".genex");
24491
+ const logDir = path38.join(dir, "logs");
24492
+ return {
24493
+ credential: path38.join(dir, "player.json"),
24494
+ state: path38.join(dir, "player-state.json"),
24495
+ relaunch: path38.join(dir, "player-relaunch.json"),
24496
+ logDir,
24497
+ log: path38.join(logDir, "player.log"),
24498
+ errorLog: path38.join(logDir, "player.error.log")
24499
+ };
24500
+ }
24501
+
24502
+ // src/lib/player/store.ts
24503
+ import fs35 from "fs/promises";
24504
+ import path39 from "path";
24505
+ async function readPlayerInstall(home) {
24506
+ try {
24507
+ const raw = await fs35.readFile(playerPaths(home).credential, "utf8");
24508
+ const parsed = JSON.parse(raw);
24509
+ if (!parsed || typeof parsed.token !== "string" || typeof parsed.apiUrl !== "string") return null;
24510
+ return parsed;
24511
+ } catch {
24512
+ return null;
24513
+ }
24514
+ }
24515
+ async function writePlayerInstall(install2, home) {
24516
+ const file = playerPaths(home).credential;
24517
+ const record = { ...install2, apiUrl: normalizeApiOrigin(install2.apiUrl) };
24518
+ await writeSecretFile(file, JSON.stringify(record, null, 2) + "\n");
24519
+ return file;
24520
+ }
24521
+ function installExpired(install2, now = Date.now()) {
24522
+ const at = Date.parse(install2.expiresAt);
24523
+ return Number.isFinite(at) && at <= now;
24524
+ }
24525
+ function describeInstall(install2) {
24526
+ return {
24527
+ apiUrl: install2.apiUrl,
24528
+ watcherId: install2.watcherId ?? null,
24529
+ label: install2.label ?? null,
24530
+ installedAt: install2.installedAt,
24531
+ version: install2.version,
24532
+ expiresAt: install2.expiresAt
24533
+ };
24534
+ }
24535
+ async function readPlayerState(home) {
24536
+ try {
24537
+ return JSON.parse(await fs35.readFile(playerPaths(home).state, "utf8"));
24538
+ } catch {
24539
+ return null;
24540
+ }
24541
+ }
24542
+ async function writePlayerState(state, home) {
24543
+ const file = playerPaths(home).state;
24544
+ try {
24545
+ await fs35.mkdir(path39.dirname(file), { recursive: true, mode: 448 });
24546
+ await fs35.writeFile(file, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
24547
+ } catch {
24548
+ }
24549
+ }
24550
+ async function removePlayerFiles(home) {
24551
+ const paths = playerPaths(home);
24552
+ const removed = [];
24553
+ for (const file of [paths.credential, paths.state, paths.relaunch]) {
24554
+ const existed = await fs35.stat(file).then(() => true, () => false);
24555
+ await fs35.rm(file, { force: true }).catch(() => {
24556
+ });
24557
+ if (existed) removed.push(file);
24558
+ }
24559
+ return removed;
24560
+ }
24561
+
24562
+ // src/lib/player/auth.ts
24563
+ var MIN_POLL_INTERVAL_MS2 = 1e3;
24564
+ var DEFAULT_PLAYER_WAIT_MS = 10 * 60 * 1e3;
24565
+ var REASSURE_EVERY_MS2 = 30 * 1e3;
24566
+ var PLAYER_NOT_APPROVED = "Nobody approved this machine, so nothing was installed. Re-run `genex player install` when you can open the link.";
24567
+ var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
24568
+ async function authorizePlayer(apiBaseUrl, options) {
24569
+ const apiUrl = normalizeApiOrigin(apiBaseUrl);
24570
+ const { log, waitMs = DEFAULT_PLAYER_WAIT_MS, open = openBrowser } = options;
24571
+ const started = await startPlayerDeviceAuth(apiUrl, options.label);
24572
+ log.plain("");
24573
+ log.step("Approve this machine for in-game model requests.");
24574
+ log.plain([
24575
+ "",
24576
+ ` ${c.cyan("\u2192")} Open: ${c.cyan(started.verifyUrl)}`,
24577
+ ` ${c.cyan("\u2192")} Code: ${c.bold(formatUserCode(started.userCode))}`,
24578
+ "",
24579
+ " The card names the Genex watcher, not a full account connection: it can",
24580
+ " answer the in-game requests you approve and spend nothing.",
24581
+ ""
24582
+ ].join("\n"));
24583
+ let warned = false;
24584
+ const warnManual = () => {
24585
+ if (warned) return;
24586
+ warned = true;
24587
+ log.dim(" (couldn't open a browser here \u2014 open the link above yourself)");
24588
+ };
24589
+ if (!open(started.verifyUrl, warnManual)) warnManual();
24590
+ const deadline = Date.now() + waitMs;
24591
+ let intervalMs = Math.max((started.interval || 2) * 1e3, MIN_POLL_INTERVAL_MS2);
24592
+ let nextReassureAt = Date.now() + REASSURE_EVERY_MS2;
24593
+ for (; ; ) {
24594
+ let res;
24595
+ try {
24596
+ res = await apiFetch(`${apiUrl}/api/cli/device/poll`, {
24597
+ method: "POST",
24598
+ headers: { "Content-Type": "application/json" },
24599
+ body: JSON.stringify({ deviceCode: started.deviceCode })
24600
+ }, { noTermsWait: true });
24601
+ } catch {
24602
+ res = new Response(null, { status: 599 });
24603
+ }
24604
+ if (res.status === 410) throw new Error("That code expired. Re-run `genex player install` for a fresh one.");
24605
+ if (res.ok) {
24606
+ const body = await res.json().catch(() => null);
24607
+ if (body?.status === "approved" && body.token) {
24608
+ if (body.scope !== void 0 && body.scope !== "player") {
24609
+ 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.");
24610
+ }
24611
+ return {
24612
+ token: bindToken(body.token, apiUrl),
24613
+ expiresAt: body.expiresAt ?? new Date(Date.now() + 30 * 24 * 36e5).toISOString()
24614
+ };
24615
+ }
24616
+ if (body?.status === "denied") throw new Error("That request was declined in the browser. Nothing was installed.");
24617
+ if (body?.interval) intervalMs = Math.max(body.interval * 1e3, MIN_POLL_INTERVAL_MS2);
24618
+ }
24619
+ if (Date.now() + intervalMs > deadline) throw new Error(PLAYER_NOT_APPROVED);
24620
+ if (Date.now() >= nextReassureAt) {
24621
+ log.dim(" still waiting for you to approve\u2026");
24622
+ nextReassureAt = Date.now() + REASSURE_EVERY_MS2;
24623
+ }
24624
+ await sleep4(intervalMs);
24625
+ }
24626
+ }
24627
+ async function startPlayerDeviceAuth(apiUrl, label) {
24628
+ const body = JSON.stringify({ ...label ? { label } : {}, scope: "player" });
24629
+ for (let attempt = 0; attempt < 2; attempt++) {
24630
+ const res = await apiFetch(`${apiUrl}/api/cli/device/start`, {
24631
+ method: "POST",
24632
+ headers: { "Content-Type": "application/json" },
24633
+ body
24634
+ }, { noTermsWait: true });
24635
+ if (res.status === 409) continue;
24636
+ if (res.status === 404 || res.status === 501) {
24637
+ throw new Error("This Genex server has no player sign-in \u2014 the watcher needs a current stand.");
24638
+ }
24639
+ if (!res.ok) throw new Error(`Couldn't start sign-in (HTTP ${res.status}). Check your connection and re-run this command.`);
24640
+ return await res.json();
24641
+ }
24642
+ throw new Error("Couldn't start sign-in \u2014 please re-run this command.");
24643
+ }
24644
+
24645
+ // src/lib/player/api.ts
24646
+ var PLAYER_VERSION_HEADER = "x-genex-player-version";
24647
+ var EXTERNAL_PROVIDER_HEADER = "X-Genex-External-Provider";
24648
+ var PlayerUpdateRequiredError = class extends Error {
24649
+ minVersion;
24650
+ latestVersion;
24651
+ constructor(minVersion, latestVersion) {
24652
+ super(`This watcher is below the server's floor (${minVersion}); the current version is ${latestVersion}.`);
24653
+ this.name = "PlayerUpdateRequiredError";
24654
+ this.minVersion = minVersion;
24655
+ this.latestVersion = latestVersion;
24656
+ }
24657
+ };
24658
+ var PlayerAuthError = class extends Error {
24659
+ status;
24660
+ code;
24661
+ constructor(status2, code) {
24662
+ super(`Genex refused this machine's credential (HTTP ${status2}${code ? `, ${code}` : ""}).`);
24663
+ this.name = "PlayerAuthError";
24664
+ this.status = status2;
24665
+ this.code = code;
24666
+ }
24667
+ };
24668
+ var PlayerApiError = class extends Error {
24669
+ status;
24670
+ code;
24671
+ constructor(status2, code) {
24672
+ super(`HTTP ${status2}${code ? ` ${code}` : ""}`);
24673
+ this.name = "PlayerApiError";
24674
+ this.status = status2;
24675
+ this.code = code;
24676
+ }
24677
+ };
24678
+ var PlayerClient = class {
24679
+ apiUrl;
24680
+ version;
24681
+ token;
24682
+ timeoutMs;
24683
+ constructor(options) {
24684
+ this.apiUrl = options.apiUrl;
24685
+ this.token = options.token;
24686
+ this.version = options.version ?? getCliVersion();
24687
+ this.timeoutMs = options.timeoutMs ?? 3e4;
24688
+ }
24689
+ async call(path44, init2 = {}, provider) {
24690
+ const headers = {
24691
+ Authorization: `Bearer ${this.token}`,
24692
+ [PLAYER_VERSION_HEADER]: this.version,
24693
+ ...provider ? { [EXTERNAL_PROVIDER_HEADER]: provider } : {},
24694
+ ...init2.body === void 0 ? {} : { "Content-Type": "application/json" }
24695
+ };
24696
+ const res = await apiFetch(`${this.apiUrl}${path44}`, {
24697
+ ...init2,
24698
+ headers,
24699
+ signal: AbortSignal.timeout(this.timeoutMs)
24700
+ }, { noTermsWait: true });
24701
+ const body = await res.json().catch(() => null);
24702
+ if (res.ok) return body ?? {};
24703
+ const code = typeof body?.error === "string" ? body.error : "";
24704
+ if (res.status === 426) {
24705
+ throw new PlayerUpdateRequiredError(
24706
+ typeof body?.minVersion === "string" ? body.minVersion : "unknown",
24707
+ typeof body?.latestVersion === "string" ? body.latestVersion : "latest"
24708
+ );
24709
+ }
24710
+ if (res.status === 401 || res.status === 403) throw new PlayerAuthError(res.status, code);
24711
+ throw new PlayerApiError(res.status, code);
24712
+ }
24713
+ /** The versioned runner policy — read on start and refreshed periodically. */
24714
+ async policy() {
24715
+ return await this.call("/api/runtime/player/policy");
24716
+ }
24717
+ /** "I am here, this is what I can run." Also how a watcher learns its id. */
24718
+ async heartbeat(input) {
24719
+ return await this.call("/api/runtime/player/watchers/heartbeat", {
24720
+ method: "POST",
24721
+ body: JSON.stringify({
24722
+ ...input.watcherId ? { watcherId: input.watcherId } : {},
24723
+ version: this.version,
24724
+ platform: input.platform,
24725
+ runners: input.runners,
24726
+ ...input.label ? { label: input.label } : {}
24727
+ })
24728
+ });
24729
+ }
24730
+ /** Everything this player has waiting on ONE funding lane. */
24731
+ async pending(provider) {
24732
+ const body = await this.call("/api/runtime/external/generations", {}, provider);
24733
+ return Array.isArray(body.generations) ? body.generations : [];
24734
+ }
24735
+ /** Take a row for a bounded lease. Idempotent for the holder; 409 otherwise.
24736
+ * Answers with the job, so a runner needs no second read. */
24737
+ async claim(id, provider, watcherId) {
24738
+ return await this.call(`/api/runtime/external/generations/${encodeURIComponent(id)}/claim`, {
24739
+ method: "POST",
24740
+ body: JSON.stringify({ watcherId })
24741
+ }, provider);
24742
+ }
24743
+ /** Hand a row back unfinished. `rate_limited` parks the budget behind it. */
24744
+ async release(id, provider, input) {
24745
+ return await this.call(`/api/runtime/external/generations/${encodeURIComponent(id)}/release`, {
24746
+ method: "POST",
24747
+ body: JSON.stringify({
24748
+ watcherId: input.watcherId,
24749
+ reason: input.reason,
24750
+ ...input.retryAfterSeconds ? { retryAfterSeconds: Math.min(86400, Math.max(1, Math.round(input.retryAfterSeconds))) } : {}
24751
+ })
24752
+ }, provider);
24753
+ }
24754
+ /** The answer. `watcherId` names the lease holder — only it may submit. */
24755
+ async submit(id, provider, watcherId, output) {
24756
+ return await this.call(`/api/runtime/external/generations/${encodeURIComponent(id)}/result`, {
24757
+ method: "POST",
24758
+ body: JSON.stringify({ output, watcherId })
24759
+ }, provider);
24760
+ }
24761
+ };
24762
+
24763
+ // src/lib/player/runners.ts
24764
+ import { spawn as spawn6, spawnSync } from "child_process";
24765
+ import fs36 from "fs/promises";
24766
+ import fsSync from "fs";
24767
+ import os9 from "os";
24768
+ import path40 from "path";
24769
+ import { randomUUID as randomUUID3 } from "crypto";
24770
+ var WATCHER_RUNNERS = ["claude", "codex"];
24771
+ var RUNNER_FOR_FAMILY = { claude: "claude", chatgpt: "codex" };
24772
+ var PROVIDER_FOR_RUNNER = { claude: "claude", codex: "chatgpt" };
24773
+ var FALLBACK_POLICY = {
24774
+ version: "fallback",
24775
+ minVersion: "0.0.0",
24776
+ latestVersion: "0.0.0",
24777
+ pollIntervalSeconds: 5,
24778
+ backoff: { initialSeconds: 5, maxSeconds: 300, factor: 2, jitterRatio: 0.2 },
24779
+ claude: { args: ["-p"], modelFlag: "--model" },
24780
+ codex: { args: ["exec"], modelFlag: "--model" }
24781
+ };
24782
+ var CLAUDE_DENIED_TOOLS = [
24783
+ "Bash",
24784
+ "Edit",
24785
+ "Write",
24786
+ "Read",
24787
+ "Glob",
24788
+ "Grep",
24789
+ "WebFetch",
24790
+ "WebSearch",
24791
+ "Task",
24792
+ "NotebookEdit",
24793
+ "TodoWrite",
24794
+ "SlashCommand",
24795
+ "KillShell",
24796
+ "BashOutput"
24797
+ ];
24798
+ var BYTES_PER_TOKEN = 4;
24799
+ var HARD_OUTPUT_BYTES = 2 * 1024 * 1024;
24800
+ var DEFAULT_TIMEOUT_MS = 10 * 6e4;
24801
+ function runnerBinaryEnv(runner) {
24802
+ return `GENEX_PLAYER_${runner.toUpperCase()}_BIN`;
24803
+ }
24804
+ function runnerBinary(runner, env = process.env, platform = process.platform) {
24805
+ const override = env[runnerBinaryEnv(runner)]?.trim();
24806
+ if (override) {
24807
+ try {
24808
+ return fsSync.statSync(override).isFile() ? override : null;
24809
+ } catch {
24810
+ return null;
24811
+ }
24812
+ }
24813
+ return resolveOnPath(runner, env, platform);
24814
+ }
24815
+ function detectRunners(env = process.env, platform = process.platform) {
24816
+ const found = [];
24817
+ for (const name of WATCHER_RUNNERS) if (runnerBinary(name, env, platform)) found.push(name);
24818
+ return found;
24819
+ }
24820
+ function resolveOnPath(name, env = process.env, platform = process.platform) {
24821
+ const win = platform === "win32";
24822
+ const dirs = (env.PATH ?? env.Path ?? "").split(win ? ";" : ":").filter(Boolean);
24823
+ const candidates = win ? [`${name}.cmd`, `${name}.exe`, `${name}.bat`, name] : [name];
24824
+ for (const dir of dirs) {
24825
+ for (const candidate of candidates) {
24826
+ const file = path40.join(dir, candidate);
24827
+ try {
24828
+ const stat = fsSync.statSync(file);
24829
+ if (!stat.isFile()) continue;
24830
+ if (!win) fsSync.accessSync(file, fsSync.constants.X_OK);
24831
+ return file;
24832
+ } catch {
24833
+ }
24834
+ }
24835
+ }
24836
+ return null;
24837
+ }
24838
+ function names(args, ...flags) {
24839
+ return args.some((a) => flags.includes(a) || flags.some((f) => a.startsWith(`${f}=`)));
24840
+ }
24841
+ function claudeArgv(job, policy = {}) {
24842
+ const base = [...policy.args ?? ["-p"]];
24843
+ const argv = [...base];
24844
+ const add = (flag, value, ...aliases) => {
24845
+ if (names(base, flag, ...aliases)) return;
24846
+ argv.push(flag);
24847
+ if (value !== void 0) argv.push(value);
24848
+ };
24849
+ if (!names(base, "-p", "--print")) argv.unshift("-p");
24850
+ if (policy.modelFlag && !names(base, policy.modelFlag)) argv.push(policy.modelFlag, job.model.cliModel);
24851
+ add("--output-format", "json");
24852
+ const structured = job.outputFormat === "json" && job.schema !== void 0;
24853
+ add("--max-turns", structured ? "2" : "1");
24854
+ add("--no-session-persistence");
24855
+ add("--permission-prompts", "none");
24856
+ add("--strict-mcp-config");
24857
+ argv.push("--append-system-prompt", job.system);
24858
+ if (structured && !names(base, "--json-schema")) {
24859
+ argv.push("--json-schema", JSON.stringify(job.schema));
24860
+ }
24861
+ if (!names(base, "--disallowedTools", "--disallowed-tools")) {
24862
+ argv.push("--disallowedTools", ...CLAUDE_DENIED_TOOLS);
24863
+ }
24864
+ return argv;
24865
+ }
24866
+ function codexArgv(job, policy = {}, files) {
24867
+ const base = [...policy.args ?? ["exec"]];
24868
+ const argv = [...base];
24869
+ const add = (flag, value) => {
24870
+ if (names(base, flag)) return;
24871
+ argv.push(flag);
24872
+ if (value !== void 0) argv.push(value);
24873
+ };
24874
+ if (!names(base, "exec") && base[0] !== "exec") argv.unshift("exec");
24875
+ add("--ephemeral");
24876
+ add("--ignore-user-config");
24877
+ add("--skip-git-repo-check");
24878
+ add("--sandbox", "read-only");
24879
+ add("-C", files.cwd);
24880
+ if (files.schemaFile && !names(base, "--output-schema")) argv.push("--output-schema", files.schemaFile);
24881
+ add("-o", files.outFile);
24882
+ add("--color", "never");
24883
+ if (policy.modelFlag && !names(base, policy.modelFlag)) argv.push(policy.modelFlag, job.model.cliModel);
24884
+ argv.push("-");
24885
+ return argv;
24886
+ }
24887
+ function classifyFailure(text) {
24888
+ const t = text.toLowerCase();
24889
+ if (/rate[ -]?limit|usage limit|quota|too many requests|429|\bresets?\b|limit reached/.test(t)) {
24890
+ const retry = parseRetrySeconds(t);
24891
+ return retry === void 0 ? { release: "rate_limited" } : { release: "rate_limited", retryAfterSeconds: retry };
24892
+ }
24893
+ if (/not logged in|please log ?in|\blogin\b|log in to|unauthorized|401|invalid api key|authentication (failed|required)|credentials/.test(t)) {
24894
+ return { release: "auth_expired" };
24895
+ }
24896
+ if (/enoent|command not found|no such file or directory|is not recognized/.test(t)) {
24897
+ return { release: "runner_unavailable" };
24898
+ }
24899
+ return { release: "failed" };
24900
+ }
24901
+ function parseRetrySeconds(text) {
24902
+ const header = /retry[ -]after:?\s*(\d+)/.exec(text);
24903
+ if (header?.[1]) return clampSeconds(Number(header[1]));
24904
+ const phrase = /(?:try again|retry|resets?|wait|available again)\D{0,20}?(\d+)\s*(second|sec|minute|min|hour|hr)s?/.exec(text);
24905
+ if (!phrase?.[1] || !phrase[2]) return void 0;
24906
+ const n = Number(phrase[1]);
24907
+ const unit = phrase[2];
24908
+ const seconds = unit.startsWith("h") ? n * 3600 : unit.startsWith("m") ? n * 60 : n;
24909
+ return clampSeconds(seconds);
24910
+ }
24911
+ function clampSeconds(value) {
24912
+ if (!Number.isFinite(value) || value <= 0) return void 0;
24913
+ return Math.min(86400, Math.max(1, Math.round(value)));
24914
+ }
24915
+ function outputByteCeiling(maxOutputTokens) {
24916
+ const fromTokens = Math.max(1024, Math.round(maxOutputTokens * BYTES_PER_TOKEN));
24917
+ return Math.min(HARD_OUTPUT_BYTES, fromTokens);
24918
+ }
24919
+ function parseClaudeEnvelope(stdout) {
24920
+ const trimmed = stdout.trim();
24921
+ if (!trimmed) return { ok: false, detail: "no_output" };
24922
+ const start = trimmed.lastIndexOf("\n{");
24923
+ const candidate = start >= 0 ? trimmed.slice(start + 1) : trimmed;
24924
+ let body;
24925
+ try {
24926
+ body = JSON.parse(candidate);
24927
+ } catch {
24928
+ return { ok: false, detail: "unparseable_envelope", text: trimmed };
24929
+ }
24930
+ const text = typeof body.result === "string" ? body.result : void 0;
24931
+ if (body.is_error === true || typeof body.subtype === "string" && body.subtype !== "success") {
24932
+ return { ok: false, detail: typeof body.subtype === "string" ? body.subtype : "is_error", text };
24933
+ }
24934
+ const structured = body.structured_output;
24935
+ return { ok: true, ...structured !== void 0 && structured !== null ? { result: structured } : {}, ...text !== void 0 ? { text } : {} };
24936
+ }
24937
+ function readOutput(value, job) {
24938
+ if (value.result !== void 0) {
24939
+ const size = Buffer.byteLength(JSON.stringify(value.result) ?? "");
24940
+ if (size > outputByteCeiling(job.maxOutputTokens)) return { ok: false, detail: "output_too_large" };
24941
+ return { ok: true, output: value.result };
24942
+ }
24943
+ const text = (value.text ?? "").trim();
24944
+ if (!text) return { ok: false, detail: "empty_output" };
24945
+ if (Buffer.byteLength(text) > outputByteCeiling(job.maxOutputTokens)) return { ok: false, detail: "output_too_large" };
24946
+ if (job.outputFormat !== "json") return { ok: true, output: text };
24947
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```$/.exec(text);
24948
+ try {
24949
+ return { ok: true, output: JSON.parse(fenced?.[1] ?? text) };
24950
+ } catch {
24951
+ return { ok: false, detail: "output_not_json" };
24952
+ }
24953
+ }
24954
+ function killRunner(child, platform = process.platform, killTreeFn) {
24955
+ const pid = child.pid;
24956
+ if (typeof pid !== "number") return;
24957
+ if (platform === "win32") {
24958
+ try {
24959
+ if (killTreeFn) killTreeFn(pid);
24960
+ else {
24961
+ const done = spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true });
24962
+ if (done.error || done.status !== 0) child.kill("SIGKILL");
24963
+ }
24964
+ } catch {
24965
+ try {
24966
+ child.kill("SIGKILL");
24967
+ } catch {
24968
+ }
24969
+ }
24970
+ return;
24971
+ }
24972
+ try {
24973
+ process.kill(-pid, "SIGKILL");
24974
+ } catch {
24975
+ }
24976
+ }
24977
+ async function runJob(job, options = {}) {
24978
+ const started = Date.now();
24979
+ const policy = options.policy ?? FALLBACK_POLICY;
24980
+ const runner = RUNNER_FOR_FAMILY[job.model.family];
24981
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
24982
+ const log = options.log ?? (() => {
24983
+ });
24984
+ const spawnFn = options.spawnFn ?? spawn6;
24985
+ const platform = options.platform ?? process.platform;
24986
+ const resolved = options.binary ?? runnerBinary(runner);
24987
+ if (!resolved) {
24988
+ return { ok: false, release: "runner_unavailable", detail: `missing:${runner}`, durationMs: Date.now() - started };
24989
+ }
24990
+ const cwd = path40.join(os9.tmpdir(), `genex-player-${randomUUID3()}`);
24991
+ await fs36.mkdir(cwd, { recursive: true, mode: 448 });
24992
+ const outFile = path40.join(cwd, "result.out");
24993
+ let argv;
24994
+ if (runner === "claude") {
24995
+ argv = claudeArgv(job, policy.claude ?? FALLBACK_POLICY.claude ?? {});
24996
+ } else {
24997
+ let schemaFile;
24998
+ if (job.outputFormat === "json" && job.schema !== void 0) {
24999
+ schemaFile = path40.join(cwd, "schema.json");
25000
+ await fs36.writeFile(schemaFile, JSON.stringify(job.schema), { mode: 384 });
25001
+ }
25002
+ argv = codexArgv(job, policy.codex ?? FALLBACK_POLICY.codex ?? {}, { cwd, outFile, ...schemaFile ? { schemaFile } : {} });
25003
+ }
25004
+ log(` ${runner} ${job.model.cliModel} (no tools)`);
25005
+ try {
25006
+ const child = spawnFn(resolved, argv, {
25007
+ cwd,
25008
+ stdio: ["pipe", "pipe", "pipe"],
25009
+ // The player's own environment, minus anything that would redirect the
25010
+ // binary off their subscription and onto a billed API account.
25011
+ env: {
25012
+ ...process.env,
25013
+ ANTHROPIC_API_KEY: void 0,
25014
+ ANTHROPIC_AUTH_TOKEN: void 0,
25015
+ ANTHROPIC_BASE_URL: void 0,
25016
+ OPENAI_API_KEY: void 0,
25017
+ OPENAI_BASE_URL: void 0
25018
+ },
25019
+ // A killable process GROUP on POSIX. On Windows it buys nothing — there
25020
+ // are no groups — and `killRunner` uses taskkill there instead.
25021
+ detached: platform !== "win32"
25022
+ });
25023
+ child.stdin?.end(job.user);
25024
+ const out = [];
25025
+ const err = [];
25026
+ child.stdout?.on("data", (d) => out.push(d.toString()));
25027
+ child.stderr?.on("data", (d) => err.push(d.toString()));
25028
+ const kill = () => killRunner(child, platform, options.killTreeFn);
25029
+ const code = await new Promise((resolve) => {
25030
+ const timer = setTimeout(() => {
25031
+ kill();
25032
+ resolve(null);
25033
+ }, timeoutMs);
25034
+ const onAbort = () => {
25035
+ kill();
25036
+ resolve("aborted");
25037
+ };
25038
+ options.signal?.addEventListener("abort", onAbort, { once: true });
25039
+ const done = (value) => {
25040
+ clearTimeout(timer);
25041
+ options.signal?.removeEventListener("abort", onAbort);
25042
+ resolve(value);
25043
+ };
25044
+ child.once("error", () => done("spawn-failed"));
25045
+ child.once("close", (c2) => done(c2));
25046
+ });
25047
+ const durationMs = Date.now() - started;
25048
+ if (code === "spawn-failed") return { ok: false, release: "runner_unavailable", detail: `missing:${runner}`, durationMs };
25049
+ if (code === "aborted") return { ok: false, release: "runner_unavailable", detail: "shutdown", exitCode: null, durationMs };
25050
+ if (code === null) return { ok: false, release: "failed", detail: "timeout", exitCode: null, durationMs };
25051
+ const stderrText = err.join("");
25052
+ if (runner === "claude") {
25053
+ const envelope = parseClaudeEnvelope(out.join(""));
25054
+ if (!envelope.ok) {
25055
+ const classified = classifyFailure(`${envelope.detail ?? ""} ${envelope.text ?? ""} ${stderrText}`);
25056
+ return { ok: false, ...classified, detail: envelope.detail ?? "failed", exitCode: code, durationMs };
25057
+ }
25058
+ const read2 = readOutput(envelope, job);
25059
+ if (!read2.ok) return { ok: false, release: "failed", detail: read2.detail, exitCode: code, durationMs };
25060
+ return { ok: true, output: read2.output, exitCode: code, durationMs };
25061
+ }
25062
+ const text = await fs36.readFile(outFile, "utf8").catch(() => "");
25063
+ if (code !== 0 && !text.trim()) {
25064
+ const classified = classifyFailure(`${stderrText} exit ${code}`);
25065
+ return { ok: false, ...classified, detail: `exit_${code}`, exitCode: code, durationMs };
25066
+ }
25067
+ const read = readOutput({ text }, job);
25068
+ if (!read.ok) return { ok: false, release: "failed", detail: read.detail, exitCode: code, durationMs };
25069
+ return { ok: true, output: read.output, exitCode: code, durationMs };
25070
+ } finally {
25071
+ await fs36.rm(cwd, { recursive: true, force: true }).catch(() => {
25072
+ });
25073
+ }
25074
+ }
25075
+
25076
+ // src/lib/player/service.ts
25077
+ import fs37 from "fs/promises";
25078
+ import os10 from "os";
25079
+ import path41 from "path";
25080
+ import { execFile } from "child_process";
25081
+ import { promisify } from "util";
25082
+ var run2 = promisify(execFile);
25083
+ var SERVICE_LABEL = "games.genex.player";
25084
+ var SYSTEMD_UNIT = "genex-player.service";
25085
+ var PLAYER_AUTOSTART_SPEC = "@genex-ai/cli-demo@latest";
25086
+ var SUPERVISED_ENV = "GENEX_PLAYER_SUPERVISED";
25087
+ function supervisedWatcher(env = process.env) {
25088
+ return env[SUPERVISED_ENV] === "1";
25089
+ }
25090
+ function servicePlatform(platform = process.platform) {
25091
+ if (platform === "darwin" || platform === "win32") return platform;
25092
+ return "linux";
25093
+ }
25094
+ function servicePaths(platform, home = os10.homedir()) {
25095
+ const { log, errorLog } = playerPaths(home);
25096
+ if (platform === "darwin") return { log, errorLog, unit: path41.join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`) };
25097
+ if (platform === "linux") return { log, errorLog, unit: path41.join(home, ".config", "systemd", "user", SYSTEMD_UNIT) };
25098
+ return { log, errorLog, unit: path41.join(home, "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "genex-player.cmd") };
25099
+ }
25100
+ function xmlEscape(value) {
25101
+ return value.replace(/[<>&'"]/g, (ch) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[ch]);
25102
+ }
25103
+ function unitFile(platform, input, paths) {
25104
+ if (platform === "darwin") {
25105
+ return `<?xml version="1.0" encoding="UTF-8"?>
25106
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
25107
+ <plist version="1.0">
25108
+ <dict>
25109
+ <key>Label</key><string>${SERVICE_LABEL}</string>
25110
+ <key>ProgramArguments</key>
25111
+ <array>
25112
+ ${[input.launcher, ...input.args].map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n")}
25113
+ </array>
25114
+ <key>EnvironmentVariables</key>
25115
+ <dict>
25116
+ <key>PATH</key><string>${xmlEscape(input.pathEnv)}</string>
25117
+ <key>HOME</key><string>${xmlEscape(input.home)}</string>
25118
+ <!-- Tells the watcher a supervisor started it, so an update is taken by
25119
+ stopping (exit 1, restarted below, resolving @latest again) rather
25120
+ than by spawning a detached grandchild: the parent's exit IS this
25121
+ job's exit, and that grandchild would have run unsupervised. -->
25122
+ <key>${SUPERVISED_ENV}</key><string>1</string>
25123
+ </dict>
25124
+ <key>RunAtLoad</key><true/>
25125
+ <!-- KeepAlive, but NOT unconditionally: exit 0 means the watcher decided to
25126
+ stop (a signal, or a credential only a person with a browser can
25127
+ replace), and restarting that every 30 seconds is an invisible loop on
25128
+ somebody else's machine. A crash comes back \u2014 and so does an update,
25129
+ which exits non-zero on purpose and is bounded by the relaunch cooldown. -->
25130
+ <key>KeepAlive</key>
25131
+ <dict><key>SuccessfulExit</key><false/></dict>
25132
+ <key>ThrottleInterval</key><integer>30</integer>
25133
+ <key>StandardOutPath</key><string>${xmlEscape(paths.log)}</string>
25134
+ <key>StandardErrorPath</key><string>${xmlEscape(paths.errorLog)}</string>
25135
+ </dict>
25136
+ </plist>
25137
+ `;
25138
+ }
25139
+ if (platform === "linux") {
25140
+ return `[Unit]
25141
+ Description=Genex player watcher \u2014 answer the in-game requests you approved, on your own subscription
25142
+ After=network-online.target
25143
+
25144
+ [Service]
25145
+ Type=simple
25146
+ ExecStart=${[input.launcher, ...input.args].map((a) => JSON.stringify(a)).join(" ")}
25147
+ Environment=PATH=${input.pathEnv}
25148
+ Environment=HOME=${input.home}
25149
+ # The supervisor marker: an update is taken by stopping and letting this unit
25150
+ # start the current version, never by spawning a child systemd is not watching.
25151
+ Environment=${SUPERVISED_ENV}=1
25152
+ # on-failure rather than always, for the reason spelled out in the plist above:
25153
+ # a clean exit is a decision (signal, dead credential) and must stay stopped; a
25154
+ # crash comes back, and so does an update, which exits non-zero on purpose.
25155
+ Restart=on-failure
25156
+ RestartSec=30
25157
+ StandardOutput=append:${paths.log}
25158
+ StandardError=append:${paths.errorLog}
25159
+
25160
+ [Install]
25161
+ WantedBy=default.target
25162
+ `;
25163
+ }
25164
+ return `@echo off\r
25165
+ set "PATH=${input.pathEnv}"\r
25166
+ start "" /b "${input.launcher}" ${input.args.map((a) => `"${a}"`).join(" ")} >> "${paths.log}" 2>> "${paths.errorLog}"\r
25167
+ `;
25168
+ }
25169
+ function autostartCommand(opts) {
25170
+ const npx = resolveOnPath("npx");
25171
+ const tail = ["player", "run", ...opts.apiUrl ? ["--api-url", opts.apiUrl] : []];
25172
+ if (npx) return { launcher: npx, args: ["-y", PLAYER_AUTOSTART_SPEC, ...tail], fallback: false };
25173
+ return { launcher: process.execPath, args: [process.argv[1] ?? "", ...tail], fallback: true };
25174
+ }
25175
+ async function installService(input, platform = servicePlatform()) {
25176
+ const paths = servicePaths(platform, input.home);
25177
+ await fs37.mkdir(path41.dirname(paths.unit), { recursive: true });
25178
+ await fs37.mkdir(path41.dirname(paths.log), { recursive: true, mode: 448 });
25179
+ await fs37.writeFile(paths.unit, unitFile(platform, input, paths), { mode: platform === "win32" ? 420 : 384 });
25180
+ if (playerDryRun()) return { unit: paths.unit, loaded: false, detail: "dry run \u2014 the unit was written, the system was not asked" };
25181
+ if (platform === "darwin") {
25182
+ const domain = `gui/${process.getuid?.() ?? 501}`;
25183
+ await run2("launchctl", ["bootout", domain, paths.unit]).catch(() => {
25184
+ });
25185
+ try {
25186
+ await run2("launchctl", ["bootstrap", domain, paths.unit]);
25187
+ } catch (error) {
25188
+ return { unit: paths.unit, loaded: false, detail: `launchctl refused it: ${String(error.message).split("\n")[0]}` };
25189
+ }
25190
+ const printed2 = await run2("launchctl", ["print", `${domain}/${SERVICE_LABEL}`]).then((r) => r.stdout).catch(() => "");
25191
+ return { unit: paths.unit, loaded: printed2.includes(SERVICE_LABEL), detail: printed2.includes("state = running") ? "running" : "registered" };
25192
+ }
25193
+ if (platform === "linux") {
25194
+ await run2("systemctl", ["--user", "daemon-reload"]).catch(() => {
25195
+ });
25196
+ try {
25197
+ await run2("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT]);
25198
+ } catch (error) {
25199
+ return { unit: paths.unit, loaded: false, detail: `systemctl refused it: ${String(error.message).split("\n")[0]}` };
25200
+ }
25201
+ const active = await run2("systemctl", ["--user", "is-active", SYSTEMD_UNIT]).then((r) => r.stdout.trim()).catch(() => "unknown");
25202
+ return { unit: paths.unit, loaded: active === "active", detail: active };
25203
+ }
25204
+ return { unit: paths.unit, loaded: true, detail: "starts at your next sign-in" };
25205
+ }
25206
+ async function serviceInstalled(home = os10.homedir(), platform = servicePlatform()) {
25207
+ const paths = servicePaths(platform, home);
25208
+ return await fs37.stat(paths.unit).then(() => paths.unit, () => null);
25209
+ }
25210
+ async function stopService(home = os10.homedir(), platform = servicePlatform()) {
25211
+ const paths = servicePaths(platform, home);
25212
+ const present = await serviceInstalled(home, platform);
25213
+ if (playerDryRun()) return { unit: paths.unit, loaded: false, detail: present ? "dry run \u2014 the system was not asked" : "nothing installed" };
25214
+ if (platform === "darwin") {
25215
+ const out2 = await run2("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, paths.unit]).then(() => "stopped").catch(() => "was not running");
25216
+ return { unit: paths.unit, loaded: false, detail: out2 };
25217
+ }
25218
+ if (platform === "linux") {
25219
+ const out2 = await run2("systemctl", ["--user", "stop", SYSTEMD_UNIT]).then(() => "stopped").catch(() => "was not running");
25220
+ return { unit: paths.unit, loaded: false, detail: out2 };
25221
+ }
25222
+ const out = await run2("taskkill", ["/IM", "genex-player.cmd", "/F"]).then(() => "stopped").catch(() => "was not running");
25223
+ return { unit: paths.unit, loaded: false, detail: out };
25224
+ }
25225
+ async function uninstallService(home = os10.homedir(), platform = servicePlatform()) {
25226
+ const paths = servicePaths(platform, home);
25227
+ const present = await serviceInstalled(home, platform);
25228
+ if (!playerDryRun()) {
25229
+ if (platform === "darwin") await run2("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, paths.unit]).catch(() => {
25230
+ });
25231
+ if (platform === "linux") {
25232
+ await run2("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT]).catch(() => {
25233
+ });
25234
+ await run2("systemctl", ["--user", "daemon-reload"]).catch(() => {
25235
+ });
25236
+ }
25237
+ }
25238
+ await fs37.rm(paths.unit, { force: true }).catch(() => {
25239
+ });
25240
+ return { unit: paths.unit, loaded: false, detail: present ? "removed" : "nothing installed" };
25241
+ }
25242
+
25243
+ // src/lib/player/relaunch.ts
25244
+ import fs38 from "fs/promises";
25245
+ import fsSync2 from "fs";
25246
+ import path42 from "path";
25247
+ import { spawn as spawn7 } from "child_process";
25248
+ var RELAUNCH_COOLDOWN_MS = 10 * 60 * 1e3;
25249
+ function relaunchPlan(input) {
25250
+ const base = autostartCommand({ ...input.apiUrl ? { apiUrl: input.apiUrl } : {} });
25251
+ if (base.fallback) {
25252
+ return { launcher: base.launcher, args: base.args };
25253
+ }
25254
+ const spec = input.version && input.version !== "latest" ? `@genex-ai/cli-demo@${input.version}` : PLAYER_AUTOSTART_SPEC;
25255
+ const sub = input.sub ?? "run";
25256
+ return {
25257
+ launcher: base.launcher,
25258
+ args: [
25259
+ "-y",
25260
+ spec,
25261
+ "player",
25262
+ sub,
25263
+ "--relaunched",
25264
+ ...input.extraArgs ?? [],
25265
+ ...input.apiUrl ? ["--api-url", input.apiUrl] : []
25266
+ ]
25267
+ };
25268
+ }
25269
+ function relaunchPossible() {
25270
+ return !autostartCommand({}).fallback;
25271
+ }
25272
+ async function relaunchOnCooldown(home, now = Date.now()) {
25273
+ try {
25274
+ const raw = await fs38.readFile(playerPaths(home).relaunch, "utf8");
25275
+ const at = Date.parse(JSON.parse(raw).at ?? "");
25276
+ return Number.isFinite(at) && now - at < RELAUNCH_COOLDOWN_MS;
25277
+ } catch {
25278
+ return false;
25279
+ }
25280
+ }
25281
+ async function markRelaunch(version, home) {
25282
+ const file = playerPaths(home).relaunch;
25283
+ try {
25284
+ await fs38.mkdir(path42.dirname(file), { recursive: true, mode: 448 });
25285
+ await fs38.writeFile(file, JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), version }) + "\n", { mode: 384 });
25286
+ } catch {
25287
+ }
25288
+ }
25289
+ async function relaunchWatcher(input) {
25290
+ const plan = relaunchPlan(input);
25291
+ const log = input.log ?? (() => {
25292
+ });
25293
+ if (input.relaunched) return { launched: false, plan, reason: "relaunched_child" };
25294
+ if (!relaunchPossible()) return { launched: false, plan, reason: "no_npx" };
25295
+ if (await relaunchOnCooldown(input.home)) return { launched: false, plan, reason: "cooldown" };
25296
+ await markRelaunch(input.version, input.home);
25297
+ if (supervisedWatcher()) {
25298
+ log(`player: stopping so the login item restarts as ${input.version} (it resolves ${PLAYER_AUTOSTART_SPEC} each start)`);
25299
+ return { launched: false, plan, reason: "supervisor_restarts" };
25300
+ }
25301
+ if (playerDryRun()) {
25302
+ log(`player: would relaunch: ${JSON.stringify([plan.launcher, ...plan.args])}`);
25303
+ return { launched: true, plan, reason: "dry_run" };
25304
+ }
25305
+ let out;
25306
+ let err;
25307
+ const paths = playerPaths(input.home);
25308
+ try {
25309
+ await fs38.mkdir(paths.logDir, { recursive: true, mode: 448 });
25310
+ out = fsSync2.openSync(paths.log, "a", 384);
25311
+ err = fsSync2.openSync(paths.errorLog, "a", 384);
25312
+ } catch {
25313
+ }
25314
+ try {
25315
+ const child = spawn7(plan.launcher, plan.args, {
25316
+ detached: true,
25317
+ stdio: ["ignore", out ?? "ignore", err ?? "ignore"]
25318
+ });
25319
+ child.on("error", (reason) => log(`player: relaunch could not start (${reason.message})`));
25320
+ child.unref();
25321
+ } finally {
25322
+ for (const fd of [out, err]) if (fd !== void 0) try {
25323
+ fsSync2.closeSync(fd);
25324
+ } catch {
25325
+ }
25326
+ }
25327
+ log(`player: relaunching as ${input.version}`);
25328
+ return { launched: true, plan };
25329
+ }
25330
+
25331
+ // src/lib/player/loop.ts
25332
+ import os11 from "os";
25333
+ var POLICY_REFRESH_MS = 5 * 6e4;
25334
+ var MAX_HEARTBEAT_MS = 6e4;
25335
+ var LEASE_RENEW_MS = 45e3;
25336
+ var JOB_TIMEOUT_MS = 10 * 6e4;
25337
+ var SHUTDOWN_RETRY_SECONDS = 5;
25338
+ var MAX_PROVIDER_COOLDOWN_MS = 60 * 6e4;
25339
+ var sleep5 = (ms) => new Promise((r) => setTimeout(r, ms));
25340
+ function platformTag(platform = process.platform) {
25341
+ return platform.toLowerCase().replace(/[^a-z0-9_-]/g, "").slice(0, 40) || "unknown";
25342
+ }
25343
+ function backoffMs(policy, attempt, random = Math.random) {
25344
+ const b = policy.backoff ?? FALLBACK_POLICY.backoff;
25345
+ const raw = Math.min(b.maxSeconds, b.initialSeconds * Math.pow(b.factor, Math.max(0, attempt - 1)));
25346
+ const jitter = 1 + (random() * 2 - 1) * b.jitterRatio;
25347
+ return Math.max(1e3, Math.round(raw * jitter * 1e3));
25348
+ }
25349
+ function isNewer(a, b) {
25350
+ const pa = /^(\d+)\.(\d+)\.(\d+)/.exec(a);
25351
+ const pb = /^(\d+)\.(\d+)\.(\d+)/.exec(b);
25352
+ if (!pa || !pb) return false;
25353
+ for (let i = 1; i <= 3; i++) {
25354
+ const x = Number(pa[i]);
25355
+ const y = Number(pb[i]);
25356
+ if (x !== y) return x > y;
25357
+ }
25358
+ return false;
25359
+ }
25360
+ function workable(row, watcherId, now = Date.now()) {
25361
+ if (!row.runner) return false;
25362
+ if (!row.claimedBy || row.claimedBy === watcherId) return true;
25363
+ const until = Date.parse(row.leaseExpiresAt ?? "");
25364
+ return Number.isFinite(until) && until <= now;
25365
+ }
25366
+ async function runWatcherLoop(options) {
25367
+ const { client, install: install2, log } = options;
25368
+ const runJobFn = options.runJobFn ?? runJob;
25369
+ const keepGoing = options.keepGoing ?? (() => true);
25370
+ const relaunchReady = options.relaunchReady ?? (() => true);
25371
+ const label = install2.label;
25372
+ let policy = FALLBACK_POLICY;
25373
+ let runners = detectRunners();
25374
+ let watcherId = install2.watcherId ?? "";
25375
+ let policyAt = 0;
25376
+ let heartbeatAt = 0;
25377
+ let failures = 0;
25378
+ let warnedNoRunners = false;
25379
+ let warnedStuckVersion = false;
25380
+ let stop2 = null;
25381
+ const held = { row: null };
25382
+ const cooldown = /* @__PURE__ */ new Map();
25383
+ const state = {
25384
+ v: 1,
25385
+ pid: process.pid,
25386
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
25387
+ lastHeartbeatAt: null,
25388
+ lastPollAt: null,
25389
+ lastJobAt: null,
25390
+ watcherId: watcherId || null,
25391
+ policyVersion: null,
25392
+ runners,
25393
+ jobsCompleted: 0,
25394
+ jobsReleased: 0,
25395
+ lastError: null
25396
+ };
25397
+ const shutdown = new AbortController();
25398
+ const onSignal = (signal) => {
25399
+ if (stop2) return;
25400
+ stop2 = { kind: "signal", signal };
25401
+ shutdown.abort();
25402
+ log(`player: ${signal} \u2014 handing back anything in flight`);
25403
+ };
25404
+ const sigint = () => onSignal("SIGINT");
25405
+ const sigterm = () => onSignal("SIGTERM");
25406
+ process.on("SIGINT", sigint);
25407
+ process.on("SIGTERM", sigterm);
25408
+ try {
25409
+ for (; ; ) {
25410
+ if (stop2 || !keepGoing()) break;
25411
+ const now = Date.now();
25412
+ if (now >= policyAt) {
25413
+ try {
25414
+ policy = await client.policy();
25415
+ policyAt = now + POLICY_REFRESH_MS;
25416
+ state.policyVersion = policy.version ?? null;
25417
+ runners = detectRunners();
25418
+ state.runners = runners;
25419
+ warnedNoRunners = false;
25420
+ failures = 0;
25421
+ } catch (error) {
25422
+ const fatal = classifyFatal(error);
25423
+ if (fatal) {
25424
+ stop2 = fatal;
25425
+ break;
25426
+ }
25427
+ failures += 1;
25428
+ policyAt = now + POLICY_REFRESH_MS;
25429
+ log(`player: policy unreadable (${describe(error)}); using the built-in one`);
25430
+ }
25431
+ }
25432
+ if (now >= heartbeatAt) {
25433
+ try {
25434
+ const beat = await client.heartbeat({
25435
+ ...watcherId ? { watcherId } : {},
25436
+ platform: platformTag(),
25437
+ runners,
25438
+ ...label ? { label } : {}
25439
+ });
25440
+ watcherId = beat.watcherId;
25441
+ state.watcherId = watcherId;
25442
+ state.lastHeartbeatAt = (/* @__PURE__ */ new Date()).toISOString();
25443
+ state.policyVersion = beat.policyVersion ?? state.policyVersion;
25444
+ heartbeatAt = now + Math.min(MAX_HEARTBEAT_MS, Math.max(5e3, (beat.pollIntervalSeconds || 5) * 1e3 * 6));
25445
+ failures = 0;
25446
+ if (isNewer(beat.latestVersion, client.version)) {
25447
+ if (await relaunchReady(beat.latestVersion)) {
25448
+ stop2 = { kind: "update_available", latestVersion: beat.latestVersion };
25449
+ break;
25450
+ }
25451
+ if (!warnedStuckVersion) {
25452
+ warnedStuckVersion = true;
25453
+ log(`player: ${beat.latestVersion} is current and this is ${client.version}, but nothing here can fetch it \u2014 still answering`);
25454
+ }
25455
+ }
25456
+ } catch (error) {
25457
+ const fatal = classifyFatal(error);
25458
+ if (fatal) {
25459
+ stop2 = fatal;
25460
+ break;
25461
+ }
25462
+ failures += 1;
25463
+ state.lastError = describe(error);
25464
+ heartbeatAt = now + backoffMs(policy, failures);
25465
+ log(`player: heartbeat failed (${describe(error)})`);
25466
+ await sleep5(backoffMs(policy, failures));
25467
+ continue;
25468
+ }
25469
+ }
25470
+ const providers = [...new Set(runners.map((r) => PROVIDER_FOR_RUNNER[r]))];
25471
+ if (providers.length === 0 && !warnedNoRunners) {
25472
+ warnedNoRunners = true;
25473
+ log("player: no claude or codex binary here \u2014 nothing can be answered yet");
25474
+ }
25475
+ let worked = false;
25476
+ for (const provider of providers) {
25477
+ if (stop2) break;
25478
+ const until = cooldown.get(provider) ?? 0;
25479
+ if (until > Date.now()) continue;
25480
+ if (until) cooldown.delete(provider);
25481
+ let pending;
25482
+ try {
25483
+ pending = await client.pending(provider);
25484
+ state.lastPollAt = (/* @__PURE__ */ new Date()).toISOString();
25485
+ failures = 0;
25486
+ } catch (error) {
25487
+ const fatal = classifyFatal(error);
25488
+ if (fatal) {
25489
+ stop2 = fatal;
25490
+ break;
25491
+ }
25492
+ failures += 1;
25493
+ state.lastError = describe(error);
25494
+ log(`player: poll failed (${describe(error)})`);
25495
+ await sleep5(backoffMs(policy, failures));
25496
+ continue;
25497
+ }
25498
+ for (const row of pending) {
25499
+ if (stop2) break;
25500
+ if (!workable(row, watcherId)) continue;
25501
+ if (await handleOne(row, provider)) worked = true;
25502
+ break;
25503
+ }
25504
+ }
25505
+ if (stop2) break;
25506
+ await writePlayerState(state, options.home);
25507
+ if (options.once) {
25508
+ stop2 = { kind: "once" };
25509
+ break;
25510
+ }
25511
+ const interval = options.pollIntervalMsOverride ?? Math.max(1e3, (policy.pollIntervalSeconds || 5) * 1e3);
25512
+ if (!worked) await sleep5(interval);
25513
+ }
25514
+ } finally {
25515
+ process.off("SIGINT", sigint);
25516
+ process.off("SIGTERM", sigterm);
25517
+ const stranded = held.row;
25518
+ if (stranded && watcherId) {
25519
+ await client.release(stranded.id, stranded.provider, {
25520
+ watcherId,
25521
+ reason: "runner_unavailable",
25522
+ retryAfterSeconds: SHUTDOWN_RETRY_SECONDS
25523
+ }).catch(() => {
25524
+ });
25525
+ held.row = null;
25526
+ state.jobsReleased += 1;
25527
+ log(`player: released ${stranded.id} (shutdown)`);
25528
+ }
25529
+ await writePlayerState(state, options.home);
25530
+ }
25531
+ return { stop: stop2 ?? { kind: "once" }, state };
25532
+ async function handleOne(row, provider) {
25533
+ const job = row.runner;
25534
+ let claimed;
25535
+ try {
25536
+ claimed = await client.claim(row.id, provider, watcherId);
25537
+ } catch (error) {
25538
+ if (error instanceof PlayerApiError && (error.code === "already_claimed" || error.code === "external_request_not_pending")) {
25539
+ log(`player: ${row.id} taken by another machine`);
25540
+ return false;
25541
+ }
25542
+ const fatal = classifyFatal(error);
25543
+ if (fatal) {
25544
+ stop2 = fatal;
25545
+ return false;
25546
+ }
25547
+ failures += 1;
25548
+ log(`player: could not claim ${row.id} (${describe(error)})`);
25549
+ await sleep5(backoffMs(policy, failures));
25550
+ return false;
25551
+ }
25552
+ held.row = { id: row.id, provider };
25553
+ const live = claimed.runner ?? job;
25554
+ const renew = setInterval(() => {
25555
+ void client.claim(row.id, provider, watcherId).catch(() => {
25556
+ });
25557
+ }, LEASE_RENEW_MS);
25558
+ renew.unref?.();
25559
+ log(`player: ${row.id} ${live.mode} ${live.model.cliModel} running`);
25560
+ let outcome2;
25561
+ try {
25562
+ outcome2 = await runJobFn(live, {
25563
+ policy,
25564
+ timeoutMs: Math.min(JOB_TIMEOUT_MS, Math.max(3e4, (live.leaseSeconds || 120) * 1e3 * 5)),
25565
+ signal: shutdown.signal,
25566
+ log
25567
+ });
25568
+ } finally {
25569
+ clearInterval(renew);
25570
+ }
25571
+ state.lastJobAt = (/* @__PURE__ */ new Date()).toISOString();
25572
+ const shuttingDown = stop2 !== null;
25573
+ if (!outcome2.ok || shuttingDown) {
25574
+ const reason = shuttingDown ? "runner_unavailable" : outcome2.release ?? "failed";
25575
+ const retryAfterSeconds = shuttingDown ? SHUTDOWN_RETRY_SECONDS : outcome2.retryAfterSeconds ?? (reason === "rate_limited" ? (policy.backoff ?? FALLBACK_POLICY.backoff).maxSeconds : void 0);
25576
+ state.jobsReleased += 1;
25577
+ state.lastError = reason;
25578
+ try {
25579
+ await client.release(row.id, provider, { watcherId, reason, ...retryAfterSeconds ? { retryAfterSeconds } : {} });
25580
+ held.row = null;
25581
+ } catch (error) {
25582
+ const fatal = classifyFatal(error);
25583
+ if (fatal) {
25584
+ stop2 = fatal;
25585
+ held.row = null;
25586
+ return true;
25587
+ }
25588
+ log(`player: release of ${row.id} failed (${describe(error)})`);
25589
+ }
25590
+ log(`player: ${row.id} released ${reason}${outcome2.detail ? ` (${outcome2.detail})` : ""} after ${outcome2.durationMs}ms`);
25591
+ if (reason === "rate_limited" || reason === "auth_expired") {
25592
+ const waitMs = Math.min(MAX_PROVIDER_COOLDOWN_MS, Math.max(
25593
+ 1e3,
25594
+ (retryAfterSeconds ?? (policy.backoff ?? FALLBACK_POLICY.backoff).maxSeconds) * 1e3
25595
+ ));
25596
+ cooldown.set(provider, Date.now() + waitMs);
25597
+ log(`player: ${provider} waits ${Math.round(waitMs / 1e3)}s before the next request`);
25598
+ }
25599
+ if (reason === "auth_expired") {
25600
+ const binary = live.model.family === "claude" ? "claude" : "codex";
25601
+ log(`player: your ${binary} CLI is not signed in \u2014 run \`${binary} login\` on this machine`);
25602
+ }
25603
+ return true;
25604
+ }
25605
+ try {
25606
+ const receipt = await client.submit(row.id, provider, watcherId, outcome2.output);
25607
+ held.row = null;
25608
+ state.jobsCompleted += 1;
25609
+ log(`player: ${row.id} delivered in ${outcome2.durationMs}ms (${receipt.status})`);
25610
+ } catch (error) {
25611
+ const fatal = classifyFatal(error);
25612
+ if (fatal) {
25613
+ stop2 = fatal;
25614
+ return true;
25615
+ }
25616
+ state.lastError = describe(error);
25617
+ log(`player: ${row.id} could not be submitted (${describe(error)})`);
25618
+ await client.release(row.id, provider, { watcherId, reason: "failed" }).catch(() => {
25619
+ });
25620
+ held.row = null;
25621
+ state.jobsReleased += 1;
25622
+ }
25623
+ return true;
25624
+ }
25625
+ }
25626
+ function classifyFatal(error) {
25627
+ if (error instanceof PlayerUpdateRequiredError) {
25628
+ return { kind: "update_required", minVersion: error.minVersion, latestVersion: error.latestVersion };
25629
+ }
25630
+ if (error instanceof PlayerAuthError) return { kind: "auth", status: error.status, code: error.code };
25631
+ return null;
25632
+ }
25633
+ function describe(error) {
25634
+ if (error instanceof PlayerApiError) return `${error.status}${error.code ? ` ${error.code}` : ""}`;
25635
+ if (error instanceof Error) return error.name === "TimeoutError" ? "timeout" : error.name;
25636
+ return "error";
25637
+ }
25638
+ function defaultLabel2() {
25639
+ try {
25640
+ return os11.hostname().replace(/[^\w .@-]/g, "").trim().slice(0, 64) || "This machine";
25641
+ } catch {
25642
+ return "This machine";
25643
+ }
25644
+ }
25645
+
25646
+ // src/commands/player.ts
25647
+ var SUBS4 = ["install", "run", "status", "stop", "uninstall"];
25648
+ var PLAYER_NOT_INSTALLED = "This machine is not set up to answer in-game model requests \u2014 run `genex player install` first.";
25649
+ var PLAYER_REAUTH_REQUIRED = "Genex no longer accepts this machine's approval \u2014 run `genex player install` again.";
25650
+ 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.";
25651
+ async function runPlayer(opts = {}) {
25652
+ const log = createLogger({ quiet: opts.quiet || opts.json });
25653
+ const sub = opts.name?.trim() || "status";
25654
+ if (!SUBS4.includes(sub)) {
25655
+ fail3(opts, log, `player ${sub}`, `Unknown subcommand ${sub}. Use: ${SUBS4.join(", ")}.`);
25656
+ return;
25657
+ }
25658
+ if (opts.dryRun) process.env[PLAYER_DRY_RUN_ENV] = "1";
25659
+ if (sub === "install") return await install(opts, log);
25660
+ if (sub === "run") return await run3(opts, log);
25661
+ if (sub === "status") return await status(opts, log);
25662
+ if (sub === "stop") return await stop(opts, log);
25663
+ return await uninstall(opts, log);
25664
+ }
25665
+ async function install(opts, log) {
25666
+ const apiUrl = normalizeApiOrigin(getApiUrl(opts.apiUrl));
25667
+ const label = opts.playerLabel?.trim() || defaultLabel2();
25668
+ let approval;
25669
+ try {
25670
+ approval = await authorizePlayer(apiUrl, {
25671
+ log,
25672
+ label,
25673
+ ...opts.timeoutSec ? { waitMs: opts.timeoutSec * 1e3 } : {}
25674
+ });
25675
+ } catch (error) {
25676
+ fail3(opts, log, "player install", error instanceof Error ? error.message : String(error));
25677
+ return;
25678
+ }
25679
+ let record = {
25680
+ v: 1,
25681
+ apiUrl,
25682
+ token: approval.token,
25683
+ expiresAt: approval.expiresAt,
25684
+ label,
25685
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
25686
+ version: getCliVersion()
25687
+ };
25688
+ const credentialFile = await writePlayerInstall(record);
25689
+ const runners = detectRunners();
25690
+ const client = new PlayerClient({ apiUrl, token: approval.token });
25691
+ try {
25692
+ const beat = await client.heartbeat({ platform: platformTag(), runners, label });
25693
+ record = { ...record, watcherId: beat.watcherId };
25694
+ await writePlayerInstall(record);
25695
+ } catch (error) {
25696
+ if (error instanceof PlayerUpdateRequiredError) {
25697
+ const outcome2 = await relaunchWatcher({
25698
+ version: error.latestVersion,
25699
+ apiUrl,
25700
+ sub: "install",
25701
+ // The flags the refused install carried, so the newer one finishes
25702
+ // the same install rather than a default one.
25703
+ extraArgs: [
25704
+ ...opts.playerLabel ? ["--label", opts.playerLabel] : [],
25705
+ ...opts.noAutostart ? ["--no-autostart"] : []
25706
+ ],
25707
+ relaunched: Boolean(opts.relaunched),
25708
+ log: (line) => log.plain(line)
25709
+ });
25710
+ log.warn(`This Genex needs player watcher ${error.minVersion} or newer; ${error.latestVersion} is current.`);
25711
+ if (!outcome2.launched) {
25712
+ log.plain(` ${c.cyan("\u2192")} Update and re-run: ${c.cyan(`npx -y @genex-ai/cli-demo@${error.latestVersion} player install`)}`);
25713
+ fail3(opts, log, "player install", "player_update_required", { minVersion: error.minVersion, latestVersion: error.latestVersion });
25714
+ return;
25715
+ }
25716
+ log.plain(` ${c.cyan("\u2192")} Picked up by ${c.cyan(`@genex-ai/cli-demo@${error.latestVersion}`)} \u2014 it finishes the install.`);
25717
+ return;
25718
+ }
25719
+ 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)}).`);
25720
+ return;
25721
+ }
25722
+ const platform = servicePlatform();
25723
+ const paths = servicePaths(platform);
25724
+ let unit = null;
25725
+ let unitDetail = "not installed (--no-autostart)";
25726
+ let supervised = false;
25727
+ if (!opts.noAutostart) {
25728
+ const command = autostartCommand({ ...usesNonDefaultStand(opts, apiUrl) ? { apiUrl } : {} });
25729
+ const result = await installService({
25730
+ launcher: command.launcher,
25731
+ args: command.args,
25732
+ // See lib/player/service.ts: a login agent inherits almost no PATH, and
25733
+ // this program exists to run binaries that are rarely on the default one.
25734
+ pathEnv: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
25735
+ home: os12.homedir()
25736
+ }, platform);
25737
+ unit = result.unit;
25738
+ unitDetail = result.detail;
25739
+ supervised = result.loaded;
25740
+ if (command.fallback) {
25741
+ log.warn("No `npx` on this machine's PATH, so the login entry runs THIS copy of the CLI and will not update itself.");
25742
+ }
25743
+ }
25744
+ let started = supervised;
25745
+ if (!started && !playerDryRun()) started = await spawnDetachedRun(apiUrl, opts);
25746
+ if (opts.json) {
25747
+ writeJsonLine2({
25748
+ command: "player install",
25749
+ status: "ok",
25750
+ error: null,
25751
+ ...describeInstall(record),
25752
+ runners,
25753
+ credentialFile,
25754
+ autostart: unit,
25755
+ autostartDetail: unitDetail,
25756
+ running: started,
25757
+ log: paths.log
25758
+ });
25759
+ return;
25760
+ }
25761
+ log.plain("");
25762
+ log.success(`This machine can now answer the in-game model requests you approve, on your own subscription.`);
25763
+ log.plain("");
25764
+ log.plain(oneParagraph({ record, runners, unit, unitDetail, started, logFile: paths.log, credentialFile }));
25765
+ if (runners.length === 0) log.warn(PLAYER_NO_RUNNERS);
25766
+ }
25767
+ function oneParagraph(input) {
25768
+ const runnerText = input.runners.length === 0 ? "no runner yet" : input.runners.map((r) => r === "claude" ? "Claude Code" : "Codex").join(" and ");
25769
+ return [
25770
+ ` Genex approval stored in ${input.credentialFile} (only this machine can read it). It can`,
25771
+ " answer approved requests and spend nothing \u2014 no coins, no credits, no",
25772
+ " account changes. Your Claude or ChatGPT sign-in is never read, copied or",
25773
+ " sent anywhere; the watcher only starts the official CLI you already use.",
25774
+ ` Runners ${runnerText}`,
25775
+ input.unit ? ` Starts at login ${input.unit} (${input.unitDetail})` : " Starts at login no \u2014 run `genex player run` yourself",
25776
+ ` Running now ${input.started ? "yes" : "no \u2014 start it with `genex player run`"}`,
25777
+ ` Log ${input.logFile} (ids and timings only, never what a game asked for)`,
25778
+ " Stop it genex player stop \xB7 and for good: genex player uninstall",
25779
+ "",
25780
+ " You never need to paste anything again: approve a request in a game and the answer",
25781
+ " comes back on its own. Revoke this machine any time from your Genex dashboard."
25782
+ ].join("\n");
25783
+ }
25784
+ async function spawnDetachedRun(apiUrl, opts) {
25785
+ let out;
25786
+ let err;
25787
+ try {
25788
+ const paths = playerPaths();
25789
+ await fsp2.mkdir(paths.logDir, { recursive: true, mode: 448 });
25790
+ out = fs39.openSync(paths.log, "a", 384);
25791
+ err = fs39.openSync(paths.errorLog, "a", 384);
25792
+ const args = [process.argv[1] ?? "", "player", "run", ...opts.apiUrl ? ["--api-url", apiUrl] : []];
25793
+ const child = spawn8(process.execPath, args, { detached: true, stdio: ["ignore", out, err] });
25794
+ child.on("error", () => {
25795
+ });
25796
+ child.unref();
25797
+ return true;
25798
+ } catch {
25799
+ return false;
25800
+ } finally {
25801
+ for (const fd of [out, err]) if (fd !== void 0) try {
25802
+ fs39.closeSync(fd);
25803
+ } catch {
25804
+ }
25805
+ }
25806
+ }
25807
+ function usesNonDefaultStand(opts, apiUrl) {
25808
+ return apiUrl !== normalizeApiOrigin(getApiUrl()) || Boolean(opts.apiUrl);
25809
+ }
25810
+ async function run3(opts, log) {
25811
+ const record = await readPlayerInstall();
25812
+ if (!record) {
25813
+ fail3(opts, log, "player run", PLAYER_NOT_INSTALLED);
25814
+ return;
25815
+ }
25816
+ if (installExpired(record)) {
25817
+ fail3(opts, log, "player run", `This machine's Genex approval expired on ${record.expiresAt} \u2014 run \`genex player install\` again.`);
25818
+ return;
25819
+ }
25820
+ const apiUrl = opts.apiUrl ? normalizeApiOrigin(getApiUrl(opts.apiUrl)) : record.apiUrl;
25821
+ bindToken(record.token, apiUrl);
25822
+ const client = new PlayerClient({ apiUrl, token: record.token });
25823
+ const line = (text) => {
25824
+ process.stdout.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${text}
25825
+ `);
25826
+ };
25827
+ if (opts.foreground) {
25828
+ log.plain(c.bold("genex player run"));
25829
+ log.dim(` ${apiUrl} \xB7 watcher ${record.watcherId ?? "(unregistered)"} \xB7 v${getCliVersion()}`);
25830
+ log.dim(" Approve a request in a game and it runs here. Ctrl-C to stop.");
25831
+ }
25832
+ const result = await runWatcherLoop({
25833
+ install: record,
25834
+ client,
25835
+ ...opts.once ? { once: true } : {},
25836
+ log: line,
25837
+ // Only break off answering requests for a merely AVAILABLE update when a
25838
+ // relaunch will actually follow. A person watching a foreground run is
25839
+ // told about it instead — their terminal is not a thing to restart behind
25840
+ // their back.
25841
+ relaunchReady: async () => !opts.foreground && !opts.relaunched && relaunchPossible() && !await relaunchOnCooldown()
25842
+ });
25843
+ if (result.state.watcherId && result.state.watcherId !== record.watcherId) {
25844
+ await writePlayerInstall({ ...record, watcherId: result.state.watcherId }).catch(() => {
25845
+ });
25846
+ }
25847
+ const stop2 = result.stop;
25848
+ if (stop2.kind === "update_required" || stop2.kind === "update_available") {
25849
+ const version = stop2.kind === "update_required" ? stop2.latestVersion : stop2.latestVersion;
25850
+ if (opts.foreground) {
25851
+ line(`player: version ${version} is current; this is ${getCliVersion()} \u2014 restart with npx -y @genex-ai/cli-demo@latest player run`);
25852
+ return;
25853
+ }
25854
+ const outcome2 = await relaunchWatcher({
25855
+ version,
25856
+ apiUrl,
25857
+ sub: "run",
25858
+ relaunched: Boolean(opts.relaunched),
25859
+ log: line
25860
+ });
25861
+ if (outcome2.reason === "supervisor_restarts") {
25862
+ process.exitCode = 1;
25863
+ return;
25864
+ }
25865
+ if (!outcome2.launched) line(`player: not relaunching (${outcome2.reason ?? "unknown"}) \u2014 still ${getCliVersion()}, current is ${version}`);
25866
+ return;
25867
+ }
25868
+ if (stop2.kind === "auth") {
25869
+ line(`player: ${PLAYER_REAUTH_REQUIRED}`);
25870
+ if (opts.foreground) process.exitCode = 1;
25871
+ return;
25872
+ }
25873
+ if (stop2.kind === "signal") line("player: stopped");
25874
+ }
25875
+ async function status(opts, log) {
25876
+ const record = await readPlayerInstall();
25877
+ const state = await readPlayerState();
25878
+ const runners = detectRunners();
25879
+ const platform = servicePlatform();
25880
+ const unit = await serviceInstalled(os12.homedir(), platform);
25881
+ const paths = playerPaths();
25882
+ const running = state?.pid ? pidAlive(state.pid) : null;
25883
+ const server = { reachable: false, policyVersion: null, minVersion: null, latestVersion: null, pending: null, error: null };
25884
+ if (record && !installExpired(record)) {
25885
+ const apiUrl = opts.apiUrl ? normalizeApiOrigin(getApiUrl(opts.apiUrl)) : record.apiUrl;
25886
+ bindToken(record.token, apiUrl);
25887
+ const client = new PlayerClient({ apiUrl, token: record.token, timeoutMs: 8e3 });
25888
+ try {
25889
+ const policy = await client.policy();
25890
+ server.reachable = true;
25891
+ server.policyVersion = policy.version ?? null;
25892
+ server.minVersion = policy.minVersion ?? null;
25893
+ server.latestVersion = policy.latestVersion ?? null;
25894
+ let pending = 0;
25895
+ for (const provider of new Set(runners.map((r) => PROVIDER_FOR_RUNNER[r]))) {
25896
+ pending += (await client.pending(provider)).length;
25897
+ }
25898
+ server.pending = pending;
25899
+ } catch (error) {
25900
+ server.error = error instanceof PlayerApiError ? `${error.status} ${error.code}`.trim() : error instanceof PlayerAuthError ? "credential_rejected" : error instanceof PlayerUpdateRequiredError ? "player_update_required" : "unreachable";
25901
+ }
25902
+ }
25903
+ if (opts.json) {
25904
+ const described2 = record ? describeInstall(record) : null;
25905
+ writeJsonLine2({
25906
+ command: "player status",
25907
+ status: record ? "ok" : "not_installed",
25908
+ error: record ? null : "not_installed",
25909
+ installed: Boolean(record),
25910
+ apiUrl: described2?.apiUrl ?? null,
25911
+ watcherId: described2?.watcherId ?? null,
25912
+ label: described2?.label ?? null,
25913
+ installedAt: described2?.installedAt ?? null,
25914
+ version: described2?.version ?? null,
25915
+ cliVersion: getCliVersion(),
25916
+ credentialExpiresAt: described2?.expiresAt ?? null,
25917
+ credentialExpired: record ? installExpired(record) : null,
25918
+ runners,
25919
+ autostart: unit,
25920
+ running,
25921
+ pid: state?.pid ?? null,
25922
+ startedAt: state?.startedAt ?? null,
25923
+ lastHeartbeatAt: state?.lastHeartbeatAt ?? null,
25924
+ lastPollAt: state?.lastPollAt ?? null,
25925
+ lastJobAt: state?.lastJobAt ?? null,
25926
+ jobsCompleted: state?.jobsCompleted ?? null,
25927
+ jobsReleased: state?.jobsReleased ?? null,
25928
+ lastError: state?.lastError ?? null,
25929
+ logFile: paths.log,
25930
+ server
25931
+ });
25932
+ if (!record) process.exitCode = 1;
25933
+ return;
25934
+ }
25935
+ log.plain(c.bold("genex player status"));
25936
+ if (!record) {
25937
+ log.plain("");
25938
+ log.plain(` ${PLAYER_NOT_INSTALLED}`);
25939
+ process.exitCode = 1;
25940
+ return;
25941
+ }
25942
+ const described = describeInstall(record);
25943
+ log.dim(` ${described.apiUrl}`);
25944
+ log.plain("");
25945
+ log.plain(` Watcher ${described.watcherId ?? "not registered yet"}${described.label ? ` \xB7 ${described.label}` : ""}`);
25946
+ log.plain(` Installed ${described.installedAt} by v${described.version} (running v${getCliVersion()})`);
25947
+ log.plain(` Approval ${installExpired(record) ? c.red("expired") : "valid"} until ${described.expiresAt}`);
25948
+ log.plain(` Runners ${runners.length ? runners.join(", ") : "none on PATH"}`);
25949
+ log.plain(` Login entry ${unit ?? "none"}`);
25950
+ log.plain(` Process ${running === null ? "never started here" : running ? `running (pid ${state?.pid})` : "not running"}`);
25951
+ if (state) {
25952
+ log.plain(` Last beat ${state.lastHeartbeatAt ?? "never"} poll ${state.lastPollAt ?? "never"} job ${state.lastJobAt ?? "never"}`);
25953
+ log.plain(` Answered ${state.jobsCompleted} \xB7 handed back ${state.jobsReleased}${state.lastError ? ` \xB7 last issue ${state.lastError}` : ""}`);
25954
+ }
25955
+ 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"})`}`);
25956
+ log.plain(` Log ${paths.log}`);
25957
+ if (installExpired(record) || server.error === "credential_rejected") process.exitCode = 1;
25958
+ }
25959
+ function pidAlive(pid) {
25960
+ try {
25961
+ process.kill(pid, 0);
25962
+ return true;
25963
+ } catch (error) {
25964
+ return error.code === "EPERM";
25965
+ }
25966
+ }
25967
+ async function stop(opts, log) {
25968
+ const platform = servicePlatform();
25969
+ const result = await stopService(os12.homedir(), platform);
25970
+ const state = await readPlayerState();
25971
+ let signalled = false;
25972
+ if (state?.pid && pidAlive(state.pid) && state.pid !== process.pid && !playerDryRun()) {
25973
+ try {
25974
+ process.kill(state.pid, "SIGTERM");
25975
+ signalled = true;
25976
+ } catch {
25977
+ }
25978
+ }
25979
+ if (opts.json) {
25980
+ writeJsonLine2({ command: "player stop", status: "ok", error: null, unit: result.unit, detail: result.detail, signalledPid: signalled ? state?.pid ?? null : null });
25981
+ return;
25982
+ }
25983
+ log.success(`Stopped: ${result.detail}${signalled ? ` (and signalled pid ${state?.pid})` : ""}`);
25984
+ 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")}.`);
25985
+ }
25986
+ async function uninstall(opts, log) {
25987
+ const platform = servicePlatform();
25988
+ const state = await readPlayerState();
25989
+ if (state?.pid && pidAlive(state.pid) && state.pid !== process.pid && !playerDryRun()) {
25990
+ try {
25991
+ process.kill(state.pid, "SIGTERM");
25992
+ } catch {
25993
+ }
25994
+ }
25995
+ const service = await uninstallService(os12.homedir(), platform);
25996
+ const removed = await removePlayerFiles();
25997
+ if (opts.json) {
25998
+ writeJsonLine2({ command: "player uninstall", status: "ok", error: null, unit: service.unit, detail: service.detail, removed });
25999
+ return;
26000
+ }
26001
+ log.success(`Removed: ${service.detail} (${path43.basename(service.unit)})`);
26002
+ for (const file of removed) log.dim(` deleted ${file}`);
26003
+ if (removed.length === 0) log.dim(" no credential was stored here");
26004
+ log.plain("");
26005
+ log.plain(" This machine can no longer answer in-game requests. The approval itself can also be");
26006
+ log.plain(" revoked from your Genex dashboard, which ends it everywhere at once.");
26007
+ }
26008
+ function fail3(opts, log, command, message, extra = {}) {
26009
+ if (opts.json) writeJsonLine2({ command, status: "failed", error: message, ...extra });
26010
+ else log.error(message);
26011
+ process.exitCode = 1;
26012
+ }
26013
+ function writeJsonLine2(value) {
26014
+ process.stdout.write(`${JSON.stringify(value)}
26015
+ `);
26016
+ }
26017
+
26018
+ // src/lib/costs.ts
26019
+ var TYPICAL_CREDITS = {
26020
+ model: 35,
26021
+ // 46 from a reference image; --texture standard 23, --no-texture 12, --geometry detailed 58, --quad 41, --low-poly 46, --parts 58 quote off the estimate
26022
+ image: 7,
26023
+ // GPT Image 2.5 at high, the default; --quality xhigh/max, --candidates, --edit and 4K quote higher, --quality low/medium and --transparent quote lower
26024
+ texture: 9,
26025
+ // gemini-3.1-flash-image, 1K square
26026
+ sfx: 2,
26027
+ music: 27,
23831
26028
  // 90 s; up to 87 for a 300 s track
23832
26029
  voice: 12,
23833
26030
  // the 1000-char cap; short lines quote 2-3
@@ -23879,6 +26076,27 @@ ${c.bold("Usage")}
23879
26076
  the asset table against it. --assets <credits>
23880
26077
  --user-approved raises it, only once the player
23881
26078
  has agreed to the number.
26079
+ genex llm <sub> [options] In-game model calls: models | bench | price.
26080
+ "models" says whether this stand serves the
26081
+ runtime LLM lane at all, and at what provider
26082
+ rates. "bench" runs the real model on YOUR OWN
26083
+ coin and reports what each attempt was charged;
26084
+ it needs --max-coins <n> --user-approved, like
26085
+ any spend the player has to agree to. "price"
26086
+ reprints the last run's recommended
26087
+ estimateCoins. Declare a game's price from a
26088
+ bench, never from a guess: a started attempt is
26089
+ charged in full, including one that fails.
26090
+ genex player <sub> [options] Answer the in-game model requests you approve, on your
26091
+ OWN Claude or ChatGPT subscription: install | run |
26092
+ status | stop | uninstall. "install" signs this machine
26093
+ in (a player-scoped approval that can spend nothing),
26094
+ finds the official claude / codex CLI you already
26095
+ use, and starts a small background program at login.
26096
+ It never reads, stores or forwards your Claude or
26097
+ ChatGPT sign-in \u2014 it only starts the CLI you installed.
26098
+ Approve a request in a game and the answer comes back
26099
+ on its own; nothing to paste, ever again.
23882
26100
  genex auth [options] Connect this machine to your Genex account, or
23883
26101
  finish a sign-in another command started \u2014 it
23884
26102
  resumes the same code, so an interrupted
@@ -24301,6 +26519,12 @@ ${c.bold("Examples")}
24301
26519
  genex shop set sku_123 --icon https://assets.genex.technology/generations/abc123/image-main
24302
26520
  genex shop test sku_123
24303
26521
  genex shop remove sku_123
26522
+ genex llm models
26523
+ genex llm bench "Reply with one short taunt." --samples 5 --max-coins 20 --user-approved
26524
+ genex llm price
26525
+ genex player install
26526
+ genex player status --json
26527
+ genex player uninstall
24304
26528
  genex publish --no-push --title "My Game"
24305
26529
  genex model "weathered wooden barrel with iron bands"
24306
26530
  genex model --image ./concept.png
@@ -24530,6 +26754,76 @@ ${c.bold("How the allowance works")}
24530
26754
  and GENEX_ASSET_BUDGET_CAP (credits; 0 turns the budget off).
24531
26755
 
24532
26756
  Every number printed is live from your account; prices can change without a deploy.
26757
+ `,
26758
+ llm: `${c.bold("genex llm")} \u2014 in-game model calls: is the lane live, what does one call cost, what should the game declare?
26759
+
26760
+ ${c.bold("Usage")}
26761
+ genex llm models [--json] Which models this stand serves, their provider rates per
26762
+ million tokens, and the headroom it recommends over a
26763
+ benchmark. Off on this stand: it says so and exits 0 \u2014
26764
+ build the feature without a model rather than promising
26765
+ a 404.
26766
+ genex llm bench "<prompt>" --max-coins <n> --user-approved [options]
26767
+ Run the real model <n> times through the development
26768
+ lane and report what each attempt was CHARGED. Refused
26769
+ without both flags, before any network call: this spends
26770
+ YOUR OWN COIN, a wallet the build's asset allowance
26771
+ cannot see. Needs a folder linked to a game you own.
26772
+ genex llm price [--json] The recommendation from the last bench in this folder.
26773
+ Reads a file; spends nothing.
26774
+
26775
+ ${c.bold("Bench options")}
26776
+ --model <id> One of the ids \`genex llm models\` prints (default: the first one).
26777
+ --samples <n> How many real attempts (default ${DEFAULT_BENCH_SAMPLES}). More samples, better p95.
26778
+ --schema <file> A JSON file holding the output schema; implies JSON output.
26779
+ --json-output Ask for JSON without a schema. --text Ask for text (the default).
26780
+ --max-coins <n> The per-attempt ceiling YOU approved \u2014 a bench runs on your own coin,
26781
+ never a player's. The run's worst case is that number times --samples,
26782
+ and it is printed before anything starts.
26783
+ --timeout <seconds> Bounds the wait for each attempt.
26784
+ --json One machine-readable object; every key is always present, null when
26785
+ the CLI could not learn it.
26786
+
26787
+ ${c.bold("Why a benchmark and not an estimate")}
26788
+ \`estimateCoins\` is a PRICE, not a guess: declaring it charges it, in full, even when the
26789
+ attempt fails, is cancelled, or stops at its budget. And three layers sit between the
26790
+ provider's rate card and that number \u2014 the provider's cost, the platform's tariff (already
26791
+ inside every charged coin), and your headroom. The bench itself is billed to YOU, on the
26792
+ development lane; the player-funded lane is what the published game uses. Adding a margin
26793
+ to the provider's rate silently drops the tariff and under-prices every call, so the
26794
+ recommendation is built from CHARGED COINS and the headroom comes from the server. A stand
26795
+ that serves no headroom gets no recommendation, because the multiplier is not the CLI's to
26796
+ invent.
26797
+
26798
+ Results land in ${LLM_BENCH_FILE} \u2014 gitignored, machine-local, re-read by \`genex llm price\`.
26799
+ `,
26800
+ player: `${c.bold("genex player")} \u2014 answer the in-game model requests you approve, on your own Claude or ChatGPT subscription.
26801
+
26802
+ ${c.bold("Usage")}
26803
+ genex player install [--label <name>] [--no-autostart] [--dry-run] [--json]
26804
+ Sign this machine in, find the official CLI you already
26805
+ use, and start a small background program at login.
26806
+ genex player run [--once] [--foreground]
26807
+ The loop itself: poll, take one request, run it, send the
26808
+ answer back. Started for you by \`install\`; run it yourself
26809
+ with --foreground to watch it work.
26810
+ genex player status [--json] What is installed here, what the server says, and when this
26811
+ machine last spoke to it.
26812
+ genex player stop Stop the background program; keep the installation.
26813
+ genex player uninstall Stop it, remove the login entry, delete the approval.
26814
+
26815
+ ${c.bold("What it does and does not touch")}
26816
+ It NEVER reads, stores, forwards or intermediates your Claude or ChatGPT sign-in \u2014 no token,
26817
+ no keychain entry, no session file. It starts the official CLI you installed and signed into,
26818
+ hands it the request on stdin with no tools and an empty scratch directory, and reads what it
26819
+ prints. Genex never sees your vendor account.
26820
+
26821
+ Its GENEX approval is a player-scoped one: it can answer requests you already approved and can
26822
+ spend nothing \u2014 no coins, no credits, no account changes. It lives in ~/.genex/player.json,
26823
+ separate from your creator sign-in, and the dashboard can revoke it at any time.
26824
+
26825
+ The log under ~/.genex/logs/ carries ids, timings and outcomes \u2014 never what a game asked for
26826
+ and never what the model answered.
24533
26827
  `,
24534
26828
  remix: `genex remix <slug-or-Genex-page-URL> [directory] [--preview | --source-only] [--json]
24535
26829
 
@@ -24714,6 +27008,16 @@ function parseArgs(argv) {
24714
27008
  "--limit",
24715
27009
  // `genex budget --assets <credits>` — the allowance the player approved.
24716
27010
  "--assets",
27011
+ // `genex llm bench` value flags. `--max-coins` is the COIN half of the
27012
+ // same approval shape as `--assets` (a different economy, its own gate).
27013
+ "--model",
27014
+ "--samples",
27015
+ "--schema",
27016
+ "--max-coins",
27017
+ // `genex player install --label <name>` — what this machine is called in
27018
+ // the dashboard. Its own key rather than the generic 2nd positional,
27019
+ // which `player` spends on the subcommand.
27020
+ "--label",
24717
27021
  // `genex motion` value flags.
24718
27022
  "--takes",
24719
27023
  "--seeds",
@@ -24791,6 +27095,33 @@ function parseArgs(argv) {
24791
27095
  case "--user-approved":
24792
27096
  parsed.options.userApproved = true;
24793
27097
  break;
27098
+ // `genex player` switches.
27099
+ case "--no-autostart":
27100
+ parsed.options.noAutostart = true;
27101
+ break;
27102
+ case "--once":
27103
+ parsed.options.once = true;
27104
+ break;
27105
+ case "--foreground":
27106
+ parsed.options.foreground = true;
27107
+ break;
27108
+ // Set by a watcher that re-executed ITSELF on a newer version, so the
27109
+ // child cannot relaunch again and spin (lib/player/relaunch.ts).
27110
+ case "--relaunched":
27111
+ parsed.options.relaunched = true;
27112
+ break;
27113
+ case "--dry-run":
27114
+ parsed.options.dryRun = true;
27115
+ break;
27116
+ // `genex llm bench` output shape. Named `--json-output` because `--json`
27117
+ // already means "print one machine-readable object" everywhere in this
27118
+ // CLI, and the model's answer format is a different question.
27119
+ case "--json-output":
27120
+ parsed.options.jsonOutput = true;
27121
+ break;
27122
+ case "--text":
27123
+ parsed.options.textOutput = true;
27124
+ break;
24794
27125
  case "--direct-text":
24795
27126
  parsed.options.directText = true;
24796
27127
  break;
@@ -24948,6 +27279,8 @@ function parseArgs(argv) {
24948
27279
  } else {
24949
27280
  (parsed.options.selectors ??= []).push(arg);
24950
27281
  }
27282
+ } else if (parsed.command === "llm") {
27283
+ parsed.options.benchPrompt = parsed.options.benchPrompt ? `${parsed.options.benchPrompt} ${arg}` : arg;
24951
27284
  } else if (parsed.command === "shop") {
24952
27285
  if (!parsed.options.hostname) parsed.options.hostname = arg;
24953
27286
  else {
@@ -25173,6 +27506,31 @@ function applyValueFlag(options, flag, value) {
25173
27506
  options.assets = n;
25174
27507
  break;
25175
27508
  }
27509
+ case "--model":
27510
+ options.modelId = value;
27511
+ break;
27512
+ case "--label":
27513
+ options.playerLabel = value;
27514
+ break;
27515
+ case "--schema":
27516
+ options.schemaPath = value;
27517
+ break;
27518
+ case "--samples": {
27519
+ const n = Number(value);
27520
+ if (!Number.isInteger(n) || n < 1 || n > 25) {
27521
+ throw new Error(`Invalid --samples value: ${value} (expected 1-25)`);
27522
+ }
27523
+ options.samples = n;
27524
+ break;
27525
+ }
27526
+ case "--max-coins": {
27527
+ const n = Number(value);
27528
+ if (!Number.isInteger(n) || n < 1) {
27529
+ throw new Error(`Invalid --max-coins value: ${value} (whole coin, 1 or more)`);
27530
+ }
27531
+ options.maxCoins = n;
27532
+ break;
27533
+ }
25176
27534
  case "--timeout": {
25177
27535
  const n = Number(value);
25178
27536
  if (!Number.isFinite(n) || n <= 0) {
@@ -25384,9 +27742,11 @@ async function main() {
25384
27742
  return;
25385
27743
  }
25386
27744
  const isBlenderMcp = parsed.command === "blender" && parsed.options.name === "mcp";
25387
- const quietUpdates = Boolean(parsed.options.quiet || parsed.options.json || isBlenderMcp);
27745
+ const isPlayerLoop = parsed.command === "player" && (parsed.options.name ?? "status") === "run";
27746
+ const quietUpdates = Boolean(parsed.options.quiet || parsed.options.json || isBlenderMcp || isPlayerLoop);
25388
27747
  const updateLog = createLogger({ quiet: quietUpdates });
25389
- if (parsed.command !== "init" && parsed.command !== "tools" && parsed.command !== "remix") {
27748
+ const ownsItsOwnInstall = parsed.command === "init" || parsed.command === "tools" || parsed.command === "remix";
27749
+ if (!ownsItsOwnInstall && parsed.command !== "player") {
25390
27750
  await syncSkills(updateLog, workspace);
25391
27751
  }
25392
27752
  const updateCheck = startUpdateCheck();
@@ -25459,6 +27819,20 @@ async function main() {
25459
27819
  case "budget":
25460
27820
  await runBudget(parsed.options);
25461
27821
  break;
27822
+ // The in-game model lane: is it live here, what does one call really
27823
+ // cost, what should the game declare. `bench` is the only CLI command
27824
+ // that spends COIN, so it carries its own --max-coins/--user-approved
27825
+ // gate — the credits allowance cannot see that wallet.
27826
+ case "llm":
27827
+ await runLlm(parsed.options);
27828
+ break;
27829
+ // The PLAYER's own program, not a builder's: it answers the in-game
27830
+ // model requests they approved by running the official `claude` /
27831
+ // `codex` CLI they signed into themselves, and never touches a vendor
27832
+ // credential. The second positional is the subcommand.
27833
+ case "player":
27834
+ await runPlayer(parsed.options);
27835
+ break;
25462
27836
  case "auth":
25463
27837
  await runAuth(parsed.options);
25464
27838
  break;