@indigoai-us/hq-cli 5.345.76 → 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.
@@ -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
@@ -0,0 +1,180 @@
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 { isRuntimeSignInFailure, runtimeDisplayName } from "./runtime-sign-in.js";
19
+ export const RUNTIME_REPAIR_ACTIONS = {
20
+ "signed-out": "signIn",
21
+ "cli-outdated": "update",
22
+ "model-unsupported": "switchModel",
23
+ transient: "tryAgain",
24
+ };
25
+ export function isRuntimeRepairClass(cls) {
26
+ return cls in RUNTIME_REPAIR_ACTIONS;
27
+ }
28
+ const NOT_INSTALLED = /\bis not installed or not on PATH\b|\bspawn \S+ ENOENT\b|\bcommand not found\b/i;
29
+ /**
30
+ * The tool says it is too old for the model. Codex: "The 'gpt-6-astra' model
31
+ * requires a newer version of Codex. Please upgrade to the latest app or CLI
32
+ * and try again." Claude Code words it "... requires a newer version of
33
+ * Claude Code".
34
+ */
35
+ const VERSION_TOO_OLD = /\brequires a newer version of (?:codex|claude code|grok|the (?:app|cli))\b|\bplease upgrade to the latest (?:app or cli|version of (?:codex|claude code|grok))\b/i;
36
+ /** The failing model id inside a version-too-old message, when it names one. */
37
+ const VERSION_TOO_OLD_MODEL = /\bthe ['"`]([^'"`\s]{1,128})['"`] model requires a newer version of\b/i;
38
+ /**
39
+ * Claude Code: "There's an issue with the selected model" (its own wording for
40
+ * a model the account cannot use) and the API's 404 not_found_error whose
41
+ * message starts "model: <id>". Codex and the OpenAI API: model_not_found,
42
+ * "does not exist or you do not have access", "not supported when using
43
+ * Codex with a ChatGPT account".
44
+ */
45
+ const MODEL_UNSUPPORTED = /there'?s an issue with the selected model|\bnot_found_error\b[^\n]{0,200}\bmodel:\s|\bmodel_not_found\b|\b(?:unknown|invalid|unsupported) model\b|\bthe (?:requested )?model ['"`]?[^'"`\s]{1,128}['"`]? (?:does not exist|is not supported|is not available|was not found|has been deprecated)\b|\bmodel ['"`][^'"`\s]{1,128}['"`] (?:does not exist|is not supported|is not available|not found)\b|\bdo(?:es)? not have access to (?:the )?model\b|\bmodel is not supported when using codex with a chatgpt account\b/i;
46
+ const TRANSIENT = /\brate[_ ]limit|\busage[_ ]limit|\byou've hit your usage limit\b|\btoo many requests\b|\b429\b|\boverloaded\b|\b(?:502|503|504|529)\b|\bservice unavailable\b|\bbad gateway\b|\bgateway time-?out\b|\binternal server error\b|\bECONNRESET\b|\bECONNREFUSED\b|\bETIMEDOUT\b|\bENOTFOUND\b|\bEAI_AGAIN\b|\bENETUNREACH\b|\bsocket hang up\b|\bnetwork (?:error|is unreachable)\b|\bstream (?:disconnected|error)\b|\bconnection (?:reset|refused|closed|error)\b|\btimed out\b|\bexceeded \d+ms \(HQ_BOT_TURN_TIMEOUT_MS\)/i;
47
+ /** Sort a runtime's failure text into one class. Order matters: most specific first. */
48
+ export function classifyRuntimeFailure(text) {
49
+ const t = (text ?? "").trim();
50
+ if (!t)
51
+ return "unknown";
52
+ if (NOT_INSTALLED.test(t))
53
+ return "not-installed";
54
+ if (VERSION_TOO_OLD.test(t))
55
+ return "cli-outdated";
56
+ if (isRuntimeSignInFailure(t))
57
+ return "signed-out";
58
+ if (MODEL_UNSUPPORTED.test(t))
59
+ return "model-unsupported";
60
+ if (TRANSIENT.test(t))
61
+ return "transient";
62
+ return "unknown";
63
+ }
64
+ /**
65
+ * Class of a failed turn's error. A RuntimeError's excerpt (the runtime's own
66
+ * reported error: failureReason, turn.failed, stderr) is the better evidence;
67
+ * its message (what HQ saw, e.g. "codex is not installed or not on PATH")
68
+ * decides when the excerpt says nothing known. An excerpt that is only a tail
69
+ * of the runtime's ordinary output (`excerptIsOutputTail`) is never read: the
70
+ * model may have printed "command not found" or a 503 itself.
71
+ */
72
+ export function classifyRuntimeError(err) {
73
+ if (err && typeof err === "object" && "stderrExcerpt" in err) {
74
+ const e = err;
75
+ const message = typeof e.message === "string" ? e.message : "";
76
+ if (e.excerptIsOutputTail === true)
77
+ return classifyRuntimeFailure(message);
78
+ const fromExcerpt = classifyRuntimeFailure(typeof e.stderrExcerpt === "string" ? e.stderrExcerpt : "");
79
+ if (fromExcerpt !== "unknown")
80
+ return fromExcerpt;
81
+ return classifyRuntimeFailure(message);
82
+ }
83
+ return classifyRuntimeFailure(err instanceof Error ? err.message : String(err ?? ""));
84
+ }
85
+ export function isRuntimeVersionTooOld(text) {
86
+ return VERSION_TOO_OLD.test((text ?? "").trim());
87
+ }
88
+ /** "gpt-6-astra" from "The 'gpt-6-astra' model requires a newer version of Codex ...". */
89
+ export function versionTooOldModel(text) {
90
+ return VERSION_TOO_OLD_MODEL.exec(text ?? "")?.[1];
91
+ }
92
+ /**
93
+ * The plain reason, as a sentence fragment without a closing period
94
+ * ("my Codex app is out of date"). Used in job run dispositions.
95
+ */
96
+ export function runtimeFailureReason(cls, runtime) {
97
+ const tool = runtimeDisplayName(runtime);
98
+ switch (cls) {
99
+ case "signed-out":
100
+ return `${tool} needs you to sign in again on this computer`;
101
+ case "cli-outdated":
102
+ return `my ${tool} app is out of date`;
103
+ case "model-unsupported":
104
+ return `the model I'm set to use isn't available in ${tool} here`;
105
+ case "transient":
106
+ return `${tool} is busy or out of reach right now`;
107
+ case "not-installed":
108
+ return `${tool} isn't installed on this computer`;
109
+ default:
110
+ return `${tool} stopped with an error`;
111
+ }
112
+ }
113
+ /** The one next step, as a full sentence. */
114
+ export function runtimeFailureNextStep(cls, runtime, context = "message") {
115
+ const tool = runtimeDisplayName(runtime);
116
+ switch (cls) {
117
+ case "signed-out":
118
+ return `Use "Sign in to ${tool}" in HQ, then send me any message.`;
119
+ case "cli-outdated":
120
+ return `Update ${tool}, then send me any message.`;
121
+ case "model-unsupported":
122
+ return `Choose a different model in my settings, then send me any message.`;
123
+ case "transient":
124
+ return context === "kickoff"
125
+ ? `Send me any message in a few minutes and I'll get started.`
126
+ : `Send your message again in a few minutes.`;
127
+ case "not-installed":
128
+ return `Install ${tool} from HQ, then send me any message.`;
129
+ default:
130
+ return context === "kickoff"
131
+ ? `Send me any message and I'll try again. If it keeps happening, restart me from HQ.`
132
+ : `Send your message again. If it keeps happening, restart me from HQ.`;
133
+ }
134
+ }
135
+ function capitalize(s) {
136
+ return s ? s[0].toUpperCase() + s.slice(1) : s;
137
+ }
138
+ /** "My Codex app is out of date. Update Codex, then send me any message." */
139
+ export function runtimeFailureText(cls, runtime, context = "message") {
140
+ return `${capitalize(runtimeFailureReason(cls, runtime))}. ${runtimeFailureNextStep(cls, runtime, context)}`;
141
+ }
142
+ /** A failure after the model started (and maybe posted part of the answer). */
143
+ export function runtimeDidNotFinishText(cls, runtime, progressPosted) {
144
+ return `I didn't finish that. ${runtimeFailureText(cls, runtime)}${progressPosted ? " The messages above show how far I got." : ""}`;
145
+ }
146
+ export function runtimeRepairBlock(args) {
147
+ return {
148
+ v: 1,
149
+ kind: "runtime-repair",
150
+ class: args.cls,
151
+ runtime: args.runtime,
152
+ action: RUNTIME_REPAIR_ACTIONS[args.cls],
153
+ botName: args.botName,
154
+ ...(args.retryOf ? { retryOf: args.retryOf } : {}),
155
+ ...(args.suggestedModel ? { suggestedModel: args.suggestedModel.slice(0, 128) } : {}),
156
+ };
157
+ }
158
+ /** The plain body plus, for a repairable class, the repair block. */
159
+ export function runtimeRepairNotice(args) {
160
+ const text = args.text ?? runtimeFailureText(args.cls, args.runtime, args.context);
161
+ if (!isRuntimeRepairClass(args.cls))
162
+ return { cls: args.cls, text };
163
+ return {
164
+ cls: args.cls,
165
+ text,
166
+ richContent: {
167
+ v: 1,
168
+ blocks: [
169
+ runtimeRepairBlock({
170
+ cls: args.cls,
171
+ runtime: args.runtime,
172
+ botName: args.botName,
173
+ ...(args.retryOf ? { retryOf: args.retryOf } : {}),
174
+ ...(args.suggestedModel ? { suggestedModel: args.suggestedModel } : {}),
175
+ }),
176
+ ],
177
+ },
178
+ };
179
+ }
180
+ //# sourceMappingURL=runtime-failure.js.map
@@ -10,7 +10,7 @@
10
10
  */
11
11
  /** Time between the bot's own retries while its sign-in is broken. */
12
12
  export const SIGN_IN_RETRY_MS = 60_000;
13
- const SIGN_IN_FAILURE = /failed to authenticate|authentication[_ ]failed|not logged in|not signed in|please (?:run \/login|log ?in|sign ?in)|(?:oauth|access|refresh) token (?:has been |was )?(?:expired|revoked|invalid)|oauth session expired|invalid api key|\b401\b|unauthori[sz]ed|login (?:required|expired)|sign-?in (?:required|expired)/i;
13
+ const SIGN_IN_FAILURE = /failed to authenticate|authentication[_ ]failed|not logged in|not signed in|please (?:run \/login|log ?in|sign ?in)|(?:oauth|access|refresh) token (?:has been |has |was )?(?:expired|revoked|invalid)|oauth session expired|invalid api key|\b401\b|unauthori[sz]ed|login (?:required|expired)|sign-?in (?:required|expired)/i;
14
14
  /** Usage and rate limits are a different problem; a new sign-in does not fix them. */
15
15
  const NOT_SIGN_IN = /usage[_ ]limit|rate[_ ]limit|quota|too many requests|credits/i;
16
16
  export function isRuntimeSignInFailure(text) {
@@ -26,6 +26,6 @@ export function runtimeDisplayName(runtime) {
26
26
  export function signInNeededText(runtime, kickoff = false) {
27
27
  const tool = runtimeDisplayName(runtime);
28
28
  return (`I can't work right now: ${tool} needs you to sign in again on this computer. ` +
29
- `Use "Sign in to ${tool}" in HQ, and I'll ${kickoff ? "get started" : "answer this"} as soon as it works again — no need to send it twice.`);
29
+ `Use "Sign in to ${tool}" in HQ, and I'll ${kickoff ? "get started" : "answer this"} as soon as it works again. No need to send it twice.`);
30
30
  }
31
31
  //# sourceMappingURL=runtime-sign-in.js.map
@@ -44,6 +44,27 @@ export interface BotStatusFile {
44
44
  * reason); cleared by the next turn that works. `null` = known to work.
45
45
  */
46
46
  runtimeSignIn?: RuntimeSignInIssue | null;
47
+ /**
48
+ * Set when the installed coding tool is too old for the model it was asked
49
+ * to use and the last turn ran on `to` instead (`from` is "" when the model
50
+ * came from the tool's own config). `null` once a turn runs on the model it
51
+ * was asked for again. Updating the tool clears it.
52
+ */
53
+ runtimeModelFallback?: {
54
+ from: string;
55
+ to: string;
56
+ at: string;
57
+ } | null;
58
+ /**
59
+ * Set while the bot holds its model turns after FAILURE_LIMIT failures in a
60
+ * window (it used to exit instead, and a bot that exited could neither load
61
+ * an updated hq-cli nor answer the next message). `null` once the hold is
62
+ * over; the next message then runs a fresh turn.
63
+ */
64
+ modelHold?: {
65
+ until: string;
66
+ reason: string;
67
+ } | null;
47
68
  /** hq-cli version this process loaded (a bot keeps it until it restarts). */
48
69
  cliVersion?: string;
49
70
  updatedAt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.76",
3
+ "version": "5.345.77",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {