@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/{blender-mcp-6TEAE65U.js → blender-mcp-HKT7N5BQ.js} +2 -2
- package/dist/{blender-serve-66OHC4ML.js → blender-serve-EH2MOW3U.js} +1 -1
- package/dist/{chunk-5WBSWMH5.js → chunk-FYHYZYBC.js} +1 -1
- package/dist/{chunk-QI7FIYBY.js → chunk-K4EHXOZK.js} +1 -1
- package/dist/index.js +2502 -128
- package/package.json +2 -2
- package/templates/skills/genex-game-director/SKILL.md +1 -0
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-llm-in-games/SKILL.md +275 -0
- package/templates/skills/genex-llm-in-games/references/pricing.md +153 -0
- package/templates/skills/genex-tool-llm/SKILL.md +96 -0
- package/templates/skills/genex-tool-workflow/SKILL.md +10 -5
- package/templates/skills/genex-updates/SKILL.md +1 -1
package/dist/index.js
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
writeSecretFile,
|
|
43
43
|
writeUserToken,
|
|
44
44
|
writeWorkspace
|
|
45
|
-
} from "./chunk-
|
|
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-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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 = [
|
|
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,
|
|
1489
|
-
return `\u2B06 Genex ${label} ${latest} available (installed ${installed}) \u2014 run: ${
|
|
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
|
|
1708
|
-
const result = await
|
|
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,
|
|
1762
|
+
constructor(code, message, status2) {
|
|
1750
1763
|
super(message);
|
|
1751
1764
|
this.code = code;
|
|
1752
|
-
this.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,
|
|
1930
|
-
return
|
|
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,
|
|
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 ===
|
|
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
|
|
2051
|
+
let run4 = 0;
|
|
2039
2052
|
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
2040
|
-
const
|
|
2041
|
-
if (
|
|
2042
|
-
if (
|
|
2053
|
+
const status2 = rows[i].status;
|
|
2054
|
+
if (status2 === "completed") break;
|
|
2055
|
+
if (status2 === "failed") run4 += 1;
|
|
2043
2056
|
}
|
|
2044
|
-
return
|
|
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
|
|
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: ${
|
|
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: ${
|
|
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: ${
|
|
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: ${
|
|
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
|
|
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,
|
|
2840
|
-
const shown =
|
|
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
|
|
3491
|
-
if (
|
|
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}, ${
|
|
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
|
|
3549
|
+
let run4 = 0;
|
|
3537
3550
|
for (let x = 0; x < W; x++) {
|
|
3538
|
-
if (subject[y * W + x])
|
|
3539
|
-
else if (
|
|
3540
|
-
tmp[y * W + x] =
|
|
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
|
-
|
|
3555
|
+
run4 = 0;
|
|
3543
3556
|
for (let x = W - 1; x >= 0; x--) {
|
|
3544
|
-
if (subject[y * W + x])
|
|
3545
|
-
else if (
|
|
3546
|
-
if (
|
|
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
|
|
3564
|
+
let run4 = 0;
|
|
3552
3565
|
for (let y = 0; y < H; y++) {
|
|
3553
|
-
if (tmp[y * W + x])
|
|
3554
|
-
else if (
|
|
3555
|
-
tmp2[y * W + x] =
|
|
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
|
-
|
|
3570
|
+
run4 = 0;
|
|
3558
3571
|
for (let y = H - 1; y >= 0; y--) {
|
|
3559
|
-
if (tmp[y * W + x])
|
|
3560
|
-
else if (
|
|
3561
|
-
if (
|
|
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 <
|
|
3620
|
+
if (big.length < names2.length) {
|
|
3608
3621
|
log.warn(
|
|
3609
|
-
`Only ${big.length} components \u2265 ${minPixels}px; expected ${
|
|
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 <
|
|
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 <
|
|
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 =
|
|
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
|
|
4109
|
-
if (
|
|
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 ${
|
|
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
|
|
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:
|
|
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
|
|
5692
|
-
for (let i = 0; i <
|
|
5693
|
-
const name =
|
|
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
|
|
7291
|
-
if (
|
|
7292
|
-
else if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
9301
|
-
if (
|
|
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 ${
|
|
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
|
|
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(
|
|
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
|
|
19420
|
-
if (
|
|
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(
|
|
20846
|
+
async function readVideo(path44, log) {
|
|
20833
20847
|
let bytes;
|
|
20834
20848
|
try {
|
|
20835
|
-
bytes = await readFile(
|
|
20849
|
+
bytes = await readFile(path44);
|
|
20836
20850
|
} catch {
|
|
20837
|
-
log.error(`Can't read ${
|
|
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(
|
|
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,
|
|
20847
|
-
const contentType = /\.mov$/i.test(
|
|
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(
|
|
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
|
|
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
|
-
|
|
21866
|
-
best = Math.max(best,
|
|
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
|
|
22234
|
+
let run4 = null;
|
|
22221
22235
|
for (let f = 0; f < air.length; f++) {
|
|
22222
|
-
if (air[f])
|
|
22236
|
+
if (air[f]) run4 = run4 ? [run4[0], f] : [f, f];
|
|
22223
22237
|
else {
|
|
22224
|
-
if (
|
|
22225
|
-
|
|
22238
|
+
if (run4 && (!best || run4[1] - run4[0] > best[1] - best[0])) best = run4;
|
|
22239
|
+
run4 = null;
|
|
22226
22240
|
}
|
|
22227
22241
|
}
|
|
22228
|
-
if (
|
|
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
|
|
22473
|
-
for (const n of
|
|
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-
|
|
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-
|
|
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
|
|
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(
|
|
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 =
|
|
23604
|
-
const { copied, updated } = await copyTemplates(
|
|
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
|
|
23665
|
-
import
|
|
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
|
|
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
|
|
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
|
-
|
|
24354
|
+
fail4(BUDGET_APPROVAL_REQUIRED);
|
|
23702
24355
|
return;
|
|
23703
24356
|
}
|
|
23704
24357
|
if (setting && (!Number.isInteger(opts.assets) || opts.assets < 0)) {
|
|
23705
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
23822
|
-
|
|
23823
|
-
|
|
23824
|
-
|
|
23825
|
-
|
|
23826
|
-
|
|
23827
|
-
|
|
23828
|
-
|
|
23829
|
-
|
|
23830
|
-
|
|
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) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[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
|
|
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
|
-
|
|
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;
|