scenri 0.4.3 → 0.4.4
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/CHANGELOG.md +17 -0
- package/dist/serve.js +329 -65
- package/package.json +1 -1
- package/studio-dist/assets/{index-8OmIjrcU.js → index-5fvERzuB.js} +24 -24
- package/studio-dist/index.html +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.4](https://github.com/tonygorb/Scenri/compare/v0.4.3...v0.4.4) (2026-08-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* cache the codex probe behind one shared runner and kill the whole tree on windows ([7c21235](https://github.com/tonygorb/Scenri/commit/7c212351231e4badcd49b8636f765dcb2ae0d73e))
|
|
9
|
+
* classify codex auth and verification failures where the user can act on them ([83c6512](https://github.com/tonygorb/Scenri/commit/83c651224095664cf439bf9ee0ea483d307bbd0d))
|
|
10
|
+
* harden the codex probe with timeouts, exe resolution and a version floor ([7c57808](https://github.com/tonygorb/Scenri/commit/7c578083b9dc665ea983cded464c0506db44ad3b))
|
|
11
|
+
* honest codex states and platform-aware setup copy in studio ([672195e](https://github.com/tonygorb/Scenri/commit/672195e373ea07940a8d661ed397ecd7d7e540e3))
|
|
12
|
+
* kill the whole install and login tree when a setup step times out on windows ([fa755a2](https://github.com/tonygorb/Scenri/commit/fa755a207b0cb81d53714d948867fce049a99171))
|
|
13
|
+
* leave the codex program token unquoted so npm cmd shims keep their own path ([72cd69d](https://github.com/tonygorb/Scenri/commit/72cd69dc3bbba1d8312d13c3be0379ecbde25d94))
|
|
14
|
+
* let taskkill own the windows kill so the process tree really dies ([340821a](https://github.com/tonygorb/Scenri/commit/340821a35d6db397ae5728047bbc5abf91a93e35))
|
|
15
|
+
* make Codex on Windows honest about readiness and unable to hang ([d698d5f](https://github.com/tonygorb/Scenri/commit/d698d5f526d9365e64e170819a61803ff0162f3e))
|
|
16
|
+
* recover codex images the windows sandbox could not move into the workdir ([4ce1d51](https://github.com/tonygorb/Scenri/commit/4ce1d51352d13f3afb83f190a9cb78bbc7427a0a))
|
|
17
|
+
* send codex prompts over stdin and watchdog silent or runaway generations ([35a7c1b](https://github.com/tonygorb/Scenri/commit/35a7c1bad5c9eea65df338f719ecd2f66065289f))
|
|
18
|
+
* set the codex version floor at the newest release verified working ([531a255](https://github.com/tonygorb/Scenri/commit/531a255b2a2e93a97b8620efa88a727cba06e43b))
|
|
19
|
+
|
|
3
20
|
## [0.4.3](https://github.com/tonygorb/Scenri/compare/v0.4.2...v0.4.3) (2026-08-23)
|
|
4
21
|
|
|
5
22
|
|
package/dist/serve.js
CHANGED
|
@@ -8,7 +8,7 @@ import Database from 'better-sqlite3';
|
|
|
8
8
|
import { randomBytes, createHash, randomUUID, timingSafeEqual } from 'crypto';
|
|
9
9
|
import fs, { realpathSync, existsSync, readFileSync, mkdirSync, chmodSync, writeFileSync, readdirSync, statSync, rmSync, renameSync } from 'fs';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
|
-
import { readFile, copyFile,
|
|
11
|
+
import { readFile, copyFile, mkdtemp, rm, readdir, stat, writeFile } from 'fs/promises';
|
|
12
12
|
import { spawn } from 'child_process';
|
|
13
13
|
import sharp6 from 'sharp';
|
|
14
14
|
import Fastify from 'fastify';
|
|
@@ -1850,59 +1850,175 @@ function createFalEngine(opts) {
|
|
|
1850
1850
|
}
|
|
1851
1851
|
};
|
|
1852
1852
|
}
|
|
1853
|
+
|
|
1854
|
+
// ../engines/codex/src/locate.ts
|
|
1855
|
+
var MIN_CODEX_VERSION = "0.145.0";
|
|
1856
|
+
var WHERE_TIMEOUT_MS = 5e3;
|
|
1857
|
+
function parseCodexVersion(text) {
|
|
1858
|
+
const m = /codex-cli\s+(\d+\.\d+\.\d+)/.exec(text);
|
|
1859
|
+
return m ? m[1] : null;
|
|
1860
|
+
}
|
|
1861
|
+
function versionAtLeast(version, floor) {
|
|
1862
|
+
const a = version.split(".").map(Number);
|
|
1863
|
+
const b = floor.split(".").map(Number);
|
|
1864
|
+
for (let i = 0; i < 3; i++) {
|
|
1865
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
|
|
1866
|
+
}
|
|
1867
|
+
return true;
|
|
1868
|
+
}
|
|
1869
|
+
function resolveCodex(platform, spawnImpl, timeoutMs = WHERE_TIMEOUT_MS) {
|
|
1870
|
+
if (platform !== "win32") return Promise.resolve({ command: "codex", direct: true });
|
|
1871
|
+
const fallback = { command: "codex", direct: false };
|
|
1872
|
+
return new Promise((resolve) => {
|
|
1873
|
+
let settled = false;
|
|
1874
|
+
const done = (r) => {
|
|
1875
|
+
if (settled) return;
|
|
1876
|
+
settled = true;
|
|
1877
|
+
clearTimeout(timer);
|
|
1878
|
+
resolve(r);
|
|
1879
|
+
};
|
|
1880
|
+
let child;
|
|
1881
|
+
const timer = setTimeout(() => {
|
|
1882
|
+
done(fallback);
|
|
1883
|
+
child?.kill();
|
|
1884
|
+
}, timeoutMs);
|
|
1885
|
+
try {
|
|
1886
|
+
child = spawnImpl("where.exe", ["codex"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
1887
|
+
} catch {
|
|
1888
|
+
done(fallback);
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
let stdout = "";
|
|
1892
|
+
child.stdout?.on("data", (d) => {
|
|
1893
|
+
stdout += String(d);
|
|
1894
|
+
});
|
|
1895
|
+
child.on("error", () => done(fallback));
|
|
1896
|
+
child.on("exit", (code) => {
|
|
1897
|
+
if (code !== 0) {
|
|
1898
|
+
done(fallback);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
const hits = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
1902
|
+
const exe = hits.find((h) => h.toLowerCase().endsWith(".exe"));
|
|
1903
|
+
done(exe ? { command: exe, direct: true } : fallback);
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// ../engines/codex/src/run.ts
|
|
1853
1909
|
var NOT_INSTALLED_REASON = "Codex CLI is not installed on this computer";
|
|
1854
1910
|
var NOT_AUTHENTICATED_REASON = "Codex CLI is installed but not signed in";
|
|
1911
|
+
var UNVERIFIED_REASON = "Could not verify Codex on this computer";
|
|
1855
1912
|
var DEFAULT_TIMEOUT_MS2 = 3e5;
|
|
1856
|
-
|
|
1913
|
+
var PROBE_TIMEOUT_MS = 1e4;
|
|
1914
|
+
var NO_ACTIVITY_TIMEOUT_MS = 12e4;
|
|
1915
|
+
var PROBE_TTL_MS = 3e4;
|
|
1916
|
+
function execArgs(dir, effort = "low") {
|
|
1857
1917
|
return [
|
|
1858
1918
|
"exec",
|
|
1859
1919
|
"--skip-git-repo-check",
|
|
1860
1920
|
"--sandbox",
|
|
1861
1921
|
"workspace-write",
|
|
1922
|
+
"--color",
|
|
1923
|
+
"never",
|
|
1862
1924
|
"-c",
|
|
1863
1925
|
`model_reasoning_effort="${effort}"`,
|
|
1864
1926
|
"-C",
|
|
1865
1927
|
dir,
|
|
1866
|
-
|
|
1928
|
+
"-"
|
|
1867
1929
|
];
|
|
1868
1930
|
}
|
|
1931
|
+
function killTree(child, platform, spawnImpl) {
|
|
1932
|
+
if (platform === "win32" && child.pid) {
|
|
1933
|
+
try {
|
|
1934
|
+
const tk = spawnImpl("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
1935
|
+
const fallback = () => {
|
|
1936
|
+
try {
|
|
1937
|
+
child.kill();
|
|
1938
|
+
} catch {
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
tk.on("error", fallback);
|
|
1942
|
+
tk.on("exit", (code) => {
|
|
1943
|
+
if (code !== 0) fallback();
|
|
1944
|
+
});
|
|
1945
|
+
return;
|
|
1946
|
+
} catch {
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
try {
|
|
1950
|
+
child.kill();
|
|
1951
|
+
} catch {
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1869
1954
|
function createRunner(opts = {}) {
|
|
1870
1955
|
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
1871
1956
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
1957
|
+
const probeTimeoutMs = opts.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
|
|
1958
|
+
const probeTtlMs = opts.probeTtlMs ?? PROBE_TTL_MS;
|
|
1959
|
+
const noActivityMs = opts.noActivityMs ?? NO_ACTIVITY_TIMEOUT_MS;
|
|
1872
1960
|
const platform = opts.platform ?? process.platform;
|
|
1961
|
+
const killCodex = (child) => killTree(child, platform, spawnImpl);
|
|
1962
|
+
let resolved = null;
|
|
1963
|
+
async function resolution() {
|
|
1964
|
+
resolved ??= await resolveCodex(platform, spawnImpl);
|
|
1965
|
+
return resolved;
|
|
1966
|
+
}
|
|
1873
1967
|
const winArg = (a) => `"${a.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/%/g, " percent ")}"`;
|
|
1874
|
-
const spawnCodex = (
|
|
1875
|
-
stdio
|
|
1876
|
-
shell: true
|
|
1877
|
-
}
|
|
1878
|
-
function run2(args, signal) {
|
|
1968
|
+
const spawnCodex = (exe, args, stdinOpen) => {
|
|
1969
|
+
const stdio = [stdinOpen ? "pipe" : "ignore", "pipe", "pipe"];
|
|
1970
|
+
return exe.direct ? spawnImpl(exe.command, args, { stdio }) : spawnImpl([exe.command, ...args.map(winArg)].join(" "), [], { stdio, shell: true });
|
|
1971
|
+
};
|
|
1972
|
+
async function run2(args, signal, io) {
|
|
1973
|
+
const exe = await resolution();
|
|
1879
1974
|
return new Promise((resolve, reject) => {
|
|
1880
1975
|
let child;
|
|
1881
1976
|
try {
|
|
1882
|
-
child = spawnCodex(args);
|
|
1977
|
+
child = spawnCodex(exe, args, io?.stdin != null);
|
|
1883
1978
|
} catch (err) {
|
|
1884
1979
|
reject(new Error(`Failed to spawn codex: ${err.message}`));
|
|
1885
1980
|
return;
|
|
1886
1981
|
}
|
|
1887
1982
|
let settled = false;
|
|
1888
1983
|
let stderr = "";
|
|
1889
|
-
|
|
1890
|
-
|
|
1984
|
+
let activityTimer = setTimeout(onSilence, noActivityMs);
|
|
1985
|
+
function sawActivity() {
|
|
1986
|
+
if (settled) return;
|
|
1987
|
+
clearTimeout(activityTimer);
|
|
1988
|
+
activityTimer = setTimeout(onSilence, noActivityMs);
|
|
1989
|
+
}
|
|
1990
|
+
function onSilence() {
|
|
1991
|
+
finish(
|
|
1992
|
+
() => reject(
|
|
1993
|
+
new Error(`Codex CLI produced no output for ${Math.round(noActivityMs / 1e3)}s, treating it as stuck`)
|
|
1994
|
+
)
|
|
1995
|
+
);
|
|
1996
|
+
killCodex(child);
|
|
1997
|
+
}
|
|
1998
|
+
child.stdout?.on("data", sawActivity);
|
|
1891
1999
|
child.stderr?.on("data", (d) => {
|
|
1892
2000
|
stderr += String(d);
|
|
2001
|
+
sawActivity();
|
|
1893
2002
|
});
|
|
2003
|
+
if (io?.stdin != null) {
|
|
2004
|
+
child.stdin?.on("error", () => {
|
|
2005
|
+
});
|
|
2006
|
+
child.stdin?.write(io.stdin);
|
|
2007
|
+
child.stdin?.end();
|
|
2008
|
+
}
|
|
1894
2009
|
const timer = setTimeout(() => {
|
|
1895
|
-
child.kill();
|
|
1896
2010
|
finish(() => reject(new Error(`Codex CLI timed out after ${timeoutMs}ms`)));
|
|
2011
|
+
killCodex(child);
|
|
1897
2012
|
}, timeoutMs);
|
|
1898
2013
|
const onAbort = () => {
|
|
1899
|
-
child.kill();
|
|
1900
2014
|
finish(() => reject(new Error("Codex CLI run aborted")));
|
|
2015
|
+
killCodex(child);
|
|
1901
2016
|
};
|
|
1902
2017
|
function finish(fn) {
|
|
1903
2018
|
if (settled) return;
|
|
1904
2019
|
settled = true;
|
|
1905
2020
|
clearTimeout(timer);
|
|
2021
|
+
clearTimeout(activityTimer);
|
|
1906
2022
|
signal?.removeEventListener("abort", onAbort);
|
|
1907
2023
|
fn();
|
|
1908
2024
|
}
|
|
@@ -1937,47 +2053,99 @@ function createRunner(opts = {}) {
|
|
|
1937
2053
|
});
|
|
1938
2054
|
}
|
|
1939
2055
|
}
|
|
1940
|
-
function
|
|
2056
|
+
function probeSpawn(exe, args) {
|
|
2057
|
+
const CAP = 8192;
|
|
1941
2058
|
return new Promise((resolve) => {
|
|
1942
2059
|
let settled = false;
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
2060
|
+
let stdout = "";
|
|
2061
|
+
const done = (outcome) => {
|
|
2062
|
+
if (settled) return;
|
|
2063
|
+
settled = true;
|
|
2064
|
+
clearTimeout(timer);
|
|
2065
|
+
resolve({ outcome, stdout });
|
|
1948
2066
|
};
|
|
1949
2067
|
let child;
|
|
2068
|
+
const timer = setTimeout(() => {
|
|
2069
|
+
done("timeout");
|
|
2070
|
+
if (child) killCodex(child);
|
|
2071
|
+
}, probeTimeoutMs);
|
|
1950
2072
|
try {
|
|
1951
|
-
child = spawnCodex(args);
|
|
2073
|
+
child = spawnCodex(exe, args, false);
|
|
1952
2074
|
} catch {
|
|
1953
|
-
done(
|
|
2075
|
+
done("spawn-error");
|
|
1954
2076
|
return;
|
|
1955
2077
|
}
|
|
1956
|
-
child.stdout?.on("data", () => {
|
|
2078
|
+
child.stdout?.on("data", (d) => {
|
|
2079
|
+
if (stdout.length < CAP) stdout += String(d).slice(0, CAP - stdout.length);
|
|
1957
2080
|
});
|
|
1958
2081
|
child.stderr?.on("data", () => {
|
|
1959
2082
|
});
|
|
1960
|
-
child.on("error", () => done(
|
|
1961
|
-
child.on("exit", (code) => done(code === 0));
|
|
2083
|
+
child.on("error", () => done("spawn-error"));
|
|
2084
|
+
child.on("exit", (code) => done(code === 0 ? "ok" : "nonzero"));
|
|
1962
2085
|
});
|
|
1963
2086
|
}
|
|
2087
|
+
let lastDiag = "";
|
|
2088
|
+
function verdict(avail, exe, version) {
|
|
2089
|
+
const line = `codex probe: exe=${exe.command} version=${version ?? "unknown"} outcome=${avail.ok ? "ready" : avail.code}`;
|
|
2090
|
+
if (line !== lastDiag) {
|
|
2091
|
+
lastDiag = line;
|
|
2092
|
+
if (!avail.ok) console.warn(line);
|
|
2093
|
+
else if (process.env.SCENRI_DEBUG === "1") console.log(line);
|
|
2094
|
+
}
|
|
2095
|
+
return avail;
|
|
2096
|
+
}
|
|
2097
|
+
let cached = null;
|
|
1964
2098
|
async function probe() {
|
|
1965
2099
|
if (process.env.SCENRI_NO_CODEX === "1") {
|
|
1966
2100
|
return { ok: false, reason: NOT_INSTALLED_REASON, code: "not-installed" };
|
|
1967
2101
|
}
|
|
1968
|
-
if (
|
|
1969
|
-
return
|
|
2102
|
+
if (cached && probeTtlMs > 0 && Date.now() - cached.at < probeTtlMs) {
|
|
2103
|
+
return cached.value;
|
|
2104
|
+
}
|
|
2105
|
+
const value = await probeUncached();
|
|
2106
|
+
cached = { at: Date.now(), value };
|
|
2107
|
+
return value;
|
|
2108
|
+
}
|
|
2109
|
+
async function probeUncached() {
|
|
2110
|
+
resolved = await resolveCodex(platform, spawnImpl);
|
|
2111
|
+
const exe = resolved;
|
|
2112
|
+
const ver = await probeSpawn(exe, ["--version"]);
|
|
2113
|
+
if (ver.outcome === "timeout") {
|
|
2114
|
+
return verdict({ ok: false, reason: UNVERIFIED_REASON, code: "unverified" }, exe, null);
|
|
1970
2115
|
}
|
|
1971
|
-
if (
|
|
1972
|
-
return { ok: false, reason:
|
|
2116
|
+
if (ver.outcome !== "ok") {
|
|
2117
|
+
return verdict({ ok: false, reason: NOT_INSTALLED_REASON, code: "not-installed" }, exe, null);
|
|
1973
2118
|
}
|
|
1974
|
-
|
|
2119
|
+
const version = parseCodexVersion(ver.stdout);
|
|
2120
|
+
if (version && !versionAtLeast(version, MIN_CODEX_VERSION)) {
|
|
2121
|
+
return verdict(
|
|
2122
|
+
{
|
|
2123
|
+
ok: false,
|
|
2124
|
+
reason: `Codex CLI ${version} is too old. Scenri needs ${MIN_CODEX_VERSION} or newer.`,
|
|
2125
|
+
code: "update-needed"
|
|
2126
|
+
},
|
|
2127
|
+
exe,
|
|
2128
|
+
version
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
2131
|
+
const login = await probeSpawn(exe, ["login", "status"]);
|
|
2132
|
+
if (login.outcome === "ok") {
|
|
2133
|
+
return verdict({ ok: true }, exe, version);
|
|
2134
|
+
}
|
|
2135
|
+
if (login.outcome === "nonzero") {
|
|
2136
|
+
return verdict({ ok: false, reason: NOT_AUTHENTICATED_REASON, code: "not-authenticated" }, exe, version);
|
|
2137
|
+
}
|
|
2138
|
+
return verdict({ ok: false, reason: UNVERIFIED_REASON, code: "unverified" }, exe, version);
|
|
2139
|
+
}
|
|
2140
|
+
function invalidateProbe() {
|
|
2141
|
+
cached = null;
|
|
2142
|
+
resolved = null;
|
|
1975
2143
|
}
|
|
1976
|
-
return { run: run2, withWorkDir, probe };
|
|
2144
|
+
return { run: run2, withWorkDir, probe, invalidateProbe };
|
|
1977
2145
|
}
|
|
1978
2146
|
var OUT_FILE = "analysis.json";
|
|
1979
2147
|
function createCodexAnalyzer(opts = {}) {
|
|
1980
|
-
const runner = createRunner(opts);
|
|
2148
|
+
const runner = opts.runner ?? createRunner(opts);
|
|
1981
2149
|
return {
|
|
1982
2150
|
isAvailable: () => runner.probe(),
|
|
1983
2151
|
async analyze(req, signal) {
|
|
@@ -1990,11 +2158,11 @@ function createCodexAnalyzer(opts = {}) {
|
|
|
1990
2158
|
}
|
|
1991
2159
|
let problems = [];
|
|
1992
2160
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1993
|
-
const args = execArgs(dir,
|
|
2161
|
+
const args = execArgs(dir, "high");
|
|
1994
2162
|
for (const ref of refs) {
|
|
1995
2163
|
args.splice(args.length - 1, 0, `--image=${ref}`);
|
|
1996
2164
|
}
|
|
1997
|
-
await runner.run(args, signal);
|
|
2165
|
+
await runner.run(args, signal, { stdin: buildPrompt(req, refs.length, problems) });
|
|
1998
2166
|
let raw;
|
|
1999
2167
|
try {
|
|
2000
2168
|
raw = await readFile(join(dir, OUT_FILE), "utf8");
|
|
@@ -2122,12 +2290,19 @@ var INSTALL_COMMAND_SUDO = "sudo npm install -g @openai/codex";
|
|
|
2122
2290
|
var DEFAULT_INSTALL_TIMEOUT_MS = 18e4;
|
|
2123
2291
|
function stateFrom(avail) {
|
|
2124
2292
|
if (avail.ok) return "ready";
|
|
2125
|
-
|
|
2293
|
+
switch (avail.code) {
|
|
2294
|
+
case "not-authenticated":
|
|
2295
|
+
case "update-needed":
|
|
2296
|
+
case "unverified":
|
|
2297
|
+
return avail.code;
|
|
2298
|
+
default:
|
|
2299
|
+
return "not-installed";
|
|
2300
|
+
}
|
|
2126
2301
|
}
|
|
2127
2302
|
function createCodexSetup(opts = {}) {
|
|
2128
2303
|
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
2129
2304
|
const platform = opts.platform ?? process.platform;
|
|
2130
|
-
const runner = createRunner(opts);
|
|
2305
|
+
const runner = opts.runner ?? createRunner(opts);
|
|
2131
2306
|
const installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
|
|
2132
2307
|
function run2(cmd, args, timeoutMs) {
|
|
2133
2308
|
return new Promise((resolve) => {
|
|
@@ -2141,8 +2316,8 @@ function createCodexSetup(opts = {}) {
|
|
|
2141
2316
|
};
|
|
2142
2317
|
let child;
|
|
2143
2318
|
const timer = setTimeout(() => {
|
|
2144
|
-
child?.kill();
|
|
2145
2319
|
done({ code: null, stderr, spawnError: `${cmd} timed out after ${timeoutMs}ms` });
|
|
2320
|
+
if (child) killTree(child, platform, spawnImpl);
|
|
2146
2321
|
}, timeoutMs);
|
|
2147
2322
|
try {
|
|
2148
2323
|
child = spawnImpl(cmd, args, { stdio: ["ignore", "pipe", "pipe"], shell: platform === "win32" });
|
|
@@ -2161,12 +2336,15 @@ function createCodexSetup(opts = {}) {
|
|
|
2161
2336
|
}
|
|
2162
2337
|
return {
|
|
2163
2338
|
async status() {
|
|
2339
|
+
runner.invalidateProbe();
|
|
2164
2340
|
const avail = await runner.probe();
|
|
2165
|
-
|
|
2341
|
+
const setupPlatform = platform === "win32" ? "windows" : platform === "darwin" ? "mac" : "linux";
|
|
2342
|
+
return { state: stateFrom(avail), reason: avail.reason, platform: setupPlatform };
|
|
2166
2343
|
},
|
|
2167
2344
|
async install() {
|
|
2168
2345
|
const res = await run2("npm", ["install", "-g", "@openai/codex"], installTimeoutMs);
|
|
2169
2346
|
if (res.code === 0) {
|
|
2347
|
+
runner.invalidateProbe();
|
|
2170
2348
|
const avail = await runner.probe();
|
|
2171
2349
|
if (avail.code === "not-installed") {
|
|
2172
2350
|
return {
|
|
@@ -2191,6 +2369,7 @@ function createCodexSetup(opts = {}) {
|
|
|
2191
2369
|
},
|
|
2192
2370
|
async login() {
|
|
2193
2371
|
const res = await run2("codex", ["login"], installTimeoutMs);
|
|
2372
|
+
runner.invalidateProbe();
|
|
2194
2373
|
if (res.code === 0) return { ok: true };
|
|
2195
2374
|
const detail = (res.spawnError ?? res.stderr).trim().slice(0, 400) || void 0;
|
|
2196
2375
|
return { ok: false, fallbackCommand: "codex login --device-auth", detail };
|
|
@@ -2201,10 +2380,20 @@ function createCodexSetup(opts = {}) {
|
|
|
2201
2380
|
// ../engines/codex/src/index.ts
|
|
2202
2381
|
function createCodexEngine(opts) {
|
|
2203
2382
|
const { saveImage } = opts;
|
|
2204
|
-
const
|
|
2383
|
+
const platform = opts.platform ?? process.platform;
|
|
2384
|
+
const runner = opts.runner ?? createRunner(opts);
|
|
2205
2385
|
const runCodex = runner.run;
|
|
2206
2386
|
const withWorkDir = runner.withWorkDir;
|
|
2207
|
-
|
|
2387
|
+
const generatedImagesDir = () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "generated_images");
|
|
2388
|
+
async function snapshotGenerated() {
|
|
2389
|
+
if (platform !== "win32") return null;
|
|
2390
|
+
try {
|
|
2391
|
+
return new Set(await readdir(generatedImagesDir()));
|
|
2392
|
+
} catch {
|
|
2393
|
+
return /* @__PURE__ */ new Set();
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
async function collectImages(dir, before = null) {
|
|
2208
2397
|
const entries = await readdir(dir);
|
|
2209
2398
|
const outFiles = entries.filter((name) => /^out-.*\.png$/.test(name)).sort((a, b) => {
|
|
2210
2399
|
const na = Number(/^out-(\d+)\.png$/.exec(a)?.[1] ?? NaN);
|
|
@@ -2213,6 +2402,10 @@ function createCodexEngine(opts) {
|
|
|
2213
2402
|
return a.localeCompare(b);
|
|
2214
2403
|
});
|
|
2215
2404
|
if (outFiles.length === 0) {
|
|
2405
|
+
if (before) {
|
|
2406
|
+
const recovered = await recoverFromGenerated(before);
|
|
2407
|
+
if (recovered) return [recovered];
|
|
2408
|
+
}
|
|
2216
2409
|
throw new Error("Codex finished but produced no images");
|
|
2217
2410
|
}
|
|
2218
2411
|
const hashes = [];
|
|
@@ -2221,6 +2414,21 @@ function createCodexEngine(opts) {
|
|
|
2221
2414
|
}
|
|
2222
2415
|
return hashes;
|
|
2223
2416
|
}
|
|
2417
|
+
async function recoverFromGenerated(before) {
|
|
2418
|
+
const home = generatedImagesDir();
|
|
2419
|
+
let names;
|
|
2420
|
+
try {
|
|
2421
|
+
names = (await readdir(home)).filter((n) => !before.has(n));
|
|
2422
|
+
} catch {
|
|
2423
|
+
return null;
|
|
2424
|
+
}
|
|
2425
|
+
if (!names.length) return null;
|
|
2426
|
+
const stamped = await Promise.all(names.map(async (n) => ({ n, mtime: (await stat(join(home, n))).mtimeMs })));
|
|
2427
|
+
stamped.sort((a, b) => b.mtime - a.mtime);
|
|
2428
|
+
const pick2 = stamped[0].n;
|
|
2429
|
+
console.warn(`codex: workdir empty, recovered ${pick2} from ${home}`);
|
|
2430
|
+
return saveImage(await readFile(join(home, pick2)));
|
|
2431
|
+
}
|
|
2224
2432
|
return {
|
|
2225
2433
|
capabilities() {
|
|
2226
2434
|
return {
|
|
@@ -2250,41 +2458,61 @@ function createCodexEngine(opts) {
|
|
|
2250
2458
|
const count = Math.max(1, req.count);
|
|
2251
2459
|
const refs = req.referenceImages ?? [];
|
|
2252
2460
|
const roles = req.referenceRoles ?? refs.map(() => "reference");
|
|
2461
|
+
const inner = new AbortController();
|
|
2462
|
+
const onOuterAbort = () => inner.abort();
|
|
2463
|
+
if (signal?.aborted) inner.abort();
|
|
2464
|
+
else signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
2253
2465
|
const jobs = Array.from(
|
|
2254
2466
|
{ length: count },
|
|
2255
2467
|
(_, i) => async () => withWorkDir(async (dir) => {
|
|
2256
|
-
const args = execArgs(dir
|
|
2468
|
+
const args = execArgs(dir);
|
|
2257
2469
|
for (const [idx, ref] of refs.entries()) {
|
|
2258
2470
|
const dest = join(dir, `ref-${idx}.png`);
|
|
2259
2471
|
await copyFile(ref, dest);
|
|
2260
2472
|
args.splice(args.length - 1, 0, `--image=${dest}`);
|
|
2261
2473
|
}
|
|
2262
|
-
await
|
|
2263
|
-
|
|
2474
|
+
const before = await snapshotGenerated();
|
|
2475
|
+
await runCodex(args, inner.signal, { stdin: buildPrompt2(req, i, roles) });
|
|
2476
|
+
return collectImages(dir, before);
|
|
2264
2477
|
})
|
|
2265
2478
|
);
|
|
2266
2479
|
const results = new Array(count);
|
|
2267
2480
|
const failures = [];
|
|
2481
|
+
let fatal = null;
|
|
2268
2482
|
let next = 0;
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2483
|
+
try {
|
|
2484
|
+
const workers = Array.from({ length: Math.min(3, count) }, async () => {
|
|
2485
|
+
while (next < count && !inner.signal.aborted) {
|
|
2486
|
+
const i = next++;
|
|
2487
|
+
try {
|
|
2488
|
+
results[i] = await jobs[i]();
|
|
2489
|
+
} catch (err) {
|
|
2490
|
+
if (signal?.aborted) throw err;
|
|
2491
|
+
results[i] = [];
|
|
2492
|
+
failures.push(err);
|
|
2493
|
+
if (fatal == null && isFatalSetupError(err)) {
|
|
2494
|
+
fatal = err;
|
|
2495
|
+
inner.abort();
|
|
2496
|
+
runner.invalidateProbe();
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2278
2499
|
}
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2500
|
+
});
|
|
2501
|
+
await Promise.all(workers);
|
|
2502
|
+
} finally {
|
|
2503
|
+
signal?.removeEventListener("abort", onOuterAbort);
|
|
2504
|
+
}
|
|
2505
|
+
const images = results.filter(Boolean).flat();
|
|
2506
|
+
if (!images.length && failures.length) throw fatal ?? failures[0];
|
|
2284
2507
|
if (failures.length) {
|
|
2285
2508
|
console.warn(
|
|
2286
2509
|
`codex: ${failures.length} of ${count} variants failed, keeping ${images.length}: ${String(failures[0]?.message ?? failures[0])}`
|
|
2287
2510
|
);
|
|
2511
|
+
return {
|
|
2512
|
+
images,
|
|
2513
|
+
costUsd: 0,
|
|
2514
|
+
raw: { requested: count, partialFailures: failures.map((f) => String(f?.message ?? f)) }
|
|
2515
|
+
};
|
|
2288
2516
|
}
|
|
2289
2517
|
return { images, costUsd: 0 };
|
|
2290
2518
|
},
|
|
@@ -2301,16 +2529,27 @@ function createCodexEngine(opts) {
|
|
|
2301
2529
|
refLines.push(`${name} shows ${EDIT_REFERENCE_ROLE_DIRECTIVE[role]}`);
|
|
2302
2530
|
}
|
|
2303
2531
|
const promptText = `Edit input.png using your image generation/editing tool: ${req.instruction}.` + (refLines.length ? ` ${refLines.join(". ")}.` : "") + ` Do not browse the web or explore files. Save the result in the current directory as out-1.png (you may run the commands needed to save and resize it). Nothing else.`;
|
|
2304
|
-
const args = execArgs(dir
|
|
2532
|
+
const args = execArgs(dir);
|
|
2305
2533
|
for (const name of ["input.png", ...refLines.map((_, i) => `${editRoles[i] ?? "reference"}-${i + 1}.png`)]) {
|
|
2306
2534
|
args.splice(args.length - 1, 0, `--image=${join(dir, name)}`);
|
|
2307
2535
|
}
|
|
2308
|
-
await
|
|
2309
|
-
|
|
2536
|
+
const before = await snapshotGenerated();
|
|
2537
|
+
try {
|
|
2538
|
+
await runCodex(args, signal, { stdin: promptText });
|
|
2539
|
+
} catch (err) {
|
|
2540
|
+
if (isFatalSetupError(err)) runner.invalidateProbe();
|
|
2541
|
+
throw err;
|
|
2542
|
+
}
|
|
2543
|
+
const images = await collectImages(dir, before);
|
|
2310
2544
|
return { images, costUsd: 0 };
|
|
2311
2545
|
});
|
|
2312
2546
|
}
|
|
2313
2547
|
};
|
|
2548
|
+
function isFatalSetupError(err) {
|
|
2549
|
+
return /failed to spawn|ENOENT|not logged in|login required|401|unauthorized/i.test(
|
|
2550
|
+
String(err?.message ?? err)
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2314
2553
|
function buildPrompt2(req, index, roles) {
|
|
2315
2554
|
const roleDirective = REFERENCE_ROLE_DIRECTIVE;
|
|
2316
2555
|
const refDirectives = roles.map(
|
|
@@ -2326,17 +2565,19 @@ function keyGetter(core, settingKey, envVar) {
|
|
|
2326
2565
|
}
|
|
2327
2566
|
function createEngineRegistry(core, extra = []) {
|
|
2328
2567
|
const saveImage = (buf) => core.images.save(buf);
|
|
2568
|
+
const codexRunner = createRunner();
|
|
2329
2569
|
const adapters = [
|
|
2330
2570
|
createOpenRouterEngine({ getKey: keyGetter(core, "openrouter_api_key", "OPENROUTER_API_KEY"), saveImage }),
|
|
2331
2571
|
createReplicateEngine({ getKey: keyGetter(core, "replicate_api_token", "REPLICATE_API_TOKEN"), saveImage }),
|
|
2332
2572
|
createFalEngine({ getKey: keyGetter(core, "fal_key", "FAL_KEY"), saveImage }),
|
|
2333
|
-
createCodexEngine({ saveImage }),
|
|
2573
|
+
createCodexEngine({ saveImage, runner: codexRunner }),
|
|
2334
2574
|
...extra
|
|
2335
2575
|
];
|
|
2336
2576
|
const byId = new Map(adapters.map((a) => [a.capabilities().id, a]));
|
|
2337
2577
|
return {
|
|
2338
2578
|
all: () => adapters,
|
|
2339
|
-
get: (id) => byId.get(id) ?? null
|
|
2579
|
+
get: (id) => byId.get(id) ?? null,
|
|
2580
|
+
codexRunner
|
|
2340
2581
|
};
|
|
2341
2582
|
}
|
|
2342
2583
|
function createDemoEngine(saveImage) {
|
|
@@ -6153,7 +6394,7 @@ function registerPresenterRoutes(app, deps) {
|
|
|
6153
6394
|
// src/routes/assetBuilds.ts
|
|
6154
6395
|
function registerAssetBuildRoutes(app, deps) {
|
|
6155
6396
|
const { core, engines, scenes, presenters } = deps;
|
|
6156
|
-
const analyzer = deps.analyzer ?? createCodexAnalyzer();
|
|
6397
|
+
const analyzer = deps.analyzer ?? createCodexAnalyzer({ runner: engines.codexRunner });
|
|
6157
6398
|
const buildEngine = async () => {
|
|
6158
6399
|
const ordered = [...engines.all()].sort((a, b) => {
|
|
6159
6400
|
const rank = (e) => e.capabilities().id === "codex-cli" ? 0 : 1;
|
|
@@ -6548,7 +6789,7 @@ function registerProjectRoutes(app, deps) {
|
|
|
6548
6789
|
|
|
6549
6790
|
// src/routes/codexSetup.ts
|
|
6550
6791
|
function registerCodexSetupRoutes(app, deps) {
|
|
6551
|
-
const codexSetup = deps.codexSetup ?? createCodexSetup();
|
|
6792
|
+
const codexSetup = deps.codexSetup ?? createCodexSetup({ runner: deps.codexRunner });
|
|
6552
6793
|
let codexSetupBusy = null;
|
|
6553
6794
|
app.get("/api/engines/codex/status", async () => codexSetup.status());
|
|
6554
6795
|
app.post("/api/engines/codex/install", async (_req, reply) => {
|
|
@@ -6781,6 +7022,21 @@ function registerImageRoutes(app, deps) {
|
|
|
6781
7022
|
|
|
6782
7023
|
// src/release/notes.data.ts
|
|
6783
7024
|
var RELEASES = [
|
|
7025
|
+
{
|
|
7026
|
+
version: "0.4.4",
|
|
7027
|
+
date: "2026-08-23",
|
|
7028
|
+
title: "Codex setup tells the truth, and a stuck generation fails instead of running forever.",
|
|
7029
|
+
sections: [
|
|
7030
|
+
{
|
|
7031
|
+
heading: "Codex",
|
|
7032
|
+
body: 'Setup now verifies more than a binary on the path: it checks the version, the sign-in, and says "could not verify" when it cannot tell, with a Check again button instead of a false Connected. Windows instructions say PowerShell, and Codex installed through npm works again.'
|
|
7033
|
+
},
|
|
7034
|
+
{
|
|
7035
|
+
heading: "Fixes",
|
|
7036
|
+
body: "A generation that goes silent now fails within minutes with a plain reason instead of running on with no news, and Cancel stops the Codex process for real, on Windows too. A signed-out or outdated Codex fails fast with the step that fixes it."
|
|
7037
|
+
}
|
|
7038
|
+
]
|
|
7039
|
+
},
|
|
6784
7040
|
{
|
|
6785
7041
|
version: "0.4.3",
|
|
6786
7042
|
date: "2026-08-23",
|
|
@@ -7493,7 +7749,7 @@ function buildServer(opts) {
|
|
|
7493
7749
|
return { ...rest, referenceCount: referenceImages.length };
|
|
7494
7750
|
});
|
|
7495
7751
|
registerProjectRoutes(app, { core });
|
|
7496
|
-
registerCodexSetupRoutes(app, { codexSetup: opts.codexSetup });
|
|
7752
|
+
registerCodexSetupRoutes(app, { codexSetup: opts.codexSetup, codexRunner: engines.codexRunner });
|
|
7497
7753
|
app.get("/api/engines", async () => {
|
|
7498
7754
|
const list2 = [];
|
|
7499
7755
|
for (const e of engines.all()) {
|
|
@@ -7572,11 +7828,17 @@ function buildServer(opts) {
|
|
|
7572
7828
|
);
|
|
7573
7829
|
}
|
|
7574
7830
|
}
|
|
7831
|
+
const NODE_TIMEOUT_MS = 6e5;
|
|
7575
7832
|
async function runNode(nodeId, engine, estimate, work, expect, post) {
|
|
7576
7833
|
const engineId = engine.capabilities().id;
|
|
7577
7834
|
reserved.set(engineId, (reserved.get(engineId) ?? 0) + estimate);
|
|
7578
7835
|
const ctrl = new AbortController();
|
|
7579
7836
|
runningGenerations.set(nodeId, ctrl);
|
|
7837
|
+
let watchdogFired = false;
|
|
7838
|
+
const watchdog = setTimeout(() => {
|
|
7839
|
+
watchdogFired = true;
|
|
7840
|
+
ctrl.abort();
|
|
7841
|
+
}, opts.nodeTimeoutMs ?? NODE_TIMEOUT_MS);
|
|
7580
7842
|
try {
|
|
7581
7843
|
const result = await work(ctrl.signal);
|
|
7582
7844
|
result.images = await normalizePngs(result.images);
|
|
@@ -7585,9 +7847,11 @@ function buildServer(opts) {
|
|
|
7585
7847
|
core.store.completeNode(nodeId, result);
|
|
7586
7848
|
core.ledger.recordCost(engineId, nodeId, result.costUsd);
|
|
7587
7849
|
} catch (err) {
|
|
7588
|
-
if (
|
|
7850
|
+
if (watchdogFired) core.store.failNode(nodeId, "generation timed out after 10 minutes");
|
|
7851
|
+
else if (ctrl.signal.aborted) core.store.cancelNode(nodeId);
|
|
7589
7852
|
else core.store.failNode(nodeId, String(err?.message ?? err));
|
|
7590
7853
|
} finally {
|
|
7854
|
+
clearTimeout(watchdog);
|
|
7591
7855
|
runningGenerations.delete(nodeId);
|
|
7592
7856
|
const left = (reserved.get(engineId) ?? 0) - estimate;
|
|
7593
7857
|
if (left > 1e-9) reserved.set(engineId, left);
|