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 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, readdir, mkdtemp, rm, writeFile } from 'fs/promises';
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
- function execArgs(dir, promptText, effort = "low") {
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
- promptText
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 = (args) => platform === "win32" ? spawnImpl(["codex", ...args].map(winArg).join(" "), [], {
1875
- stdio: ["ignore", "pipe", "pipe"],
1876
- shell: true
1877
- }) : spawnImpl("codex", args, { stdio: ["ignore", "pipe", "pipe"] });
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
- child.stdout?.on("data", () => {
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 exitedZero(args) {
2056
+ function probeSpawn(exe, args) {
2057
+ const CAP = 8192;
1941
2058
  return new Promise((resolve) => {
1942
2059
  let settled = false;
1943
- const done = (ok) => {
1944
- if (!settled) {
1945
- settled = true;
1946
- resolve(ok);
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(false);
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(false));
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 (!await exitedZero(["--version"])) {
1969
- return { ok: false, reason: NOT_INSTALLED_REASON, code: "not-installed" };
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 (!await exitedZero(["login", "status"])) {
1972
- return { ok: false, reason: NOT_AUTHENTICATED_REASON, code: "not-authenticated" };
2116
+ if (ver.outcome !== "ok") {
2117
+ return verdict({ ok: false, reason: NOT_INSTALLED_REASON, code: "not-installed" }, exe, null);
1973
2118
  }
1974
- return { ok: true };
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, buildPrompt(req, refs.length, problems), "high");
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
- return avail.code === "not-authenticated" ? "not-authenticated" : "not-installed";
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
- return { state: stateFrom(avail), reason: avail.reason };
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 runner = createRunner(opts);
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
- async function collectImages(dir) {
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, buildPrompt2(req, i, roles));
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 runCodex(args, signal);
2263
- return collectImages(dir);
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
- const workers = Array.from({ length: Math.min(3, count) }, async () => {
2270
- while (next < count) {
2271
- const i = next++;
2272
- try {
2273
- results[i] = await jobs[i]();
2274
- } catch (err) {
2275
- if (signal?.aborted) throw err;
2276
- results[i] = [];
2277
- failures.push(err);
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
- await Promise.all(workers);
2282
- const images = results.flat();
2283
- if (!images.length && failures.length) throw failures[0];
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, promptText);
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 runCodex(args, signal);
2309
- const images = await collectImages(dir);
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 (ctrl.signal.aborted) core.store.cancelNode(nodeId);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",