@indigoai-us/hq-cli 5.345.174 → 5.345.175
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 +7 -0
- package/dist/commands/bot.js +3 -0
- package/dist/commands/dm.d.ts +7 -0
- package/dist/commands/dm.js +16 -4
- package/dist/lib/bot/run.d.ts +2 -0
- package/dist/lib/bot/run.js +47 -13
- package/dist/lib/bot/runtime/index.js +18 -2
- package/dist/lib/bot/runtime/types.d.ts +13 -0
- package/dist/lib/bot/runtime/types.js +13 -0
- package/dist/lib/bot/runtime-failure.d.ts +17 -6
- package/dist/lib/bot/runtime-failure.js +54 -9
- package/dist/lib/bot/status.d.ts +10 -0
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.175] — 2026-10-28
|
|
6
|
+
- A local bot whose coding tool crashed before it could start on a message (seen as "Claude Code stopped with an error" on the first message after a day or more idle) now tries that message once more on its own. If it crashes again, the reply carries a Heal bot card so HQ can restart the bot and send the message again, and the bot's log and `hq bot status` record what the tool printed, how long it ran and which binary it was, so the cause can be found.
|
|
7
|
+
- `hq dm --attach` after a person `hq login` looks up the caller's `prs_` uid
|
|
8
|
+
when the session token has no `custom:entityUid` (human tokens never carry
|
|
9
|
+
that claim). Plain `hq dm` already worked; attaching a file no longer fails
|
|
10
|
+
with "Could not read your person uid from the session token".
|
|
11
|
+
|
|
5
12
|
## [5.345.174] — 2026-10-27
|
|
6
13
|
- `hq channels delete <name> --yes` deletes a channel you own, including its
|
|
7
14
|
messages. Group DMs stay undeletable. Without `--yes` the command refuses.
|
package/dist/commands/bot.js
CHANGED
|
@@ -1257,6 +1257,9 @@ async function runBotStatus(nameArg, opts) {
|
|
|
1257
1257
|
if (status.modelHold && new Date(status.modelHold.until).getTime() > Date.now()) {
|
|
1258
1258
|
console.log(` ${chalk.yellow(`holding until ${status.modelHold.until}`)} after repeated model failures; the next message after that runs again`);
|
|
1259
1259
|
}
|
|
1260
|
+
if (status.lastCrash) {
|
|
1261
|
+
console.log(` last crash: ${chalk.yellow(`${status.lastCrash.at}, exit ${status.lastCrash.code ?? "?"}${status.lastCrash.durationMs !== undefined ? ` after ${status.lastCrash.durationMs} ms` : ""}`)}`);
|
|
1262
|
+
}
|
|
1260
1263
|
if (status.runtimeModelFallback) {
|
|
1261
1264
|
console.log(` model: ${chalk.yellow(`running on ${status.runtimeModelFallback.to}`)} because ${row.runtime} here is too old for ${status.runtimeModelFallback.from || "its configured model"}; update ${row.runtime} to use it`);
|
|
1262
1265
|
}
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -392,6 +392,13 @@ export interface CallerIdentity {
|
|
|
392
392
|
* Pure → unit-testable.
|
|
393
393
|
*/
|
|
394
394
|
export declare function callerFromToken(token: string): CallerIdentity;
|
|
395
|
+
/**
|
|
396
|
+
* Vault scope uid for `--attach`. Human Cognito sessions do not carry
|
|
397
|
+
* `custom:entityUid` (hq-pro sets that only on agent and outpost tokens), so
|
|
398
|
+
* JWT peek is not enough after a normal `hq login`. Fall back to the
|
|
399
|
+
* canonical person lookup used by `--personal` files and secrets.
|
|
400
|
+
*/
|
|
401
|
+
export declare function callerUidForAttach(token: string): Promise<string>;
|
|
395
402
|
/**
|
|
396
403
|
* Newest-first channel history → the caller's most recent top-level eventId.
|
|
397
404
|
* Replies (those with rootEventId) are skipped. Pure → unit-testable.
|
package/dist/commands/dm.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
4
|
-
import { resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
4
|
+
import { resolveCallerPersonUid, resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
5
5
|
import { isMachineIdentity } from "@indigoai-us/hq-cloud";
|
|
6
6
|
import { peekIdToken } from "../utils/id-token.js";
|
|
7
7
|
import { redactAgentInboxItem as redactInboxCredentials } from "../lib/agent-kit/inbox-redact.js";
|
|
@@ -792,9 +792,9 @@ async function runChannelSend(channelName, message, opts) {
|
|
|
792
792
|
// personal channels in the caller's personal vault.
|
|
793
793
|
let uploaded = [];
|
|
794
794
|
if (localFiles.length > 0) {
|
|
795
|
-
const callerUid =
|
|
795
|
+
const callerUid = await callerUidForAttach(token);
|
|
796
796
|
const scopeUid = channel.companyUid?.trim()
|
|
797
|
-
? attachmentScopeForChannel(channel, callerUid
|
|
797
|
+
? attachmentScopeForChannel(channel, callerUid)
|
|
798
798
|
: personalScopeForCaller(callerUid);
|
|
799
799
|
uploaded = await uploadDmAttachments({
|
|
800
800
|
token,
|
|
@@ -989,7 +989,7 @@ async function runDmSend(recipient, message, rawOpts) {
|
|
|
989
989
|
if (localFiles.length > 0) {
|
|
990
990
|
// A 1:1 attachment is stored in the SENDER's personal vault under the
|
|
991
991
|
// sorted participant pair; the server lets either member read it.
|
|
992
|
-
const callerUid =
|
|
992
|
+
const callerUid = await callerUidForAttach(token);
|
|
993
993
|
const scopeUid = personalScopeForCaller(callerUid);
|
|
994
994
|
const peerUid = await resolveDmPeerUid(token, recipient);
|
|
995
995
|
uploaded = await uploadDmAttachments({
|
|
@@ -1363,6 +1363,18 @@ export function callerFromToken(token) {
|
|
|
1363
1363
|
...(emailNorm ? { email: emailNorm } : {}),
|
|
1364
1364
|
};
|
|
1365
1365
|
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Vault scope uid for `--attach`. Human Cognito sessions do not carry
|
|
1368
|
+
* `custom:entityUid` (hq-pro sets that only on agent and outpost tokens), so
|
|
1369
|
+
* JWT peek is not enough after a normal `hq login`. Fall back to the
|
|
1370
|
+
* canonical person lookup used by `--personal` files and secrets.
|
|
1371
|
+
*/
|
|
1372
|
+
export async function callerUidForAttach(token) {
|
|
1373
|
+
const fromJwt = callerFromToken(token).personUid;
|
|
1374
|
+
if (fromJwt)
|
|
1375
|
+
return fromJwt;
|
|
1376
|
+
return resolveCallerPersonUid(token, undefined, { command: "hq dm --attach" });
|
|
1377
|
+
}
|
|
1366
1378
|
function isCallerMessage(m, me) {
|
|
1367
1379
|
if (me.personUid && m.fromPersonUid === me.personUid)
|
|
1368
1380
|
return true;
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -85,6 +85,8 @@ export declare const FAILURE_WINDOW_MS: number;
|
|
|
85
85
|
export declare const FAILURE_LIMIT = 5;
|
|
86
86
|
/** Resumed-session failures with the model CLI's unknown error, in a row, before the session is cleared. */
|
|
87
87
|
export declare const RESUMED_UNKNOWN_ERROR_LIMIT = 2;
|
|
88
|
+
/** Wait before the one automatic re-run of a turn whose runtime crashed before it started. */
|
|
89
|
+
export declare const RUNTIME_CRASH_RETRY_DELAY_MS = 3000;
|
|
88
90
|
export declare const MAX_ATTEMPTS_PER_MESSAGE = 3;
|
|
89
91
|
export declare const THINKING_EMOJI = "\uD83D\uDC40";
|
|
90
92
|
export declare const ROOM_THINKING_STATUS = "is thinking\u2026";
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -129,6 +129,8 @@ export const FAILURE_WINDOW_MS = 10 * 60 * 1000;
|
|
|
129
129
|
export const FAILURE_LIMIT = 5;
|
|
130
130
|
/** Resumed-session failures with the model CLI's unknown error, in a row, before the session is cleared. */
|
|
131
131
|
export const RESUMED_UNKNOWN_ERROR_LIMIT = 2;
|
|
132
|
+
/** Wait before the one automatic re-run of a turn whose runtime crashed before it started. */
|
|
133
|
+
export const RUNTIME_CRASH_RETRY_DELAY_MS = 3_000;
|
|
132
134
|
/** The model CLI's own message, exactly; "Unexpected token" in a stack trace is not it. */
|
|
133
135
|
const RESUMED_UNKNOWN_ERROR = /an unknown error occurred \(unexpected\)/i;
|
|
134
136
|
export const MAX_ATTEMPTS_PER_MESSAGE = 3;
|
|
@@ -693,7 +695,7 @@ export async function runBot(deps) {
|
|
|
693
695
|
// nothing while their next messages wait out the hold.
|
|
694
696
|
if (tell) {
|
|
695
697
|
try {
|
|
696
|
-
await tell(holdNoticeText(modelHoldMs, messagePending, cls ? runtimeFailureText(cls, runtime.id) : undefined));
|
|
698
|
+
await tell(holdNoticeText(modelHoldMs, messagePending, cls ? runtimeFailureText(cls, runtime.id, "message", config.name) : undefined));
|
|
697
699
|
}
|
|
698
700
|
catch (err) {
|
|
699
701
|
log("warn", `could not say that turns are on hold: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1039,16 +1041,35 @@ export async function runBot(deps) {
|
|
|
1039
1041
|
}
|
|
1040
1042
|
return turn;
|
|
1041
1043
|
};
|
|
1042
|
-
const
|
|
1044
|
+
const attemptOnce = async () => {
|
|
1045
|
+
const session = readBotSession(dir, config.runtime, sessionScope);
|
|
1046
|
+
try {
|
|
1047
|
+
return await runAttempt(session, await composePrompt(session));
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
if (!missingResumedSession(err))
|
|
1051
|
+
throw err;
|
|
1052
|
+
clearBotSession(dir, sessionScope);
|
|
1053
|
+
log("warn", "saved model session is gone; starting a new one with recent DM history");
|
|
1054
|
+
return await runAttempt(null, await composePrompt(null));
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1043
1057
|
try {
|
|
1044
|
-
return await
|
|
1058
|
+
return await attemptOnce();
|
|
1045
1059
|
}
|
|
1046
1060
|
catch (err) {
|
|
1047
|
-
|
|
1061
|
+
// The coding tool's process died before the turn started (Bun's
|
|
1062
|
+
// "error: An unknown error occurred (Unexpected)" after a long idle,
|
|
1063
|
+
// October 2026). A fresh process usually works: wait a moment and run
|
|
1064
|
+
// the same turn once more. If that fails too, the caller's normal
|
|
1065
|
+
// failure path tells the person (with the Heal bot card).
|
|
1066
|
+
if (stream.signal?.aborted || classifyRuntimeError(err) !== "runtime-crashed")
|
|
1048
1067
|
throw err;
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1068
|
+
log("info", "runtime crashed before the turn started; retrying once");
|
|
1069
|
+
await sleep(RUNTIME_CRASH_RETRY_DELAY_MS, stream.signal);
|
|
1070
|
+
if (stream.signal?.aborted)
|
|
1071
|
+
throw err;
|
|
1072
|
+
return await attemptOnce();
|
|
1052
1073
|
}
|
|
1053
1074
|
};
|
|
1054
1075
|
/** Failure bookkeeping shared by every model turn: window, health, status, log, stale session. */
|
|
@@ -1059,7 +1080,20 @@ export async function runBot(deps) {
|
|
|
1059
1080
|
const count = failures.record(now().getTime());
|
|
1060
1081
|
modelHealth = count >= FAILURE_LIMIT ? "failed" : "degraded";
|
|
1061
1082
|
setStatus({ restarts: (readStatus()?.restarts ?? 0) + 1, recentFailures: count, lastError: excerpt.slice(0, 400) });
|
|
1062
|
-
|
|
1083
|
+
const cls = classifyRuntimeError(err);
|
|
1084
|
+
log("error", `model turn failed (${count}/${FAILURE_LIMIT} in window, ${cls}): ${excerpt}`);
|
|
1085
|
+
if (cls === "runtime-crashed" && err instanceof RuntimeError) {
|
|
1086
|
+
const stderr = (err.stderrFull ?? err.stderrExcerpt).slice(0, 2000);
|
|
1087
|
+
log("error", `runtime crash evidence: exit ${err.exitCode ?? "?"}, lived ${err.durationMs ?? "?"} ms, binary ${err.binaryPath ?? runtime.binary}; stderr: ${JSON.stringify(stderr)}; stdout head: ${JSON.stringify((err.stdoutHead ?? "").slice(0, 300))}`);
|
|
1088
|
+
setStatus({
|
|
1089
|
+
lastCrash: {
|
|
1090
|
+
at: now().toISOString(),
|
|
1091
|
+
code: err.exitCode,
|
|
1092
|
+
...(err.durationMs !== undefined ? { durationMs: err.durationMs } : {}),
|
|
1093
|
+
stderr,
|
|
1094
|
+
},
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1063
1097
|
// A resumed session that no longer exists must not poison every turn: the
|
|
1064
1098
|
// next turn starts fresh with the recent DM history prepended. The model
|
|
1065
1099
|
// CLI's "An unknown error occurred (Unexpected)" (every turn of a
|
|
@@ -1683,7 +1717,7 @@ export async function runBot(deps) {
|
|
|
1683
1717
|
runtime: runtime.id,
|
|
1684
1718
|
botName: config.name,
|
|
1685
1719
|
context: "kickoff",
|
|
1686
|
-
...(outcome.started && poster.postedCount > 0 ? { text: runtimeDidNotFinishText(cls, runtime.id, true) } : {}),
|
|
1720
|
+
...(outcome.started && poster.postedCount > 0 ? { text: runtimeDidNotFinishText(cls, runtime.id, true, config.name) } : {}),
|
|
1687
1721
|
});
|
|
1688
1722
|
try {
|
|
1689
1723
|
await postRepair(target, notice);
|
|
@@ -1852,7 +1886,7 @@ export async function runBot(deps) {
|
|
|
1852
1886
|
}
|
|
1853
1887
|
// A missing tool does not come back between retries: say so now.
|
|
1854
1888
|
if (attempt >= MAX_ATTEMPTS_PER_MESSAGE || cls === "not-installed") {
|
|
1855
|
-
const plain = runtimeFailureText(cls, runtime.id);
|
|
1889
|
+
const plain = runtimeFailureText(cls, runtime.id, "message", config.name);
|
|
1856
1890
|
await sendToPerson(`I couldn't run this one. ${plain}`).catch(() => undefined);
|
|
1857
1891
|
await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(plain), disposition: `${runtime.id} could not be started` });
|
|
1858
1892
|
markReported("failed");
|
|
@@ -1872,12 +1906,12 @@ export async function runBot(deps) {
|
|
|
1872
1906
|
// reports and says this real reason, not "the bot was restarted".
|
|
1873
1907
|
// What the cloud stores as the run's output: plain words, never the raw
|
|
1874
1908
|
// runtime text (that is in the bot log and status.json).
|
|
1875
|
-
const jobOutput = outcome.aborted ? `${STOPPED_BY_OWNER_REASON}.` : runtimeFailureText(failureClass(outcome), runtime.id);
|
|
1909
|
+
const jobOutput = outcome.aborted ? `${STOPPED_BY_OWNER_REASON}.` : runtimeFailureText(failureClass(outcome), runtime.id, "message", config.name);
|
|
1876
1910
|
patchInflight(dir, id, { jobFailure: { reason, output: jobOutput } });
|
|
1877
1911
|
try {
|
|
1878
1912
|
await sendToPerson(outcome.aborted
|
|
1879
1913
|
? didNotFinishText(reason, poster.postedCount > 0)
|
|
1880
|
-
: runtimeDidNotFinishText(failureClass(outcome), runtime.id, poster.postedCount > 0));
|
|
1914
|
+
: runtimeDidNotFinishText(failureClass(outcome), runtime.id, poster.postedCount > 0, config.name));
|
|
1881
1915
|
}
|
|
1882
1916
|
catch (err) {
|
|
1883
1917
|
// Leave it un-acked with the marker: the next poll reports it.
|
|
@@ -2381,7 +2415,7 @@ export async function runBot(deps) {
|
|
|
2381
2415
|
runtime: runtime.id,
|
|
2382
2416
|
botName: config.name,
|
|
2383
2417
|
context: "message",
|
|
2384
|
-
text: runtimeDidNotFinishText(cls, runtime.id, poster.postedCount > 0),
|
|
2418
|
+
text: runtimeDidNotFinishText(cls, runtime.id, poster.postedCount > 0, config.name),
|
|
2385
2419
|
retryOf: item.eventId ?? id,
|
|
2386
2420
|
}), item.rootEventId);
|
|
2387
2421
|
}
|
|
@@ -394,12 +394,15 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
|
|
|
394
394
|
let modelFallback = remembered ? { from: remembered.from, to: remembered.to } : undefined;
|
|
395
395
|
/** The retries on supported models failed too: report the first failure (its cause is the old CLI). */
|
|
396
396
|
let fallbackFailure;
|
|
397
|
+
let spawnedAt = nowMs();
|
|
397
398
|
try {
|
|
398
399
|
result = await spawnImpl(binary, args, spawnOptions);
|
|
399
400
|
if (runtime.id === "grok" && result.code !== 0 && !input.signal?.aborted) {
|
|
400
401
|
const retryArgs = grokRetryArgsAfterInvalidOutputFormat(args, result.stderr);
|
|
401
|
-
if (retryArgs)
|
|
402
|
+
if (retryArgs) {
|
|
403
|
+
spawnedAt = nowMs();
|
|
402
404
|
result = await spawnImpl(binary, retryArgs, spawnOptions);
|
|
405
|
+
}
|
|
403
406
|
}
|
|
404
407
|
if (remembered) {
|
|
405
408
|
// A turn on the remembered model: it either still works or the memo is wrong.
|
|
@@ -439,6 +442,7 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
|
|
|
439
442
|
continue;
|
|
440
443
|
tried.push(to);
|
|
441
444
|
const retryArgs = runtime.buildArgs({ ...turnInput, model: to }, { outputFile, newSessionId });
|
|
445
|
+
spawnedAt = nowMs();
|
|
442
446
|
const retry = await spawnImpl(binary, retryArgs, spawnOptions);
|
|
443
447
|
if (input.signal?.aborted) {
|
|
444
448
|
// The owner stopped it: report the stop, remember nothing.
|
|
@@ -493,6 +497,11 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
|
|
|
493
497
|
}
|
|
494
498
|
}
|
|
495
499
|
if (result.code !== 0 || result.signal) {
|
|
500
|
+
const durationMs = Math.max(0, nowMs() - spawnedAt);
|
|
501
|
+
const sawStreamEvent = result.stdout.split("\n").some((line) => {
|
|
502
|
+
const ev = parseJsonLine(line);
|
|
503
|
+
return ev !== null && (ev.type === "system" || ev.type === "assistant" || ev.type === "result");
|
|
504
|
+
});
|
|
496
505
|
const reason = runtime.failureReason?.(result.stdout, result.stderr);
|
|
497
506
|
// Streaming runtimes write JSON events to stdout: never show those to a
|
|
498
507
|
// person as the reason. stderr, then a plain description, instead.
|
|
@@ -505,7 +514,14 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
|
|
|
505
514
|
: result.signal
|
|
506
515
|
? `the ${runtime.binary} process was killed (${result.signal})`
|
|
507
516
|
: `${runtime.binary} exited with code ${result.code}`;
|
|
508
|
-
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,
|
|
517
|
+
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, {
|
|
518
|
+
...(!reason && stdoutTail ? { excerptIsOutputTail: true } : {}),
|
|
519
|
+
durationMs,
|
|
520
|
+
sawStreamEvent,
|
|
521
|
+
binaryPath: binary,
|
|
522
|
+
stderrFull: result.stderr.slice(0, 2000),
|
|
523
|
+
stdoutHead: result.stdout.slice(0, 300),
|
|
524
|
+
});
|
|
509
525
|
}
|
|
510
526
|
const parsed = runtime.parseOutput(result.stdout, { outputFile, outputFileText });
|
|
511
527
|
// A turn that ran on the requested model and succeeded: nothing to remember.
|
|
@@ -128,10 +128,23 @@ export declare class RuntimeError extends Error {
|
|
|
128
128
|
* must never decide what went wrong; only the message may.
|
|
129
129
|
*/
|
|
130
130
|
readonly excerptIsOutputTail: boolean;
|
|
131
|
+
/** How long the runtime process lived, in ms, when known. */
|
|
132
|
+
readonly durationMs: number | undefined;
|
|
133
|
+
/** True when stdout carried at least one stream event (`system`, `assistant`, `result`): the turn started. */
|
|
134
|
+
readonly sawStreamEvent: boolean;
|
|
135
|
+
/** Crash evidence for the bot log: the binary spawned, full stderr and the head of stdout (bounded). */
|
|
136
|
+
readonly binaryPath: string | undefined;
|
|
137
|
+
readonly stderrFull: string | undefined;
|
|
138
|
+
readonly stdoutHead: string | undefined;
|
|
131
139
|
constructor(message: string, stderrExcerpt: string, exitCode: number | null, opts?: {
|
|
132
140
|
started?: boolean;
|
|
133
141
|
aborted?: boolean;
|
|
134
142
|
excerptIsOutputTail?: boolean;
|
|
143
|
+
durationMs?: number;
|
|
144
|
+
sawStreamEvent?: boolean;
|
|
145
|
+
binaryPath?: string;
|
|
146
|
+
stderrFull?: string;
|
|
147
|
+
stdoutHead?: string;
|
|
135
148
|
});
|
|
136
149
|
}
|
|
137
150
|
export interface BotRuntime {
|
|
@@ -19,6 +19,14 @@ export class RuntimeError extends Error {
|
|
|
19
19
|
* must never decide what went wrong; only the message may.
|
|
20
20
|
*/
|
|
21
21
|
excerptIsOutputTail;
|
|
22
|
+
/** How long the runtime process lived, in ms, when known. */
|
|
23
|
+
durationMs;
|
|
24
|
+
/** True when stdout carried at least one stream event (`system`, `assistant`, `result`): the turn started. */
|
|
25
|
+
sawStreamEvent;
|
|
26
|
+
/** Crash evidence for the bot log: the binary spawned, full stderr and the head of stdout (bounded). */
|
|
27
|
+
binaryPath;
|
|
28
|
+
stderrFull;
|
|
29
|
+
stdoutHead;
|
|
22
30
|
constructor(message, stderrExcerpt, exitCode, opts = {}) {
|
|
23
31
|
super(message);
|
|
24
32
|
this.stderrExcerpt = stderrExcerpt;
|
|
@@ -27,6 +35,11 @@ export class RuntimeError extends Error {
|
|
|
27
35
|
this.started = opts.started ?? true;
|
|
28
36
|
this.aborted = opts.aborted ?? false;
|
|
29
37
|
this.excerptIsOutputTail = opts.excerptIsOutputTail ?? false;
|
|
38
|
+
this.durationMs = opts.durationMs;
|
|
39
|
+
this.sawStreamEvent = opts.sawStreamEvent ?? false;
|
|
40
|
+
this.binaryPath = opts.binaryPath;
|
|
41
|
+
this.stderrFull = opts.stderrFull;
|
|
42
|
+
this.stdoutHead = opts.stdoutHead;
|
|
30
43
|
}
|
|
31
44
|
}
|
|
32
45
|
/**
|
|
@@ -27,16 +27,27 @@ export type RuntimeFailureClass =
|
|
|
27
27
|
| "transient"
|
|
28
28
|
/** The tool is not installed (or not on PATH) on this computer. */
|
|
29
29
|
| "not-installed"
|
|
30
|
+
/** The tool's process exited before the turn started (e.g. Bun's "error: ... (Unexpected)"). A restart heals it. */
|
|
31
|
+
| "runtime-crashed"
|
|
30
32
|
/** Anything else. Neutral copy; never blames the sign-in. */
|
|
31
33
|
| "unknown";
|
|
32
|
-
/** The
|
|
33
|
-
export type RuntimeRepairClass = Extract<RuntimeFailureClass, "signed-out" | "cli-outdated" | "model-unsupported" | "transient">;
|
|
34
|
+
/** The classes that get a repair card in the DM. */
|
|
35
|
+
export type RuntimeRepairClass = Extract<RuntimeFailureClass, "signed-out" | "cli-outdated" | "model-unsupported" | "transient" | "runtime-crashed">;
|
|
34
36
|
/** The one thing the person can do about it. The desktop maps each to a button. */
|
|
35
|
-
export type RuntimeRepairAction = "signIn" | "update" | "switchModel" | "tryAgain";
|
|
37
|
+
export type RuntimeRepairAction = "signIn" | "update" | "switchModel" | "tryAgain" | "restart";
|
|
36
38
|
export declare const RUNTIME_REPAIR_ACTIONS: Record<RuntimeRepairClass, RuntimeRepairAction>;
|
|
37
39
|
export declare function isRuntimeRepairClass(cls: RuntimeFailureClass): cls is RuntimeRepairClass;
|
|
38
40
|
/** Sort a runtime's failure text into one class. Order matters: most specific first. */
|
|
39
41
|
export declare function classifyRuntimeFailure(text: string | undefined | null): RuntimeFailureClass;
|
|
42
|
+
export declare function isBunProcessError(text: string | undefined | null): boolean;
|
|
43
|
+
/** A process that exits non-zero this soon, with no stream event, never started its turn. */
|
|
44
|
+
export declare const RUNTIME_CRASH_MAX_MS = 5000;
|
|
45
|
+
/**
|
|
46
|
+
* True when the coding tool's process exited before the turn started: a
|
|
47
|
+
* non-zero exit code, not stopped by the owner, no stream event on stdout,
|
|
48
|
+
* and either Bun's process error shape on stderr or a life under 5 s.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isRuntimeCrash(err: unknown): boolean;
|
|
40
51
|
/**
|
|
41
52
|
* Class of a failed turn's error. A RuntimeError's excerpt (the runtime's own
|
|
42
53
|
* reported error: failureReason, turn.failed, stderr) is the better evidence;
|
|
@@ -57,11 +68,11 @@ export type RuntimeFailureContext = "kickoff" | "message";
|
|
|
57
68
|
*/
|
|
58
69
|
export declare function runtimeFailureReason(cls: RuntimeFailureClass, runtime: BotRuntimeId | string): string;
|
|
59
70
|
/** The one next step, as a full sentence. */
|
|
60
|
-
export declare function runtimeFailureNextStep(cls: RuntimeFailureClass, runtime: BotRuntimeId | string, context?: RuntimeFailureContext): string;
|
|
71
|
+
export declare function runtimeFailureNextStep(cls: RuntimeFailureClass, runtime: BotRuntimeId | string, context?: RuntimeFailureContext, botName?: string): string;
|
|
61
72
|
/** "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;
|
|
73
|
+
export declare function runtimeFailureText(cls: RuntimeFailureClass, runtime: BotRuntimeId | string, context?: RuntimeFailureContext, botName?: string): string;
|
|
63
74
|
/** 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;
|
|
75
|
+
export declare function runtimeDidNotFinishText(cls: RuntimeFailureClass, runtime: BotRuntimeId | string, progressPosted: boolean, botName?: string): string;
|
|
65
76
|
/**
|
|
66
77
|
* The machine-readable repair payload a failure DM carries. The DM transport
|
|
67
78
|
* (POST /v1/notify/dm) stores structured data only in `richContent`
|
|
@@ -21,6 +21,7 @@ export const RUNTIME_REPAIR_ACTIONS = {
|
|
|
21
21
|
"cli-outdated": "update",
|
|
22
22
|
"model-unsupported": "switchModel",
|
|
23
23
|
transient: "tryAgain",
|
|
24
|
+
"runtime-crashed": "restart",
|
|
24
25
|
};
|
|
25
26
|
export function isRuntimeRepairClass(cls) {
|
|
26
27
|
return cls in RUNTIME_REPAIR_ACTIONS;
|
|
@@ -61,6 +62,38 @@ export function classifyRuntimeFailure(text) {
|
|
|
61
62
|
return "transient";
|
|
62
63
|
return "unknown";
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Bun's process-level error line (Claude Code is a Bun-compiled binary):
|
|
67
|
+
* "error: An unknown error occurred (Unexpected)". The excerpt joins stderr
|
|
68
|
+
* lines with " | ", so each segment is checked on its own.
|
|
69
|
+
*/
|
|
70
|
+
const BUN_PROCESS_ERROR = /^error: .+ \((?:Unexpected|[A-Z][A-Za-z]+)\)\s*$/m;
|
|
71
|
+
export function isBunProcessError(text) {
|
|
72
|
+
return (text ?? "")
|
|
73
|
+
.split(/\n| \| /)
|
|
74
|
+
.some((segment) => BUN_PROCESS_ERROR.test(segment.trim()));
|
|
75
|
+
}
|
|
76
|
+
/** A process that exits non-zero this soon, with no stream event, never started its turn. */
|
|
77
|
+
export const RUNTIME_CRASH_MAX_MS = 5_000;
|
|
78
|
+
function bunShaped(e) {
|
|
79
|
+
return (isBunProcessError(typeof e.stderrExcerpt === "string" ? e.stderrExcerpt : "") ||
|
|
80
|
+
isBunProcessError(typeof e.stderrFull === "string" ? e.stderrFull : ""));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* True when the coding tool's process exited before the turn started: a
|
|
84
|
+
* non-zero exit code, not stopped by the owner, no stream event on stdout,
|
|
85
|
+
* and either Bun's process error shape on stderr or a life under 5 s.
|
|
86
|
+
*/
|
|
87
|
+
export function isRuntimeCrash(err) {
|
|
88
|
+
if (!err || typeof err !== "object" || !("stderrExcerpt" in err))
|
|
89
|
+
return false;
|
|
90
|
+
const e = err;
|
|
91
|
+
if (typeof e.exitCode !== "number" || e.exitCode === 0)
|
|
92
|
+
return false;
|
|
93
|
+
if (e.aborted === true || e.sawStreamEvent === true)
|
|
94
|
+
return false;
|
|
95
|
+
return bunShaped(e) || (typeof e.durationMs === "number" && e.durationMs < RUNTIME_CRASH_MAX_MS);
|
|
96
|
+
}
|
|
64
97
|
/**
|
|
65
98
|
* Class of a failed turn's error. A RuntimeError's excerpt (the runtime's own
|
|
66
99
|
* reported error: failureReason, turn.failed, stderr) is the better evidence;
|
|
@@ -73,12 +106,20 @@ export function classifyRuntimeError(err) {
|
|
|
73
106
|
if (err && typeof err === "object" && "stderrExcerpt" in err) {
|
|
74
107
|
const e = err;
|
|
75
108
|
const message = typeof e.message === "string" ? e.message : "";
|
|
76
|
-
|
|
77
|
-
|
|
109
|
+
const crashed = isRuntimeCrash(err);
|
|
110
|
+
// Bun's own process error is the most specific evidence there is.
|
|
111
|
+
if (crashed && bunShaped(err))
|
|
112
|
+
return "runtime-crashed";
|
|
113
|
+
if (e.excerptIsOutputTail === true) {
|
|
114
|
+
const fromTailMessage = classifyRuntimeFailure(message);
|
|
115
|
+
return fromTailMessage === "unknown" && crashed ? "runtime-crashed" : fromTailMessage;
|
|
116
|
+
}
|
|
78
117
|
const fromExcerpt = classifyRuntimeFailure(typeof e.stderrExcerpt === "string" ? e.stderrExcerpt : "");
|
|
79
118
|
if (fromExcerpt !== "unknown")
|
|
80
119
|
return fromExcerpt;
|
|
81
|
-
|
|
120
|
+
const fromMessage = classifyRuntimeFailure(message);
|
|
121
|
+
// A quick exit with no known cause: the process died before the turn.
|
|
122
|
+
return fromMessage === "unknown" && crashed ? "runtime-crashed" : fromMessage;
|
|
82
123
|
}
|
|
83
124
|
return classifyRuntimeFailure(err instanceof Error ? err.message : String(err ?? ""));
|
|
84
125
|
}
|
|
@@ -106,14 +147,18 @@ export function runtimeFailureReason(cls, runtime) {
|
|
|
106
147
|
return `${tool} is busy or out of reach right now`;
|
|
107
148
|
case "not-installed":
|
|
108
149
|
return `${tool} isn't installed on this computer`;
|
|
150
|
+
case "runtime-crashed":
|
|
151
|
+
return `my ${tool} process could not start`;
|
|
109
152
|
default:
|
|
110
153
|
return `${tool} stopped with an error`;
|
|
111
154
|
}
|
|
112
155
|
}
|
|
113
156
|
/** The one next step, as a full sentence. */
|
|
114
|
-
export function runtimeFailureNextStep(cls, runtime, context = "message") {
|
|
157
|
+
export function runtimeFailureNextStep(cls, runtime, context = "message", botName) {
|
|
115
158
|
const tool = runtimeDisplayName(runtime);
|
|
116
159
|
switch (cls) {
|
|
160
|
+
case "runtime-crashed":
|
|
161
|
+
return `Press "Heal bot" in HQ, or run: hq bot restart ${botName ?? "<bot>"}`;
|
|
117
162
|
case "signed-out":
|
|
118
163
|
return `Use "Sign in to ${tool}" in HQ, then send me any message.`;
|
|
119
164
|
case "cli-outdated":
|
|
@@ -136,12 +181,12 @@ function capitalize(s) {
|
|
|
136
181
|
return s ? s[0].toUpperCase() + s.slice(1) : s;
|
|
137
182
|
}
|
|
138
183
|
/** "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)}`;
|
|
184
|
+
export function runtimeFailureText(cls, runtime, context = "message", botName) {
|
|
185
|
+
return `${capitalize(runtimeFailureReason(cls, runtime))}. ${runtimeFailureNextStep(cls, runtime, context, botName)}`;
|
|
141
186
|
}
|
|
142
187
|
/** 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." : ""}`;
|
|
188
|
+
export function runtimeDidNotFinishText(cls, runtime, progressPosted, botName) {
|
|
189
|
+
return `I didn't finish that. ${runtimeFailureText(cls, runtime, "message", botName)}${progressPosted ? " The messages above show how far I got." : ""}`;
|
|
145
190
|
}
|
|
146
191
|
export function runtimeRepairBlock(args) {
|
|
147
192
|
return {
|
|
@@ -177,7 +222,7 @@ export function withRetrySuggestion(text) {
|
|
|
177
222
|
* press.
|
|
178
223
|
*/
|
|
179
224
|
export function runtimeRepairNotice(args) {
|
|
180
|
-
const text = args.text ?? runtimeFailureText(args.cls, args.runtime, args.context);
|
|
225
|
+
const text = args.text ?? runtimeFailureText(args.cls, args.runtime, args.context, args.botName);
|
|
181
226
|
if (!isRuntimeRepairClass(args.cls))
|
|
182
227
|
return { cls: args.cls, text };
|
|
183
228
|
return {
|
package/dist/lib/bot/status.d.ts
CHANGED
|
@@ -55,6 +55,16 @@ export interface BotStatusFile {
|
|
|
55
55
|
to: string;
|
|
56
56
|
at: string;
|
|
57
57
|
} | null;
|
|
58
|
+
/**
|
|
59
|
+
* The last time the coding tool's process exited before its turn started
|
|
60
|
+
* (runtime-crashed): when, exit code, how long it lived, and its stderr.
|
|
61
|
+
*/
|
|
62
|
+
lastCrash?: {
|
|
63
|
+
at: string;
|
|
64
|
+
code: number | null;
|
|
65
|
+
durationMs?: number;
|
|
66
|
+
stderr: string;
|
|
67
|
+
};
|
|
58
68
|
/**
|
|
59
69
|
* Set while the bot holds its model turns after FAILURE_LIMIT failures in a
|
|
60
70
|
* window (it used to exit instead, and a bot that exited could neither load
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.345.
|
|
3
|
+
"version": "5.345.175",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -114,11 +114,11 @@
|
|
|
114
114
|
"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
|
|
115
115
|
},
|
|
116
116
|
"optionalDependencies": {
|
|
117
|
-
"@indigoai-us/hq-hook-win32-x64": "5.345.
|
|
118
|
-
"@indigoai-us/hq-hook-win32-arm64": "5.345.
|
|
119
|
-
"@indigoai-us/hq-hook-darwin-x64": "5.345.
|
|
120
|
-
"@indigoai-us/hq-hook-darwin-arm64": "5.345.
|
|
121
|
-
"@indigoai-us/hq-hook-linux-x64": "5.345.
|
|
122
|
-
"@indigoai-us/hq-hook-linux-arm64": "5.345.
|
|
117
|
+
"@indigoai-us/hq-hook-win32-x64": "5.345.175",
|
|
118
|
+
"@indigoai-us/hq-hook-win32-arm64": "5.345.175",
|
|
119
|
+
"@indigoai-us/hq-hook-darwin-x64": "5.345.175",
|
|
120
|
+
"@indigoai-us/hq-hook-darwin-arm64": "5.345.175",
|
|
121
|
+
"@indigoai-us/hq-hook-linux-x64": "5.345.175",
|
|
122
|
+
"@indigoai-us/hq-hook-linux-arm64": "5.345.175"
|
|
123
123
|
}
|
|
124
124
|
}
|