@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 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.
@@ -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
  }
@@ -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.
@@ -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 = callerFromToken(token).personUid;
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 = callerFromToken(token).personUid;
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;
@@ -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";
@@ -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 session = readBotSession(dir, config.runtime, sessionScope);
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 runAttempt(session, await composePrompt(session));
1058
+ return await attemptOnce();
1045
1059
  }
1046
1060
  catch (err) {
1047
- if (!missingResumedSession(err))
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
- clearBotSession(dir, sessionScope);
1050
- log("warn", "saved model session is gone; starting a new one with recent DM history");
1051
- return await runAttempt(null, await composePrompt(null));
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
- log("error", `model turn failed (${count}/${FAILURE_LIMIT} in window, ${classifyRuntimeError(err)}): ${excerpt}`);
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, !reason && stdoutTail ? { excerptIsOutputTail: true } : {});
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 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 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
- if (e.excerptIsOutputTail === true)
77
- return classifyRuntimeFailure(message);
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
- return classifyRuntimeFailure(message);
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 {
@@ -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.174",
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.174",
118
- "@indigoai-us/hq-hook-win32-arm64": "5.345.174",
119
- "@indigoai-us/hq-hook-darwin-x64": "5.345.174",
120
- "@indigoai-us/hq-hook-darwin-arm64": "5.345.174",
121
- "@indigoai-us/hq-hook-linux-x64": "5.345.174",
122
- "@indigoai-us/hq-hook-linux-arm64": "5.345.174"
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
  }