@indigoai-us/hq-cli 5.345.75 → 5.345.77

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +30 -37
  3. package/dist/command-catalog.generated.d.ts +83 -3
  4. package/dist/command-catalog.generated.js +100 -3
  5. package/dist/commands/access.d.ts +1 -0
  6. package/dist/commands/access.js +17 -5
  7. package/dist/commands/bot.d.ts +12 -0
  8. package/dist/commands/bot.js +54 -2
  9. package/dist/commands/core-native-family-flag.js +1 -1
  10. package/dist/commands/files.js +7 -2
  11. package/dist/commands/members.d.ts +42 -0
  12. package/dist/commands/members.js +151 -1
  13. package/dist/commands/secrets.js +1 -1
  14. package/dist/fast-scaffold.d.ts +1 -1
  15. package/dist/fast-scaffold.js +1 -0
  16. package/dist/lib/bot/api.d.ts +11 -0
  17. package/dist/lib/bot/api.js +2 -0
  18. package/dist/lib/bot/config.js +4 -2
  19. package/dist/lib/bot/inflight.d.ts +3 -0
  20. package/dist/lib/bot/probe.d.ts +57 -0
  21. package/dist/lib/bot/probe.js +100 -0
  22. package/dist/lib/bot/run.d.ts +4 -0
  23. package/dist/lib/bot/run.js +171 -37
  24. package/dist/lib/bot/runtime/codex.d.ts +67 -1
  25. package/dist/lib/bot/runtime/codex.js +197 -3
  26. package/dist/lib/bot/runtime/grok.js +2 -2
  27. package/dist/lib/bot/runtime/index.d.ts +8 -0
  28. package/dist/lib/bot/runtime/index.js +91 -5
  29. package/dist/lib/bot/runtime/messages-stream.js +1 -1
  30. package/dist/lib/bot/runtime/types.d.ts +33 -0
  31. package/dist/lib/bot/runtime/types.js +10 -0
  32. package/dist/lib/bot/runtime-failure.d.ts +113 -0
  33. package/dist/lib/bot/runtime-failure.js +180 -0
  34. package/dist/lib/bot/runtime-sign-in.js +2 -2
  35. package/dist/lib/bot/status.d.ts +21 -0
  36. package/dist/lib/index-render/companies.js +33 -16
  37. package/dist/lib/index-render/shared.d.ts +18 -0
  38. package/dist/lib/index-render/shared.js +24 -0
  39. package/dist/lib/lanes/monitor.js +30 -15
  40. package/package.json +1 -1
@@ -13,8 +13,140 @@
13
13
  * Verified against Codex CLI on 2026-09-10 (event names; the model itself
14
14
  * cannot run under this machine's account).
15
15
  */
16
- import { parseJsonLine, RuntimeError } from "./types.js";
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { MAX_MODEL_FALLBACK_RETRIES, parseJsonLine, RuntimeError } from "./types.js";
17
20
  import { resolveRuntimeBinary } from "./windows.js";
21
+ import { versionTooOldModel } from "../runtime-failure.js";
22
+ /**
23
+ * The model a turn falls back to when the installed Codex is too old for the
24
+ * one it was asked to use (usually the person's own `model` in
25
+ * ~/.codex/config.toml, set by a newer Codex app) and the first choice from
26
+ * Codex's bundled catalog is refused as well. Every Codex CLI since mid 2026
27
+ * ships it.
28
+ */
29
+ export const CODEX_PINNED_FALLBACK_MODEL = "gpt-5.5";
30
+ /**
31
+ * The generation number in a Codex model slug: 6 for "gpt-6-astra", 6.1 for
32
+ * "gpt-6.1-sol", 5.6 for "gpt-5.6-terra". Undefined when the slug has none
33
+ * ("codex-auto-review").
34
+ */
35
+ export function codexModelGeneration(slug) {
36
+ const m = /^gpt-(\d+(?:\.\d+)?)(?:$|[-.])/i.exec(slug.trim());
37
+ return m ? Number(m[1]) : undefined;
38
+ }
39
+ /** [0, 147, 0] from "codex-cli 0.147.0". */
40
+ export function parseCodexVersion(text) {
41
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(text ?? "");
42
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
43
+ }
44
+ function versionOf(value) {
45
+ if (typeof value === "string")
46
+ return parseCodexVersion(value);
47
+ if (Array.isArray(value) && value.length > 0 && value.every((n) => typeof n === "number" && Number.isFinite(n))) {
48
+ return value;
49
+ }
50
+ return undefined;
51
+ }
52
+ function compareVersions(a, b) {
53
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
54
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
55
+ if (d !== 0)
56
+ return d;
57
+ }
58
+ return 0;
59
+ }
60
+ /**
61
+ * The model Codex itself would offer first that this Codex is most likely to
62
+ * run, from the JSON of `codex debug models --bundled` (`{ "models": [...] }`
63
+ * or a bare array). Lowest `priority` wins among the models that are listed
64
+ * in the picker, not `exclude`d, not newer than `installed` (when the catalog
65
+ * carries `minimal_client_version`; the real one does not), and of an OLDER
66
+ * generation than `refused` (gpt-6.1-sol is no better than gpt-6-astra for a
67
+ * Codex that refuses gpt-6; gpt-5.6-sol is). The bundled catalog of an old
68
+ * Codex still lists models it cannot run, because the "requires a newer
69
+ * version" check is the server's, so the generation rule is what matters.
70
+ */
71
+ export function pickCodexFallbackModel(catalogJson, opts = {}) {
72
+ let root;
73
+ try {
74
+ root = JSON.parse(catalogJson);
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ const list = Array.isArray(root)
80
+ ? root
81
+ : root && typeof root === "object" && Array.isArray(root.models)
82
+ ? root.models
83
+ : [];
84
+ const exclude = new Set((opts.exclude ?? []).filter(Boolean));
85
+ const refusedGen = opts.refused ? codexModelGeneration(opts.refused) : undefined;
86
+ const candidates = [];
87
+ list.forEach((entry, index) => {
88
+ if (!entry || typeof entry !== "object")
89
+ return;
90
+ const m = entry;
91
+ const slug = typeof m.slug === "string" ? m.slug.trim() : "";
92
+ if (!slug || exclude.has(slug) || !/^[\w.:/-]{1,128}$/.test(slug))
93
+ return;
94
+ if (typeof m.visibility === "string" && m.visibility !== "list")
95
+ return;
96
+ if (refusedGen !== undefined) {
97
+ const gen = codexModelGeneration(slug);
98
+ if (gen === undefined || gen >= refusedGen)
99
+ return;
100
+ }
101
+ const min = versionOf(m.minimal_client_version);
102
+ if (min && opts.installed && compareVersions(min, opts.installed) > 0)
103
+ return;
104
+ const priority = typeof m.priority === "number" && Number.isFinite(m.priority) ? m.priority : Number.POSITIVE_INFINITY;
105
+ candidates.push({ slug, priority, index });
106
+ });
107
+ candidates.sort((a, b) => a.priority - b.priority || a.index - b.index);
108
+ return candidates[0]?.slug;
109
+ }
110
+ /**
111
+ * The models to try, best first, after this Codex refused `failing` as too
112
+ * new: the best older-generation model from the bundled catalog, then the
113
+ * pinned fallback. At most MAX_MODEL_FALLBACK_RETRIES, never `failing` itself.
114
+ * `codex debug models --bundled` reads only the catalog inside the binary: no
115
+ * network, no sign-in, no cache write.
116
+ */
117
+ export async function codexFallbackModels(args) {
118
+ const failing = versionTooOldModel(args.reason) ?? args.requestedModel;
119
+ const exclude = [failing ?? "", args.requestedModel ?? ""];
120
+ // With no refused model named, or one whose slug carries no generation, the
121
+ // catalog cannot say which models are older: the pinned fallback alone.
122
+ if (!failing || codexModelGeneration(failing) === undefined) {
123
+ return exclude.includes(CODEX_PINNED_FALLBACK_MODEL) ? [] : [CODEX_PINNED_FALLBACK_MODEL];
124
+ }
125
+ const out = [];
126
+ let installed;
127
+ try {
128
+ const v = await args.run(["--version"]);
129
+ if (v.code === 0)
130
+ installed = parseCodexVersion(v.stdout);
131
+ }
132
+ catch {
133
+ /* unknown version: the catalog is still filtered by generation and visibility */
134
+ }
135
+ try {
136
+ const catalog = await args.run(["debug", "models", "--bundled"]);
137
+ if (catalog.code === 0) {
138
+ const picked = pickCodexFallbackModel(catalog.stdout, { exclude, ...(installed ? { installed } : {}), ...(failing ? { refused: failing } : {}) });
139
+ if (picked)
140
+ out.push(picked);
141
+ }
142
+ }
143
+ catch {
144
+ /* older Codex without `debug models`: the pinned fallback alone */
145
+ }
146
+ if (!exclude.includes(CODEX_PINNED_FALLBACK_MODEL) && !out.includes(CODEX_PINNED_FALLBACK_MODEL))
147
+ out.push(CODEX_PINNED_FALLBACK_MODEL);
148
+ return out.slice(0, MAX_MODEL_FALLBACK_RETRIES);
149
+ }
18
150
  /** Progress events in one codex JSONL line: finished agent messages and tool starts. */
19
151
  export function codexStreamEvents(line) {
20
152
  const ev = parseJsonLine(line);
@@ -79,11 +211,70 @@ function unwrapApiError(raw) {
79
211
  }
80
212
  return t;
81
213
  }
214
+ export function isExecutableFile(candidate) {
215
+ try {
216
+ if (!fs.statSync(candidate).isFile())
217
+ return false;
218
+ fs.accessSync(candidate, fs.constants.X_OK);
219
+ return true;
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ }
225
+ /** App bundles that carry the Codex CLI, in the HQ desktop app's lookup order. */
226
+ export const CODEX_APP_BUNDLES = ["ChatGPT.app", "Codex.app"];
227
+ /** Where the CLI sits inside a bundle: current layout first, then the older one. */
228
+ export const CODEX_BUNDLE_CLI_PATHS = [
229
+ path.join("Contents", "Resources", "codex-cli", "bin", "codex"),
230
+ path.join("Contents", "Resources", "codex"),
231
+ ];
232
+ /**
233
+ * The Codex CLI shipped inside the ChatGPT or Codex desktop app. A Mac that
234
+ * only has the desktop app has no `codex` on PATH, yet the HQ app counts it as
235
+ * installed, so a bot must be able to use it too. Order matches the HQ desktop
236
+ * app (apps/sync/src-tauri/src/commands/launch.rs, bundled_codex_bin_in):
237
+ * /Applications before ~/Applications, ChatGPT.app before Codex.app, and the
238
+ * codex-cli/bin layout before the older Resources/codex one.
239
+ */
240
+ export function bundledCodexBin(homeDir = os.homedir(), isExecutable = isExecutableFile) {
241
+ const appDirs = ["/Applications", path.join(homeDir, "Applications")];
242
+ for (const dir of appDirs) {
243
+ for (const bundle of CODEX_APP_BUNDLES) {
244
+ for (const rel of CODEX_BUNDLE_CLI_PATHS) {
245
+ const candidate = path.join(dir, bundle, rel);
246
+ if (isExecutable(candidate))
247
+ return candidate;
248
+ }
249
+ }
250
+ }
251
+ return null;
252
+ }
253
+ function codexOnPath(env, isExecutable) {
254
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
255
+ if (dir && isExecutable(path.join(dir, "codex")))
256
+ return true;
257
+ }
258
+ return false;
259
+ }
260
+ /**
261
+ * PATH wins. On macOS, fall back to the desktop app's bundled CLI. When
262
+ * nothing is found the bare name is returned, so the spawn fails with the
263
+ * usual "codex is not installed or not on PATH" error.
264
+ */
265
+ export function resolveCodexBinary(env, homeDir, platform = process.platform, cwd = process.cwd(), isExecutable = isExecutableFile) {
266
+ if (platform === "win32")
267
+ return resolveRuntimeBinary("codex", env, platform, undefined, cwd);
268
+ if (platform !== "darwin" || codexOnPath(env, isExecutable))
269
+ return "codex";
270
+ // `||`, not `??`: an empty HOME must not turn ~/Applications into a relative path.
271
+ return bundledCodexBin(homeDir || env.HOME || os.homedir(), isExecutable) ?? "codex";
272
+ }
82
273
  export function createCodexRuntime() {
83
274
  return {
84
275
  id: "codex",
85
276
  binary: "codex",
86
- resolveBinary: (env, cwd, platform) => resolveRuntimeBinary("codex", env, platform ?? process.platform, undefined, cwd),
277
+ resolveBinary: (env, cwd, platform) => resolveCodexBinary(env, undefined, platform ?? process.platform, cwd),
87
278
  buildArgs(input, scratch) {
88
279
  const promptArg = input.platform === "win32" ? "-" : codexPrompt(input);
89
280
  const common = ["--json", "--skip-git-repo-check", "-o", scratch.outputFile];
@@ -138,7 +329,7 @@ export function createCodexRuntime() {
138
329
  const fromFile = extra.outputFileText?.trim();
139
330
  const text = (fromFile || lastMessage || "").trim();
140
331
  if (!text) {
141
- throw new RuntimeError(`codex produced no reply${errorMessage ? `: ${errorMessage.slice(0, 200)}` : ""}`, (errorMessage ?? stdout.slice(-400)).slice(0, 400), null);
332
+ throw new RuntimeError(`codex produced no reply${errorMessage ? `: ${errorMessage.slice(0, 200)}` : ""}`, (errorMessage ?? stdout.slice(-400)).slice(0, 400), null, errorMessage ? {} : { excerptIsOutputTail: true });
142
333
  }
143
334
  return { text, sessionId };
144
335
  },
@@ -148,6 +339,9 @@ export function createCodexRuntime() {
148
339
  failureReason(stdout) {
149
340
  return codexFailureReason(stdout);
150
341
  },
342
+ fallbackModelsAfterVersionMismatch(args) {
343
+ return codexFallbackModels(args);
344
+ },
151
345
  };
152
346
  }
153
347
  //# sourceMappingURL=codex.js.map
@@ -150,12 +150,12 @@ export function createGrokRuntime(opts = {}) {
150
150
  if (legacy) {
151
151
  const text = String(legacy.text);
152
152
  if (!text.trim())
153
- throw new RuntimeError("grok produced no reply", stdout.trim().slice(-400), null);
153
+ throw new RuntimeError("grok produced no reply", stdout.trim().slice(-400), null, { excerptIsOutputTail: true });
154
154
  return { text, sessionId: typeof legacy.sessionId === "string" ? legacy.sessionId : undefined };
155
155
  }
156
156
  const result = messagesStreamResult(stdout, "grok");
157
157
  if (!result.text.trim())
158
- throw new RuntimeError("grok produced no reply", stdout.trim().slice(-400), null);
158
+ throw new RuntimeError("grok produced no reply", stdout.trim().slice(-400), null, { excerptIsOutputTail: true });
159
159
  return result;
160
160
  },
161
161
  parseStreamLine(line) {
@@ -20,6 +20,12 @@ export declare function descendantPids(pid: number, listProcesses?: () => string
20
20
  /** After a stop, wait this long for the output pipes to close before settling anyway. */
21
21
  export declare const STOP_SETTLE_GRACE_MS = 2000;
22
22
  export declare const defaultSpawn: SpawnLike;
23
+ /** Time limit for the short helper commands a turn may run (`--version`, the bundled model catalog). */
24
+ export declare const AUX_COMMAND_TIMEOUT_MS = 20000;
25
+ export declare const MODEL_FALLBACK_RECHECK_MS: number;
26
+ export declare const MODEL_FALLBACK_RECHECK_TURNS = 10;
27
+ /** Test seam. */
28
+ export declare function forgetModelFallbacks(): void;
23
29
  export interface RunTurnDeps {
24
30
  spawnImpl?: SpawnLike;
25
31
  env?: NodeJS.ProcessEnv;
@@ -27,6 +33,8 @@ export interface RunTurnDeps {
27
33
  scratchDir?: string;
28
34
  newSessionId?: () => string;
29
35
  platform?: NodeJS.Platform;
36
+ /** Clock for the fallback memo (test seam). */
37
+ now?: () => number;
30
38
  }
31
39
  /**
32
40
  * Run one turn: spawn the runtime CLI, parse the reply, clean up scratch.
@@ -9,7 +9,8 @@ import { createClaudeRuntime } from "./claude.js";
9
9
  import { createCodexRuntime } from "./codex.js";
10
10
  import { createGrokRuntime, grokRetryArgsAfterInvalidOutputFormat, probeGrokOutputFormat } from "./grok.js";
11
11
  import { stripControlTokens } from "./sanitize.js";
12
- import { parseJsonLine, RuntimeError, turnTimeoutMs, stderrExcerpt, } from "./types.js";
12
+ import { isRuntimeVersionTooOld, versionTooOldModel } from "../runtime-failure.js";
13
+ import { parseJsonLine, RuntimeError, turnTimeoutMs, stderrExcerpt, MAX_MODEL_FALLBACK_RETRIES, } from "./types.js";
13
14
  export * from "./types.js";
14
15
  export { createClaudeRuntime, claudePermissionMode } from "./claude.js";
15
16
  export { createCodexRuntime, codexPrompt } from "./codex.js";
@@ -317,6 +318,16 @@ export const defaultSpawn = (command, args, options) => new Promise((resolve, re
317
318
  });
318
319
  });
319
320
  });
321
+ /** Time limit for the short helper commands a turn may run (`--version`, the bundled model catalog). */
322
+ export const AUX_COMMAND_TIMEOUT_MS = 20_000;
323
+ const rememberedModelFallbacks = new Map();
324
+ const fallbackKey = (runtime, requestedModel) => `${runtime.id}|${requestedModel ?? ""}`;
325
+ export const MODEL_FALLBACK_RECHECK_MS = 30 * 60_000;
326
+ export const MODEL_FALLBACK_RECHECK_TURNS = 10;
327
+ /** Test seam. */
328
+ export function forgetModelFallbacks() {
329
+ rememberedModelFallbacks.clear();
330
+ }
320
331
  /**
321
332
  * Run one turn: spawn the runtime CLI, parse the reply, clean up scratch.
322
333
  * Throws RuntimeError (with a bounded stderr excerpt) on any failure.
@@ -327,7 +338,13 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
327
338
  const outputFile = path.join(scratchDir, "last-message.txt");
328
339
  const newSessionId = input.newSessionId ?? (deps.newSessionId ?? randomUUID)();
329
340
  const turnInput = { ...input, platform: deps.platform ?? input.platform ?? process.platform };
330
- const args = runtime.buildArgs(turnInput, { outputFile, newSessionId });
341
+ const nowMs = deps.now ?? Date.now;
342
+ const memoKey = fallbackKey(runtime, input.model);
343
+ const memo = rememberedModelFallbacks.get(memoKey);
344
+ // Time to try the requested model again (the person may have updated the CLI).
345
+ const recheck = memo !== undefined && (nowMs() - memo.at >= MODEL_FALLBACK_RECHECK_MS || memo.turns >= MODEL_FALLBACK_RECHECK_TURNS);
346
+ const remembered = memo && !recheck ? memo : undefined;
347
+ const args = runtime.buildArgs(remembered ? { ...turnInput, model: remembered.to } : turnInput, { outputFile, newSessionId });
331
348
  const env = input.env ?? deps.env ?? process.env;
332
349
  const binary = runtime.resolveBinary?.(env, input.cwd, turnInput.platform) ?? runtime.binary;
333
350
  const stdin = runtime.stdinInput?.(turnInput);
@@ -356,6 +373,9 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
356
373
  ...(input.signal ? { signal: input.signal } : {}),
357
374
  };
358
375
  let result;
376
+ let modelFallback = remembered ? { from: remembered.from, to: remembered.to } : undefined;
377
+ /** The retries on supported models failed too: report the first failure (its cause is the old CLI). */
378
+ let fallbackFailure;
359
379
  try {
360
380
  result = await spawnImpl(binary, args, spawnOptions);
361
381
  if (runtime.id === "grok" && result.code !== 0 && !input.signal?.aborted) {
@@ -363,6 +383,68 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
363
383
  if (retryArgs)
364
384
  result = await spawnImpl(binary, retryArgs, spawnOptions);
365
385
  }
386
+ if (remembered) {
387
+ // A turn on the remembered model: it either still works or the memo is wrong.
388
+ if (result.code === 0 && !result.signal)
389
+ remembered.turns += 1;
390
+ else if (!input.signal?.aborted)
391
+ rememberedModelFallbacks.delete(memoKey);
392
+ }
393
+ // The installed CLI is too old for the requested model (often the person's
394
+ // own default, set by a newer app of the same tool). The request was
395
+ // refused before the model did anything, so run the same turn again on the
396
+ // models this CLI should support, at most MAX_MODEL_FALLBACK_RETRIES
397
+ // times, instead of failing the turn.
398
+ //
399
+ // On `exec resume` the refused attempt may already have written the user
400
+ // message into the thread's rollout before the server answered, so the
401
+ // retry can leave that message twice in a row in the history. Codex has no
402
+ // way to drop the last message of a thread, and two identical user turns
403
+ // with nothing between them read as one request, so it is left as is.
404
+ if ((result.code !== 0 || result.signal) && !input.signal?.aborted && runtime.fallbackModelsAfterVersionMismatch && !remembered) {
405
+ const reason = runtime.failureReason?.(result.stdout, result.stderr) ?? stderrExcerpt(result.stderr);
406
+ if (isRuntimeVersionTooOld(reason)) {
407
+ // Still refused at a recheck: the remembered model is the only candidate (no catalog).
408
+ const candidates = memo
409
+ ? [memo.to]
410
+ : await runtime
411
+ .fallbackModelsAfterVersionMismatch({
412
+ reason,
413
+ requestedModel: input.model,
414
+ run: (auxArgs) => spawnImpl(binary, auxArgs, { cwd: input.cwd, env, timeoutMs: AUX_COMMAND_TIMEOUT_MS }),
415
+ })
416
+ .catch(() => []);
417
+ const from = versionTooOldModel(reason) ?? input.model ?? "";
418
+ const tried = [];
419
+ for (const to of candidates.slice(0, MAX_MODEL_FALLBACK_RETRIES)) {
420
+ if (!to || to === input.model || tried.includes(to) || input.signal?.aborted)
421
+ continue;
422
+ tried.push(to);
423
+ const retryArgs = runtime.buildArgs({ ...turnInput, model: to }, { outputFile, newSessionId });
424
+ const retry = await spawnImpl(binary, retryArgs, spawnOptions);
425
+ if (input.signal?.aborted) {
426
+ // The owner stopped it: report the stop, remember nothing.
427
+ result = retry;
428
+ break;
429
+ }
430
+ if (retry.code === 0 && !retry.signal) {
431
+ result = retry;
432
+ modelFallback = { from, to };
433
+ rememberedModelFallbacks.set(memoKey, { from, to, at: nowMs(), turns: 0 });
434
+ fallbackFailure = undefined;
435
+ break;
436
+ }
437
+ // The remembered model no longer works either: forget it.
438
+ if (memo && to === memo.to)
439
+ rememberedModelFallbacks.delete(memoKey);
440
+ const retryReason = runtime.failureReason?.(retry.stdout, retry.stderr) ?? stderrExcerpt(retry.stderr);
441
+ fallbackFailure = `${fallbackFailure ? `${fallbackFailure}; ` : ""}retry on ${to} failed too${retryReason ? `: ${retryReason.slice(0, 200)}` : ""}`;
442
+ // Only another version refusal is worth the next candidate.
443
+ if (!isRuntimeVersionTooOld(retryReason))
444
+ break;
445
+ }
446
+ }
447
+ }
366
448
  }
367
449
  catch (err) {
368
450
  const code = err.code;
@@ -397,21 +479,25 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
397
479
  // Streaming runtimes write JSON events to stdout: never show those to a
398
480
  // person as the reason. stderr, then a plain description, instead.
399
481
  const stdoutIsEvents = result.stdout.split("\n").some((line) => parseJsonLine(line) !== null);
482
+ const stdoutTail = !result.stderr.trim() && Boolean(result.stdout.trim()) && !stdoutIsEvents;
400
483
  const fallback = result.stderr.trim()
401
484
  ? stderrExcerpt(result.stderr)
402
- : result.stdout.trim() && !stdoutIsEvents
485
+ : stdoutTail
403
486
  ? stderrExcerpt(result.stdout)
404
487
  : result.signal
405
488
  ? `the ${runtime.binary} process was killed (${result.signal})`
406
489
  : `${runtime.binary} exited with code ${result.code}`;
407
- throw new RuntimeError(`${runtime.binary} exited with ${result.code ?? `signal ${result.signal}`}${reason ? `: ${reason.slice(0, 200)}` : ""}`, reason ? reason.slice(0, 400) : fallback, result.code);
490
+ throw new RuntimeError(`${runtime.binary} exited with ${result.code ?? `signal ${result.signal}`}${reason ? `: ${reason.slice(0, 200)}` : ""}${fallbackFailure ? ` (${fallbackFailure})` : ""}`, reason ? reason.slice(0, 400) : fallback, result.code, !reason && stdoutTail ? { excerptIsOutputTail: true } : {});
408
491
  }
409
492
  const parsed = runtime.parseOutput(result.stdout, { outputFile, outputFileText });
493
+ // A turn that ran on the requested model and succeeded: nothing to remember.
494
+ if (!modelFallback)
495
+ rememberedModelFallbacks.delete(memoKey);
410
496
  // Strip any leaked model control token (`<|eos|>` etc.) from the final reply
411
497
  // before it is posted; a reply that was only such tokens becomes empty and the
412
498
  // caller applies its normal empty-reply handling.
413
499
  const text = stripControlTokens(parsed.text);
414
500
  // A resumed session keeps its id even when the CLI omits it from the envelope.
415
- return { text, sessionId: parsed.sessionId ?? input.sessionId };
501
+ return { text, sessionId: parsed.sessionId ?? input.sessionId, ...(modelFallback ? { modelFallback } : {}) };
416
502
  }
417
503
  //# sourceMappingURL=index.js.map
@@ -85,7 +85,7 @@ export function messagesStreamResult(stdout, runtimeName) {
85
85
  }
86
86
  }
87
87
  if (!sawJson)
88
- throw new RuntimeError(`${runtimeName} returned non-JSON output`, stdout.trim().slice(-400), null);
88
+ throw new RuntimeError(`${runtimeName} returned non-JSON output`, stdout.trim().slice(-400), null, { excerptIsOutputTail: true });
89
89
  if (result?.is_error === true) {
90
90
  const detail = typeof result.result === "string" && result.result ? result.result : String(result.subtype ?? "error");
91
91
  throw new RuntimeError(`${runtimeName} reported an error: ${detail.slice(0, 200)}`, detail.slice(0, 400), null);
@@ -70,6 +70,16 @@ export type RuntimeStreamEvent = {
70
70
  export interface RuntimeTurnResult {
71
71
  text: string;
72
72
  sessionId?: string;
73
+ /**
74
+ * Set when the first attempt failed because the installed CLI is too old for
75
+ * the model it was asked to use, and the turn was run again on a model this
76
+ * CLI supports. `from` is the refused model ("" when it came from the CLI's
77
+ * own config), `to` the one that answered.
78
+ */
79
+ modelFallback?: {
80
+ from: string;
81
+ to: string;
82
+ };
73
83
  }
74
84
  export interface SpawnResult {
75
85
  code: number | null;
@@ -106,9 +116,17 @@ export declare class RuntimeError extends Error {
106
116
  readonly started: boolean;
107
117
  /** True when the turn was ended on purpose (owner stop), not by a model failure. */
108
118
  readonly aborted: boolean;
119
+ /**
120
+ * True when `stderrExcerpt` is a tail of the runtime's ordinary output, not
121
+ * an error it reported. Such a tail can contain anything the model printed
122
+ * ("command not found" from a shell step, a 503 in a fetched page), so it
123
+ * must never decide what went wrong; only the message may.
124
+ */
125
+ readonly excerptIsOutputTail: boolean;
109
126
  constructor(message: string, stderrExcerpt: string, exitCode: number | null, opts?: {
110
127
  started?: boolean;
111
128
  aborted?: boolean;
129
+ excerptIsOutputTail?: boolean;
112
130
  });
113
131
  }
114
132
  export interface BotRuntime {
@@ -145,6 +163,19 @@ export interface BotRuntime {
145
163
  * than the stderr excerpt is known. Pure.
146
164
  */
147
165
  failureReason?(stdout: string, stderr: string): string | undefined;
166
+ /**
167
+ * Optional: after a failed turn whose reason says this CLI is too old for
168
+ * the requested model, the models to try instead, best first (at most
169
+ * `MAX_MODEL_FALLBACK_RETRIES`), or empty when none is known. The turn is
170
+ * run again on each in order until one is not refused for its version.
171
+ * `run` spawns the same binary with other arguments (catalog, version) and
172
+ * must not need the network or a sign-in.
173
+ */
174
+ fallbackModelsAfterVersionMismatch?(args: {
175
+ reason: string;
176
+ requestedModel: string | undefined;
177
+ run: (args: string[]) => Promise<SpawnResult>;
178
+ }): Promise<string[]>;
148
179
  }
149
180
  /**
150
181
  * Turns have no time limit: a turn runs until the model process exits (the
@@ -152,6 +183,8 @@ export interface BotRuntime {
152
183
  * unset, empty, zero or invalid means unlimited.
153
184
  */
154
185
  export declare const TURN_TIMEOUT_ENV = "HQ_BOT_TURN_TIMEOUT_MS";
186
+ /** Most extra attempts one turn makes on other models after a version refusal. Never a loop. */
187
+ export declare const MAX_MODEL_FALLBACK_RETRIES = 2;
155
188
  export declare function turnTimeoutMs(env?: NodeJS.ProcessEnv): number | undefined;
156
189
  /** Parse one NDJSON line; null when it is not a JSON object. */
157
190
  export declare function parseJsonLine(line: string): Record<string, unknown> | null;
@@ -12,6 +12,13 @@ export class RuntimeError extends Error {
12
12
  started;
13
13
  /** True when the turn was ended on purpose (owner stop), not by a model failure. */
14
14
  aborted;
15
+ /**
16
+ * True when `stderrExcerpt` is a tail of the runtime's ordinary output, not
17
+ * an error it reported. Such a tail can contain anything the model printed
18
+ * ("command not found" from a shell step, a 503 in a fetched page), so it
19
+ * must never decide what went wrong; only the message may.
20
+ */
21
+ excerptIsOutputTail;
15
22
  constructor(message, stderrExcerpt, exitCode, opts = {}) {
16
23
  super(message);
17
24
  this.stderrExcerpt = stderrExcerpt;
@@ -19,6 +26,7 @@ export class RuntimeError extends Error {
19
26
  this.name = "RuntimeError";
20
27
  this.started = opts.started ?? true;
21
28
  this.aborted = opts.aborted ?? false;
29
+ this.excerptIsOutputTail = opts.excerptIsOutputTail ?? false;
22
30
  }
23
31
  }
24
32
  /**
@@ -27,6 +35,8 @@ export class RuntimeError extends Error {
27
35
  * unset, empty, zero or invalid means unlimited.
28
36
  */
29
37
  export const TURN_TIMEOUT_ENV = "HQ_BOT_TURN_TIMEOUT_MS";
38
+ /** Most extra attempts one turn makes on other models after a version refusal. Never a loop. */
39
+ export const MAX_MODEL_FALLBACK_RETRIES = 2;
30
40
  export function turnTimeoutMs(env = process.env) {
31
41
  const raw = Number(env[TURN_TIMEOUT_ENV]);
32
42
  return Number.isFinite(raw) && raw > 0 ? raw : undefined;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What went wrong with a bot's coding tool (Claude Code, Codex, Grok), in a
3
+ * class a person can act on.
4
+ *
5
+ * A failed model turn used to be reported by quoting the runtime's own error
6
+ * in the DM ("Sorry, I couldn't get started on my own (codex failed: The
7
+ * 'gpt-6-astra' model requires a newer version of Codex ...)"), and the
8
+ * fallback copy told the person to check the sign-in even when the cause was
9
+ * something else. Now every failure is classified here, the person gets one
10
+ * plain sentence plus one next step, and the raw text goes only to the bot
11
+ * log. For the four repairable classes the DM also carries a machine-readable
12
+ * `runtime-repair` block in its `richContent` so the desktop can render a
13
+ * repair card; the plain body stays the fallback.
14
+ *
15
+ * Matching is narrow on purpose: each class has its own known error shapes,
16
+ * and anything unrecognised is `unknown`, which never blames the sign-in.
17
+ */
18
+ import type { BotRuntimeId } from "./config.js";
19
+ export type RuntimeFailureClass =
20
+ /** The tool's login is missing, expired or revoked. */
21
+ "signed-out"
22
+ /** The installed tool is too old for the model it was asked to use. */
23
+ | "cli-outdated"
24
+ /** The model id is unknown, retired, or not available to this account. */
25
+ | "model-unsupported"
26
+ /** Network trouble, a rate or usage limit, an overloaded service, or a timeout. */
27
+ | "transient"
28
+ /** The tool is not installed (or not on PATH) on this computer. */
29
+ | "not-installed"
30
+ /** Anything else. Neutral copy; never blames the sign-in. */
31
+ | "unknown";
32
+ /** The four classes that get a repair card in the DM. */
33
+ export type RuntimeRepairClass = Extract<RuntimeFailureClass, "signed-out" | "cli-outdated" | "model-unsupported" | "transient">;
34
+ /** The one thing the person can do about it. The desktop maps each to a button. */
35
+ export type RuntimeRepairAction = "signIn" | "update" | "switchModel" | "tryAgain";
36
+ export declare const RUNTIME_REPAIR_ACTIONS: Record<RuntimeRepairClass, RuntimeRepairAction>;
37
+ export declare function isRuntimeRepairClass(cls: RuntimeFailureClass): cls is RuntimeRepairClass;
38
+ /** Sort a runtime's failure text into one class. Order matters: most specific first. */
39
+ export declare function classifyRuntimeFailure(text: string | undefined | null): RuntimeFailureClass;
40
+ /**
41
+ * Class of a failed turn's error. A RuntimeError's excerpt (the runtime's own
42
+ * reported error: failureReason, turn.failed, stderr) is the better evidence;
43
+ * its message (what HQ saw, e.g. "codex is not installed or not on PATH")
44
+ * decides when the excerpt says nothing known. An excerpt that is only a tail
45
+ * of the runtime's ordinary output (`excerptIsOutputTail`) is never read: the
46
+ * model may have printed "command not found" or a 503 itself.
47
+ */
48
+ export declare function classifyRuntimeError(err: unknown): RuntimeFailureClass;
49
+ export declare function isRuntimeVersionTooOld(text: string | undefined | null): boolean;
50
+ /** "gpt-6-astra" from "The 'gpt-6-astra' model requires a newer version of Codex ...". */
51
+ export declare function versionTooOldModel(text: string | undefined | null): string | undefined;
52
+ /** Where the failure happened, which changes only the next step's wording. */
53
+ export type RuntimeFailureContext = "kickoff" | "message";
54
+ /**
55
+ * The plain reason, as a sentence fragment without a closing period
56
+ * ("my Codex app is out of date"). Used in job run dispositions.
57
+ */
58
+ export declare function runtimeFailureReason(cls: RuntimeFailureClass, runtime: BotRuntimeId | string): string;
59
+ /** The one next step, as a full sentence. */
60
+ export declare function runtimeFailureNextStep(cls: RuntimeFailureClass, runtime: BotRuntimeId | string, context?: RuntimeFailureContext): string;
61
+ /** "My Codex app is out of date. Update Codex, then send me any message." */
62
+ export declare function runtimeFailureText(cls: RuntimeFailureClass, runtime: BotRuntimeId | string, context?: RuntimeFailureContext): string;
63
+ /** A failure after the model started (and maybe posted part of the answer). */
64
+ export declare function runtimeDidNotFinishText(cls: RuntimeFailureClass, runtime: BotRuntimeId | string, progressPosted: boolean): string;
65
+ /**
66
+ * The machine-readable repair payload a failure DM carries. The DM transport
67
+ * (POST /v1/notify/dm) stores structured data only in `richContent`
68
+ * (`{ v: 1, blocks: [...] }`, each block with a string `kind`), so this object
69
+ * travels as `richContent.blocks[0]`. The plain `body` is always the fallback.
70
+ */
71
+ export interface RuntimeRepairBlock {
72
+ v: 1;
73
+ kind: "runtime-repair";
74
+ class: RuntimeRepairClass;
75
+ runtime: BotRuntimeId | string;
76
+ action: RuntimeRepairAction;
77
+ /** The bot's handle (its folder name), as `hq bot set-model` takes it. */
78
+ botName: string;
79
+ /** Event id of the person's message that failed, so "Try again" can resend it. */
80
+ retryOf?: string;
81
+ /** For `switchModel`: a model this computer's tool can run. */
82
+ suggestedModel?: string;
83
+ }
84
+ export interface RuntimeRichContent {
85
+ v: 1;
86
+ blocks: [RuntimeRepairBlock];
87
+ }
88
+ export interface RuntimeRepairNotice {
89
+ cls: RuntimeFailureClass;
90
+ /** The plain DM body. */
91
+ text: string;
92
+ /** Absent for `not-installed` and `unknown`: those get the plain text only. */
93
+ richContent?: RuntimeRichContent;
94
+ }
95
+ export declare function runtimeRepairBlock(args: {
96
+ cls: RuntimeRepairClass;
97
+ runtime: BotRuntimeId | string;
98
+ botName: string;
99
+ retryOf?: string;
100
+ suggestedModel?: string;
101
+ }): RuntimeRepairBlock;
102
+ /** The plain body plus, for a repairable class, the repair block. */
103
+ export declare function runtimeRepairNotice(args: {
104
+ cls: RuntimeFailureClass;
105
+ runtime: BotRuntimeId | string;
106
+ botName: string;
107
+ context: RuntimeFailureContext;
108
+ /** Overrides the default body (e.g. the "I didn't finish that" form). */
109
+ text?: string;
110
+ retryOf?: string;
111
+ suggestedModel?: string;
112
+ }): RuntimeRepairNotice;
113
+ //# sourceMappingURL=runtime-failure.d.ts.map