@indigoai-us/hq-cli 5.345.75 → 5.345.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +30 -37
  3. package/dist/command-catalog.generated.d.ts +83 -3
  4. package/dist/command-catalog.generated.js +100 -3
  5. package/dist/commands/access.d.ts +1 -0
  6. package/dist/commands/access.js +17 -5
  7. package/dist/commands/bot.d.ts +12 -0
  8. package/dist/commands/bot.js +54 -2
  9. package/dist/commands/core-native-family-flag.js +1 -1
  10. package/dist/commands/files.js +7 -2
  11. package/dist/commands/members.d.ts +42 -0
  12. package/dist/commands/members.js +151 -1
  13. package/dist/commands/secrets.js +1 -1
  14. package/dist/fast-scaffold.d.ts +1 -1
  15. package/dist/fast-scaffold.js +1 -0
  16. package/dist/lib/bot/api.d.ts +11 -0
  17. package/dist/lib/bot/api.js +2 -0
  18. package/dist/lib/bot/config.js +4 -2
  19. package/dist/lib/bot/inflight.d.ts +3 -0
  20. package/dist/lib/bot/probe.d.ts +57 -0
  21. package/dist/lib/bot/probe.js +100 -0
  22. package/dist/lib/bot/run.d.ts +4 -0
  23. package/dist/lib/bot/run.js +171 -37
  24. package/dist/lib/bot/runtime/codex.d.ts +67 -1
  25. package/dist/lib/bot/runtime/codex.js +197 -3
  26. package/dist/lib/bot/runtime/grok.js +2 -2
  27. package/dist/lib/bot/runtime/index.d.ts +8 -0
  28. package/dist/lib/bot/runtime/index.js +91 -5
  29. package/dist/lib/bot/runtime/messages-stream.js +1 -1
  30. package/dist/lib/bot/runtime/types.d.ts +33 -0
  31. package/dist/lib/bot/runtime/types.js +10 -0
  32. package/dist/lib/bot/runtime-failure.d.ts +113 -0
  33. package/dist/lib/bot/runtime-failure.js +180 -0
  34. package/dist/lib/bot/runtime-sign-in.js +2 -2
  35. package/dist/lib/bot/status.d.ts +21 -0
  36. package/dist/lib/index-render/companies.js +33 -16
  37. package/dist/lib/index-render/shared.d.ts +18 -0
  38. package/dist/lib/index-render/shared.js +24 -0
  39. package/dist/lib/lanes/monitor.js +30 -15
  40. package/package.json +1 -1
@@ -73,7 +73,8 @@ import { isAgentSender, isDmItem, isMetaNonReply, resolveReplyTarget } from "./r
73
73
  import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
74
74
  import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
75
75
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
76
- import { isRuntimeSignInFailure, SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
76
+ import { SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
77
+ import { classifyRuntimeError, classifyRuntimeFailure, runtimeDidNotFinishText, runtimeFailureReason, runtimeFailureText, runtimeRepairNotice, } from "./runtime-failure.js";
77
78
  import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
78
79
  import { captureHeartbeatIdentityMismatch, identityMismatchMessage, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
79
80
  import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
@@ -181,13 +182,19 @@ export const SETUP_WORKER_ID = SETUP_BOT_WORKER_ID;
181
182
  /** The one message a person gets when a turn on their message did not finish. */
182
183
  export function didNotFinishText(reason, progressPosted) {
183
184
  const r = reason.trim().replace(/[.\s]+$/, "");
184
- return `I didn't finish that — ${r}.${progressPosted ? " The messages above show how far I got." : ""}`;
185
+ const sentence = r ? `${r[0].toUpperCase()}${r.slice(1)}` : r;
186
+ return `I didn't finish that. ${sentence}.${progressPosted ? " The messages above show how far I got." : ""}`;
185
187
  }
186
188
  export { containsNoReplySentinel };
187
189
  /** Grok emits is_error with this message when the model produces only reasoning tokens. */
188
190
  export function isReasoningOnlyError(excerpt) {
189
191
  return excerpt.includes("reasoning_only");
190
192
  }
193
+ /** The one note a person gets when the bot pauses after repeated failures. */
194
+ export function holdNoticeText(holdMs, messagePending) {
195
+ const minutes = Math.max(1, Math.round(holdMs / 60_000));
196
+ return `I'm pausing for ${minutes} minute${minutes === 1 ? "" : "s"} after a few failed tries, then I'll ${messagePending ? "pick your message up" : "pick up from your next message"}.`;
197
+ }
191
198
  /** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
192
199
  export const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
193
200
  export const RESTARTED_REASON = "I was restarted while I was working on it";
@@ -228,6 +235,9 @@ class FailureWindow {
228
235
  exceeded(nowMs) {
229
236
  return this.count(nowMs) >= this.limit;
230
237
  }
238
+ clear() {
239
+ this.stamps.length = 0;
240
+ }
231
241
  }
232
242
  function defaultSleep(ms, signal) {
233
243
  return new Promise((resolve) => {
@@ -493,7 +503,9 @@ export async function runBot(deps) {
493
503
  return { stop: async () => { }, done, status: () => null, idle: () => true };
494
504
  }
495
505
  state = "running";
496
- setStatus({ state: "running", lastActivityAt: now().toISOString() });
506
+ // A fresh process has a fresh failure window; whatever the last one recorded
507
+ // (a bot that exited after repeated failures on an older hq-cli) is over.
508
+ setStatus({ state: "running", lastActivityAt: now().toISOString(), recentFailures: 0, modelHold: null });
497
509
  log("info", "running");
498
510
  // Read before the kickoff can write its own marker: whatever is here now was
499
511
  // left by a previous run that was cut off mid-turn.
@@ -539,12 +551,49 @@ export async function runBot(deps) {
539
551
  // ── Heartbeat loop ─────────────────────────────────────────────────────────
540
552
  const failures = new FailureWindow(FAILURE_WINDOW_MS, FAILURE_LIMIT);
541
553
  let modelHealth = "ok";
554
+ /**
555
+ * FAILURE_LIMIT model failures inside the window: hold model turns for a
556
+ * cooldown instead of exiting. The bot used to exit 0 here, which every
557
+ * supervisor (launchd KeepAlive{SuccessfulExit:false}, systemd on-failure,
558
+ * the hq daemon units) treats as "stay down", so a bot whose coding tool was
559
+ * briefly broken stayed dead until someone ran `hq bot start`, and never saw
560
+ * the updated hq-cli that fixed it. Holding keeps the process (and its
561
+ * hq-cli update watcher) alive; after the cooldown the window is cleared and
562
+ * the next inbound message runs a fresh turn.
563
+ */
564
+ const modelHoldMs = Math.max(0, deps.modelHoldMs ?? FAILURE_WINDOW_MS);
565
+ let modelHoldUntil = 0;
566
+ const holdAfterFailures = async (excerpt, tell, messagePending = false) => {
567
+ const until = now().getTime() + modelHoldMs;
568
+ modelHoldUntil = until;
569
+ failures.clear();
570
+ modelHealth = "failed";
571
+ const reason = `${FAILURE_LIMIT} model failures in 10 minutes; last: ${excerpt}`;
572
+ setStatus({ modelHold: { until: new Date(until).toISOString(), reason: reason.slice(0, 400) }, lastError: excerpt.slice(0, 400) });
573
+ log("error", `${reason}. Holding model turns for ${Math.round(modelHoldMs / 60_000)} min, then trying again on the next message`);
574
+ // One plain note to the person, never silence: they would otherwise see
575
+ // nothing while their next messages wait out the hold.
576
+ if (tell) {
577
+ try {
578
+ await tell(holdNoticeText(modelHoldMs, messagePending));
579
+ }
580
+ catch (err) {
581
+ log("warn", `could not say that turns are on hold: ${err instanceof Error ? err.message : String(err)}`);
582
+ }
583
+ }
584
+ };
585
+ const liftModelHold = () => {
586
+ modelHoldUntil = 0;
587
+ setStatus({ modelHold: null });
588
+ log("info", "model hold over; the next message runs a fresh turn");
589
+ };
542
590
  // ── Coding tool sign-in ────────────────────────────────────────────────────
543
591
  const signInRetryMs = Math.max(0, deps.signInRetryMs ?? SIGN_IN_RETRY_MS);
544
592
  let signInIssue = null;
545
593
  let nextSignInRetryAt = 0;
546
594
  /** Conversations already told this time that the bot needs a sign-in. */
547
595
  const toldAboutSignIn = new Set();
596
+ const isSignInFailure = (excerpt) => classifyRuntimeFailure(excerpt) === "signed-out";
548
597
  const markSignInBroken = (excerpt) => {
549
598
  nextSignInRetryAt = now().getTime() + signInRetryMs;
550
599
  modelHealth = "degraded";
@@ -563,11 +612,18 @@ export async function runBot(deps) {
563
612
  log("info", `${runtime.id} sign-in works again; resuming`);
564
613
  };
565
614
  /** Say once per conversation that the bot cannot work until its tool signs in. */
566
- const tellSignInNeeded = async (conversation, post, kickoff = false) => {
615
+ const tellSignInNeeded = async (conversation, post, kickoff = false, retryOf) => {
567
616
  if (toldAboutSignIn.has(conversation))
568
617
  return;
569
618
  try {
570
- await post(signInNeededText(config.runtime, kickoff));
619
+ await post(runtimeRepairNotice({
620
+ cls: "signed-out",
621
+ runtime: config.runtime,
622
+ botName: config.name,
623
+ context: kickoff ? "kickoff" : "message",
624
+ text: signInNeededText(config.runtime, kickoff),
625
+ ...(retryOf ? { retryOf } : {}),
626
+ }));
571
627
  toldAboutSignIn.add(conversation);
572
628
  }
573
629
  catch (err) {
@@ -774,6 +830,16 @@ export async function runBot(deps) {
774
830
  }
775
831
  modelHealth = "ok";
776
832
  markSignInWorks();
833
+ // A good turn: the last recorded failure is no longer the bot's state.
834
+ setStatus({ recentFailures: failures.count(now().getTime()), lastError: undefined });
835
+ if (turn.modelFallback) {
836
+ const { from, to } = turn.modelFallback;
837
+ log("warn", `${runtime.id} on this computer is too old for model ${from || "(its configured default)"}; this turn ran on ${to} instead. Update ${runtime.id} to use ${from || "that model"} again`);
838
+ setStatus({ runtimeModelFallback: { from, to, at: now().toISOString() } });
839
+ }
840
+ else if (readStatus()?.runtimeModelFallback) {
841
+ setStatus({ runtimeModelFallback: null });
842
+ }
777
843
  log("info", `model turn done in ${Math.round((now().getTime() - tTurnStart) / 1000)}s (${Math.round((tTurnStart - tReceived) / 1000)}s before it)`);
778
844
  if (turn.sessionId) {
779
845
  writeBotSession(dir, {
@@ -792,7 +858,7 @@ export async function runBot(deps) {
792
858
  const count = failures.record(now().getTime());
793
859
  modelHealth = count >= FAILURE_LIMIT ? "failed" : "degraded";
794
860
  setStatus({ restarts: (readStatus()?.restarts ?? 0) + 1, recentFailures: count, lastError: excerpt.slice(0, 400) });
795
- log("error", `model turn failed (${count}/${FAILURE_LIMIT} in window): ${excerpt}`);
861
+ log("error", `model turn failed (${count}/${FAILURE_LIMIT} in window, ${classifyRuntimeError(err)}): ${excerpt}`);
796
862
  // A resumed session that no longer exists must not poison every turn.
797
863
  const resumed = Boolean(err?.resumedSession);
798
864
  if (resumed && /session|resume|not found/i.test(excerpt))
@@ -892,7 +958,30 @@ export async function runBot(deps) {
892
958
  currentTurns.delete(controller);
893
959
  }
894
960
  };
895
- const failureReason = (outcome) => outcome.aborted ? STOPPED_BY_OWNER_REASON : `${runtime.id} stopped with an error: ${outcome.excerpt.slice(0, 160)}`;
961
+ const failureClass = (outcome) => classifyRuntimeError(outcome.error);
962
+ /** Plain reason for a failed turn: never the runtime's raw text (that goes to the log). */
963
+ const failureReason = (outcome) => outcome.aborted ? STOPPED_BY_OWNER_REASON : runtimeFailureReason(failureClass(outcome), runtime.id);
964
+ /**
965
+ * Post a failure notice: the plain body, plus its repair block in a DM.
966
+ * A server that refuses the block (400) still gets the plain body.
967
+ */
968
+ const postRepair = async (target, notice, rootEventId) => {
969
+ if (target.kind === "dm" && notice.richContent) {
970
+ const base = { toPersonUid: target.peerUid, body: notice.text, audience: "human", ...(rootEventId ? { rootEventId } : {}) };
971
+ try {
972
+ await api.sendDm({ ...base, richContent: notice.richContent });
973
+ return;
974
+ }
975
+ catch (err) {
976
+ if (!(err instanceof BotApiError && err.status === 400))
977
+ throw err;
978
+ log("warn", `repair card refused (${err.code ?? err.status}); sent as plain text`);
979
+ }
980
+ await api.sendDm(base);
981
+ return;
982
+ }
983
+ await sendToTarget(target, notice.text, rootEventId, "human");
984
+ };
896
985
  const postOne = (target, body, rootEventId, audience) => {
897
986
  const root = rootEventId ? { rootEventId } : {};
898
987
  return target.kind === "dm"
@@ -1061,7 +1150,8 @@ export async function runBot(deps) {
1061
1150
  ? {
1062
1151
  status: "failed",
1063
1152
  disposition: failure.reason.slice(0, 256),
1064
- ...(failure.excerpt ? { output: jobRunOutput(failure.excerpt) } : {}),
1153
+ // The raw runtime text stays in the bot log and status.json only.
1154
+ output: jobRunOutput(failure.output ?? `${failure.reason[0]?.toUpperCase() ?? ""}${failure.reason.slice(1)}.`),
1065
1155
  }
1066
1156
  : { status: "failed", disposition: "the run was cut off when the bot restarted" };
1067
1157
  await completeJobRun(marker.jobId, marker.messageId, completion);
@@ -1232,12 +1322,12 @@ export async function runBot(deps) {
1232
1322
  log("info", `kickoff reply sent (${outcome.reply.length} chars)`);
1233
1323
  return;
1234
1324
  }
1235
- if (!outcome.aborted && poster.postedCount === 0 && isRuntimeSignInFailure(outcome.excerpt)) {
1325
+ if (!outcome.aborted && poster.postedCount === 0 && isSignInFailure(outcome.excerpt)) {
1236
1326
  // Nothing ran: hold the kickoff until the tool can sign in, then run it.
1237
1327
  clearInflight(dir, KICKOFF_MESSAGE_ID);
1238
1328
  patchBotConfig(dir, { kickoffPendingSignIn: true });
1239
1329
  markSignInBroken(outcome.excerpt);
1240
- await tellSignInNeeded("dm", sendToPerson, true);
1330
+ await tellSignInNeeded("dm", (notice) => postRepair(target, notice), true);
1241
1331
  return;
1242
1332
  }
1243
1333
  if (readBotConfig(dir)?.kickoffPendingSignIn)
@@ -1245,11 +1335,20 @@ export async function runBot(deps) {
1245
1335
  let count = 0;
1246
1336
  if (!outcome.aborted)
1247
1337
  count = recordTurnFailure(outcome.error, "dm").count;
1248
- const body = outcome.started && (outcome.aborted || poster.postedCount > 0)
1249
- ? `${didNotFinishText(failureReason(outcome), poster.postedCount > 0)} Send me any message and I'll pick up from there.`
1250
- : `Sorry — I couldn't get started on my own (${runtime.id} failed: ${outcome.excerpt.slice(0, 160)}). Send me any message and I'll pick up from there, or check that ${runtime.id} is signed in on this computer.`;
1338
+ // The person sees one plain sentence and one next step; the raw runtime
1339
+ // text is in the log (recordTurnFailure) and status.json only.
1340
+ const cls = failureClass(outcome);
1341
+ const notice = outcome.aborted
1342
+ ? { cls, text: `${didNotFinishText(failureReason(outcome), poster.postedCount > 0)} Send me any message and I'll pick up from there.` }
1343
+ : runtimeRepairNotice({
1344
+ cls,
1345
+ runtime: runtime.id,
1346
+ botName: config.name,
1347
+ context: "kickoff",
1348
+ ...(outcome.started && poster.postedCount > 0 ? { text: runtimeDidNotFinishText(cls, runtime.id, true) } : {}),
1349
+ });
1251
1350
  try {
1252
- await sendToPerson(body);
1351
+ await postRepair(target, notice);
1253
1352
  clearInflight(dir, KICKOFF_MESSAGE_ID);
1254
1353
  }
1255
1354
  catch {
@@ -1257,7 +1356,7 @@ export async function runBot(deps) {
1257
1356
  }
1258
1357
  log("error", `kickoff turn ${outcome.aborted ? "stopped by the owner" : `failed (${count}/${FAILURE_LIMIT} in window)`}; not retried`);
1259
1358
  if (!outcome.aborted && failures.exceeded(now().getTime())) {
1260
- await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
1359
+ await holdAfterFailures(outcome.excerpt, sendToPerson);
1261
1360
  }
1262
1361
  };
1263
1362
  /**
@@ -1392,13 +1491,13 @@ export async function runBot(deps) {
1392
1491
  log("info", `job ${jobId} run ${id} done (${outcome.reply.length} chars${catchUp ? ", catch-up" : ""})`);
1393
1492
  return;
1394
1493
  }
1395
- if (!outcome.aborted && poster.postedCount === 0 && isRuntimeSignInFailure(outcome.excerpt)) {
1494
+ if (!outcome.aborted && poster.postedCount === 0 && isSignInFailure(outcome.excerpt)) {
1396
1495
  // Nothing ran: hold the run until the coding tool can sign in again. It
1397
1496
  // is neither acked nor reported succeeded — the work is still owed.
1398
1497
  clearInflight(dir, id);
1399
1498
  attempts.delete(id);
1400
1499
  markSignInBroken(outcome.excerpt);
1401
- await tellSignInNeeded(`job:${jobId}`, (body) => sendToTarget(markerTarget, body, undefined, "human"));
1500
+ await tellSignInNeeded(`job:${jobId}`, (notice) => postRepair(markerTarget, notice));
1402
1501
  log("warn", `kept scheduled run ${id} of job ${jobId} until ${runtime.id} can sign in again`);
1403
1502
  return; // un-acked, un-reported: run once the sign-in works
1404
1503
  }
@@ -1407,12 +1506,13 @@ export async function runBot(deps) {
1407
1506
  clearInflight(dir, id);
1408
1507
  const { excerpt } = recordTurnFailure(outcome.error, target.sessionScope);
1409
1508
  if (failures.exceeded(now().getTime())) {
1410
- await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${excerpt}`);
1509
+ await holdAfterFailures(excerpt, sendToPerson, true);
1411
1510
  return;
1412
1511
  }
1413
1512
  if (attempt >= MAX_ATTEMPTS_PER_MESSAGE) {
1414
- await sendToPerson(`I couldn't run this one — ${runtime.id} kept failing: ${excerpt.slice(0, 160)}.`).catch(() => undefined);
1415
- await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(excerpt), disposition: `${runtime.id} could not be started` });
1513
+ const plain = runtimeFailureText(classifyRuntimeError(outcome.error), runtime.id);
1514
+ await sendToPerson(`I couldn't run this one. ${plain}`).catch(() => undefined);
1515
+ await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(plain), disposition: `${runtime.id} could not be started` });
1416
1516
  markReported("failed");
1417
1517
  markProcessed(dir, id, now().toISOString());
1418
1518
  await api.ackInbox(config.agentUid, id);
@@ -1428,16 +1528,21 @@ export async function runBot(deps) {
1428
1528
  const reason = failureReason(outcome);
1429
1529
  // Saved BEFORE the post: if "did not finish" cannot be posted, recovery
1430
1530
  // reports and says this real reason, not "the bot was restarted".
1431
- patchInflight(dir, id, { jobFailure: { reason, ...(outcome.excerpt ? { excerpt: outcome.excerpt } : {}) } });
1531
+ // What the cloud stores as the run's output: plain words, never the raw
1532
+ // runtime text (that is in the bot log and status.json).
1533
+ const jobOutput = outcome.aborted ? `${STOPPED_BY_OWNER_REASON}.` : runtimeFailureText(failureClass(outcome), runtime.id);
1534
+ patchInflight(dir, id, { jobFailure: { reason, output: jobOutput } });
1432
1535
  try {
1433
- await sendToPerson(didNotFinishText(reason, poster.postedCount > 0));
1536
+ await sendToPerson(outcome.aborted
1537
+ ? didNotFinishText(reason, poster.postedCount > 0)
1538
+ : runtimeDidNotFinishText(failureClass(outcome), runtime.id, poster.postedCount > 0));
1434
1539
  }
1435
1540
  catch (err) {
1436
1541
  // Leave it un-acked with the marker: the next poll reports it.
1437
1542
  log("warn", `could not post "did not finish" for run ${id} of job ${jobId}: ${err instanceof Error ? err.message : String(err)}`);
1438
1543
  return;
1439
1544
  }
1440
- await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(outcome.excerpt), disposition: reason.slice(0, 256) });
1545
+ await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(jobOutput), disposition: reason.slice(0, 256) });
1441
1546
  markReported("failed");
1442
1547
  markProcessed(dir, id, now().toISOString());
1443
1548
  await api.ackInbox(config.agentUid, id);
@@ -1445,7 +1550,7 @@ export async function runBot(deps) {
1445
1550
  attempts.delete(id);
1446
1551
  log("error", `scheduled run ${id} of job ${jobId} ${outcome.aborted ? "stopped by the owner" : "failed"} after it started; not re-run`);
1447
1552
  if (!outcome.aborted && failures.exceeded(now().getTime())) {
1448
- await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
1553
+ await holdAfterFailures(outcome.excerpt, sendToPerson);
1449
1554
  }
1450
1555
  };
1451
1556
  /**
@@ -1657,7 +1762,7 @@ export async function runBot(deps) {
1657
1762
  });
1658
1763
  // The model saw the note once its turn ran. A turn that never started, or
1659
1764
  // one held for a coding-tool sign-in, runs again and keeps the note for it.
1660
- if (setupNote && !toolOffer && (outcome.ok || (outcome.started && !isRuntimeSignInFailure(outcome.excerpt)))) {
1765
+ if (setupNote && !toolOffer && (outcome.ok || (outcome.started && !isSignInFailure(outcome.excerpt)))) {
1661
1766
  try {
1662
1767
  patchSetupIntroState(dir, { notePending: false });
1663
1768
  }
@@ -1666,7 +1771,7 @@ export async function runBot(deps) {
1666
1771
  }
1667
1772
  }
1668
1773
  // Same rule for the offer: answered once the model saw the answer.
1669
- if (toolOffer && toolAnswer && (outcome.ok || (outcome.started && !isRuntimeSignInFailure(outcome.excerpt)))) {
1774
+ if (toolOffer && toolAnswer && (outcome.ok || (outcome.started && !isSignInFailure(outcome.excerpt)))) {
1670
1775
  try {
1671
1776
  patchSetupIntroState(dir, { kickoffDeferred: false, toolOfferAnswer: toolAnswer });
1672
1777
  log("info", `coding tool offer answered (${toolAnswer === "kept_here" ? "kept setup here" : "wrote a message"}); setup runs here`);
@@ -1710,14 +1815,14 @@ export async function runBot(deps) {
1710
1815
  return;
1711
1816
  }
1712
1817
  await clearThinking();
1713
- if (!outcome.aborted && poster.postedCount === 0 && isRuntimeSignInFailure(outcome.excerpt)) {
1818
+ if (!outcome.aborted && poster.postedCount === 0 && isSignInFailure(outcome.excerpt)) {
1714
1819
  // The tool refused before doing anything: keep the message for when it
1715
1820
  // can sign in again, and say so once in this conversation.
1716
1821
  clearInflight(dir, id);
1717
1822
  attempts.delete(id);
1718
1823
  markSignInBroken(outcome.excerpt);
1719
1824
  const conversation = markerTarget.kind === "dm" ? sessionScope : `room:${markerTarget.channelId}:${item.rootEventId ?? ""}`;
1720
- await tellSignInNeeded(conversation, sendToPerson);
1825
+ await tellSignInNeeded(conversation, (notice) => postRepair(markerTarget, notice, item.rootEventId), false, item.eventId ?? id);
1721
1826
  log("warn", `kept ${id} until ${runtime.id} can sign in again`);
1722
1827
  return; // un-acked: answered once the sign-in works
1723
1828
  }
@@ -1727,12 +1832,18 @@ export async function runBot(deps) {
1727
1832
  clearInflight(dir, id);
1728
1833
  const { excerpt } = recordTurnFailure(outcome.error, sessionScope);
1729
1834
  if (failures.exceeded(now().getTime())) {
1730
- await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${excerpt}`);
1835
+ await holdAfterFailures(excerpt, sendToPerson, true);
1731
1836
  return;
1732
1837
  }
1733
1838
  if (attempt >= MAX_ATTEMPTS_PER_MESSAGE) {
1734
1839
  try {
1735
- await sendToPerson(`Sorry — I couldn't answer that one (${runtime.id} kept failing: ${excerpt.slice(0, 160)}). Try again in a bit, or check that ${runtime.id} is signed in on this computer.`);
1840
+ await postRepair(markerTarget, runtimeRepairNotice({
1841
+ cls: classifyRuntimeError(outcome.error),
1842
+ runtime: runtime.id,
1843
+ botName: config.name,
1844
+ context: "message",
1845
+ retryOf: item.eventId ?? id,
1846
+ }), item.rootEventId);
1736
1847
  }
1737
1848
  catch {
1738
1849
  /* best-effort */
@@ -1762,8 +1873,9 @@ export async function runBot(deps) {
1762
1873
  attempts.delete(id);
1763
1874
  await clearThinking();
1764
1875
  log("info", `unmentioned room turn ${id} failed with no post (${where}); acked silently`);
1876
+ // Nobody asked the bot here, so the hold note goes to the owner's DM, not the room.
1765
1877
  if (failures.exceeded(now().getTime())) {
1766
- await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
1878
+ await holdAfterFailures(outcome.excerpt, (body) => sendToTarget({ kind: "dm", peerUid: config.ownerUid }, body, undefined, "human"));
1767
1879
  }
1768
1880
  return;
1769
1881
  }
@@ -1771,8 +1883,21 @@ export async function runBot(deps) {
1771
1883
  // DM or @-mentioned room reasoning_only: the person asked directly, so
1772
1884
  // still say we did not finish — but with a plain reason, never the raw
1773
1885
  // internal error string.
1774
- const reason = reasoningOnly ? "the model returned no answer" : failureReason(outcome);
1775
- await sendToPerson(didNotFinishText(reason, poster.postedCount > 0));
1886
+ if (reasoningOnly || outcome.aborted) {
1887
+ const reason = reasoningOnly ? "the model returned no answer" : failureReason(outcome);
1888
+ await sendToPerson(didNotFinishText(reason, poster.postedCount > 0));
1889
+ }
1890
+ else {
1891
+ const cls = failureClass(outcome);
1892
+ await postRepair(markerTarget, runtimeRepairNotice({
1893
+ cls,
1894
+ runtime: runtime.id,
1895
+ botName: config.name,
1896
+ context: "message",
1897
+ text: runtimeDidNotFinishText(cls, runtime.id, poster.postedCount > 0),
1898
+ retryOf: item.eventId ?? id,
1899
+ }), item.rootEventId);
1900
+ }
1776
1901
  }
1777
1902
  catch (err) {
1778
1903
  // Leave it un-acked with the marker: the next poll says it did not finish.
@@ -1785,7 +1910,7 @@ export async function runBot(deps) {
1785
1910
  attempts.delete(id);
1786
1911
  log("error", `turn on ${id} ${outcome.aborted ? "stopped by the owner" : "failed"} after it started; told the owner, not re-run`);
1787
1912
  if (!outcome.aborted && failures.exceeded(now().getTime())) {
1788
- await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
1913
+ await holdAfterFailures(outcome.excerpt, sendToPerson);
1789
1914
  }
1790
1915
  };
1791
1916
  // The kickoff runs alongside the heartbeat, before the first inbox poll, so
@@ -1874,6 +1999,18 @@ export async function runBot(deps) {
1874
1999
  // Queued items were never started or acked; the next process gets them.
1875
2000
  if (pollForCliUpdate())
1876
2001
  break;
2002
+ // Completions the cloud has not accepted yet: re-send the report (never
2003
+ // the work), alongside the poll rather than in front of it. Also during
2004
+ // a hold: a report owed to the cloud has nothing to do with the model.
2005
+ kickCompletionRetries();
2006
+ // Holding after repeated model failures: no inbox pulls until the
2007
+ // cooldown is over (the update watcher above keeps running meanwhile).
2008
+ if (modelHoldUntil > now().getTime()) {
2009
+ await sleep(Math.max(1, Math.min(inboxIntervalMs, modelHoldUntil - now().getTime())));
2010
+ continue;
2011
+ }
2012
+ if (modelHoldUntil)
2013
+ liftModelHold();
1877
2014
  // A kickoff held for a sign-in retries on the same timer as messages.
1878
2015
  if (busyLanes.size === 0 &&
1879
2016
  (!signInIssue || now().getTime() >= nextSignInRetryAt) &&
@@ -1884,9 +2021,6 @@ export async function runBot(deps) {
1884
2021
  log("error", `kickoff retry crashed: ${err instanceof Error ? err.message : String(err)}`);
1885
2022
  });
1886
2023
  }
1887
- // Completions the cloud has not accepted yet: re-send the report (never
1888
- // the work), alongside the poll rather than in front of it.
1889
- kickCompletionRetries();
1890
2024
  const useInboxBackoff = await inboxPollBackoffEnabled().catch((error) => {
1891
2025
  log("warn", `inbox poll backoff flag lookup failed (${safeErrorClass(error)}); using default-off`);
1892
2026
  return false;
@@ -13,7 +13,51 @@
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 { type BotRuntime, type RuntimeStreamEvent, type RuntimeTurnInput } from "./types.js";
16
+ import { type BotRuntime, type RuntimeStreamEvent, type RuntimeTurnInput, type SpawnResult } from "./types.js";
17
+ /**
18
+ * The model a turn falls back to when the installed Codex is too old for the
19
+ * one it was asked to use (usually the person's own `model` in
20
+ * ~/.codex/config.toml, set by a newer Codex app) and the first choice from
21
+ * Codex's bundled catalog is refused as well. Every Codex CLI since mid 2026
22
+ * ships it.
23
+ */
24
+ export declare const CODEX_PINNED_FALLBACK_MODEL = "gpt-5.5";
25
+ /**
26
+ * The generation number in a Codex model slug: 6 for "gpt-6-astra", 6.1 for
27
+ * "gpt-6.1-sol", 5.6 for "gpt-5.6-terra". Undefined when the slug has none
28
+ * ("codex-auto-review").
29
+ */
30
+ export declare function codexModelGeneration(slug: string): number | undefined;
31
+ /** [0, 147, 0] from "codex-cli 0.147.0". */
32
+ export declare function parseCodexVersion(text: string | undefined): number[] | undefined;
33
+ /**
34
+ * The model Codex itself would offer first that this Codex is most likely to
35
+ * run, from the JSON of `codex debug models --bundled` (`{ "models": [...] }`
36
+ * or a bare array). Lowest `priority` wins among the models that are listed
37
+ * in the picker, not `exclude`d, not newer than `installed` (when the catalog
38
+ * carries `minimal_client_version`; the real one does not), and of an OLDER
39
+ * generation than `refused` (gpt-6.1-sol is no better than gpt-6-astra for a
40
+ * Codex that refuses gpt-6; gpt-5.6-sol is). The bundled catalog of an old
41
+ * Codex still lists models it cannot run, because the "requires a newer
42
+ * version" check is the server's, so the generation rule is what matters.
43
+ */
44
+ export declare function pickCodexFallbackModel(catalogJson: string, opts?: {
45
+ exclude?: string[];
46
+ installed?: number[];
47
+ refused?: string;
48
+ }): string | undefined;
49
+ /**
50
+ * The models to try, best first, after this Codex refused `failing` as too
51
+ * new: the best older-generation model from the bundled catalog, then the
52
+ * pinned fallback. At most MAX_MODEL_FALLBACK_RETRIES, never `failing` itself.
53
+ * `codex debug models --bundled` reads only the catalog inside the binary: no
54
+ * network, no sign-in, no cache write.
55
+ */
56
+ export declare function codexFallbackModels(args: {
57
+ reason: string;
58
+ requestedModel: string | undefined;
59
+ run: (args: string[]) => Promise<SpawnResult>;
60
+ }): Promise<string[]>;
17
61
  /** Progress events in one codex JSONL line: finished agent messages and tool starts. */
18
62
  export declare function codexStreamEvents(line: string): RuntimeStreamEvent[];
19
63
  export declare function codexPrompt(input: RuntimeTurnInput): string;
@@ -24,5 +68,27 @@ export declare function codexPrompt(input: RuntimeTurnInput): string;
24
68
  * warnings (`item.completed` with `item.type === "error"`) are not failures.
25
69
  */
26
70
  export declare function codexFailureReason(stdout: string): string | undefined;
71
+ /** True when `candidate` is a regular file the current user can execute. */
72
+ export type IsExecutableFile = (candidate: string) => boolean;
73
+ export declare function isExecutableFile(candidate: string): boolean;
74
+ /** App bundles that carry the Codex CLI, in the HQ desktop app's lookup order. */
75
+ export declare const CODEX_APP_BUNDLES: readonly ["ChatGPT.app", "Codex.app"];
76
+ /** Where the CLI sits inside a bundle: current layout first, then the older one. */
77
+ export declare const CODEX_BUNDLE_CLI_PATHS: readonly [string, string];
78
+ /**
79
+ * The Codex CLI shipped inside the ChatGPT or Codex desktop app. A Mac that
80
+ * only has the desktop app has no `codex` on PATH, yet the HQ app counts it as
81
+ * installed, so a bot must be able to use it too. Order matches the HQ desktop
82
+ * app (apps/sync/src-tauri/src/commands/launch.rs, bundled_codex_bin_in):
83
+ * /Applications before ~/Applications, ChatGPT.app before Codex.app, and the
84
+ * codex-cli/bin layout before the older Resources/codex one.
85
+ */
86
+ export declare function bundledCodexBin(homeDir?: string, isExecutable?: IsExecutableFile): string | null;
87
+ /**
88
+ * PATH wins. On macOS, fall back to the desktop app's bundled CLI. When
89
+ * nothing is found the bare name is returned, so the spawn fails with the
90
+ * usual "codex is not installed or not on PATH" error.
91
+ */
92
+ export declare function resolveCodexBinary(env: NodeJS.ProcessEnv, homeDir?: string, platform?: NodeJS.Platform, cwd?: string, isExecutable?: IsExecutableFile): string;
27
93
  export declare function createCodexRuntime(): BotRuntime;
28
94
  //# sourceMappingURL=codex.d.ts.map