@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.
- package/CHANGELOG.md +48 -0
- package/assets/scaffold/core/scripts/rebuild-companies-index.sh +30 -37
- package/dist/command-catalog.generated.d.ts +83 -3
- package/dist/command-catalog.generated.js +100 -3
- package/dist/commands/access.d.ts +1 -0
- package/dist/commands/access.js +17 -5
- package/dist/commands/bot.d.ts +12 -0
- package/dist/commands/bot.js +54 -2
- package/dist/commands/core-native-family-flag.js +1 -1
- package/dist/commands/files.js +7 -2
- package/dist/commands/members.d.ts +42 -0
- package/dist/commands/members.js +151 -1
- package/dist/commands/secrets.js +1 -1
- package/dist/fast-scaffold.d.ts +1 -1
- package/dist/fast-scaffold.js +1 -0
- package/dist/lib/bot/api.d.ts +11 -0
- package/dist/lib/bot/api.js +2 -0
- package/dist/lib/bot/config.js +4 -2
- package/dist/lib/bot/inflight.d.ts +3 -0
- package/dist/lib/bot/probe.d.ts +57 -0
- package/dist/lib/bot/probe.js +100 -0
- package/dist/lib/bot/run.d.ts +4 -0
- package/dist/lib/bot/run.js +171 -37
- package/dist/lib/bot/runtime/codex.d.ts +67 -1
- package/dist/lib/bot/runtime/codex.js +197 -3
- package/dist/lib/bot/runtime/grok.js +2 -2
- package/dist/lib/bot/runtime/index.d.ts +8 -0
- package/dist/lib/bot/runtime/index.js +91 -5
- package/dist/lib/bot/runtime/messages-stream.js +1 -1
- package/dist/lib/bot/runtime/types.d.ts +33 -0
- package/dist/lib/bot/runtime/types.js +10 -0
- package/dist/lib/bot/runtime-failure.d.ts +113 -0
- package/dist/lib/bot/runtime-failure.js +180 -0
- package/dist/lib/bot/runtime-sign-in.js +2 -2
- package/dist/lib/bot/status.d.ts +21 -0
- package/dist/lib/index-render/companies.js +33 -16
- package/dist/lib/index-render/shared.d.ts +18 -0
- package/dist/lib/index-render/shared.js +24 -0
- package/dist/lib/lanes/monitor.js +30 -15
- 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
|
|
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) =>
|
|
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 {
|
|
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
|
|
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
|
-
:
|
|
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
|