@indigoai-us/hq-cli 5.345.103 → 5.345.105

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 (74) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/assets/bot-workers/setup/skills/add-a-pack.md +130 -0
  3. package/assets/bot-workers/setup/worker.yaml +3 -0
  4. package/dist/command-catalog.generated.d.ts +206 -0
  5. package/dist/command-catalog.generated.js +246 -0
  6. package/dist/commands/bot.d.ts +10 -0
  7. package/dist/commands/bot.js +20 -12
  8. package/dist/commands/daemon.js +385 -1
  9. package/dist/commands/integrations-connect.d.ts +6 -0
  10. package/dist/commands/integrations-connect.js +36 -0
  11. package/dist/commands/integrations-slack.d.ts +30 -0
  12. package/dist/commands/integrations-slack.js +81 -0
  13. package/dist/commands/integrations.js +2 -0
  14. package/dist/commands/pack-install.d.ts +20 -1
  15. package/dist/commands/pack-install.js +80 -5
  16. package/dist/commands/pack-start-bot.d.ts +24 -0
  17. package/dist/commands/pack-start-bot.js +76 -0
  18. package/dist/commands/packs.d.ts +9 -0
  19. package/dist/commands/packs.js +26 -3
  20. package/dist/commands/pkg-install.js +11 -2
  21. package/dist/commands/secrets.d.ts +1 -5
  22. package/dist/commands/secrets.js +2 -92
  23. package/dist/lib/bot/config.d.ts +7 -0
  24. package/dist/lib/bot/daemon.js +2 -1
  25. package/dist/lib/bot/index.d.ts +2 -0
  26. package/dist/lib/bot/index.js +2 -0
  27. package/dist/lib/bot/lean-settings.d.ts +16 -0
  28. package/dist/lib/bot/lean-settings.js +34 -0
  29. package/dist/lib/bot/pack-link.d.ts +66 -0
  30. package/dist/lib/bot/pack-link.js +153 -0
  31. package/dist/lib/bot/pack-setup-driver.d.ts +37 -0
  32. package/dist/lib/bot/pack-setup-driver.js +83 -0
  33. package/dist/lib/bot/pack-setup.d.ts +62 -0
  34. package/dist/lib/bot/pack-setup.js +103 -0
  35. package/dist/lib/bot/prompt.d.ts +8 -0
  36. package/dist/lib/bot/prompt.js +5 -0
  37. package/dist/lib/bot/run.d.ts +18 -2
  38. package/dist/lib/bot/run.js +163 -25
  39. package/dist/lib/bot/runtime/claude.d.ts +27 -2
  40. package/dist/lib/bot/runtime/claude.js +49 -4
  41. package/dist/lib/bot/runtime/types.d.ts +5 -0
  42. package/dist/lib/bot/runtime-failure.d.ts +18 -2
  43. package/dist/lib/bot/runtime-failure.js +21 -1
  44. package/dist/lib/bot/toolchain.d.ts +25 -0
  45. package/dist/lib/bot/toolchain.js +33 -0
  46. package/dist/lib/bot/worker-source.d.ts +1 -0
  47. package/dist/lib/bot/worker-source.js +7 -1
  48. package/dist/lib/daemon/catalog.d.ts +15 -0
  49. package/dist/lib/daemon/catalog.js +49 -0
  50. package/dist/lib/daemon/paths.d.ts +1 -0
  51. package/dist/lib/daemon/paths.js +1 -0
  52. package/dist/lib/daemon/remote-control-forwarder.d.ts +14 -0
  53. package/dist/lib/daemon/remote-control-forwarder.js +104 -0
  54. package/dist/lib/daemon/remote-control-pools.d.ts +44 -0
  55. package/dist/lib/daemon/remote-control-pools.js +245 -0
  56. package/dist/lib/daemon/remote-control.d.ts +4 -0
  57. package/dist/lib/daemon/remote-control.js +17 -2
  58. package/dist/lib/daemon/run.js +126 -0
  59. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +5 -4
  60. package/dist/lib/doctor/fix/refresh-sync-progress.js +5 -4
  61. package/dist/lib/doctor/json-output.d.ts +19 -7
  62. package/dist/lib/doctor/json-output.js +16 -6
  63. package/dist/lib/packages/setup-block.d.ts +43 -0
  64. package/dist/lib/packages/setup-block.js +236 -0
  65. package/dist/lib/packages/setup-prompt.d.ts +34 -0
  66. package/dist/lib/packages/setup-prompt.js +128 -0
  67. package/dist/lib/packages/setup-takeover.d.ts +50 -0
  68. package/dist/lib/packages/setup-takeover.js +75 -0
  69. package/dist/lib/packages/start-bot.d.ts +47 -0
  70. package/dist/lib/packages/start-bot.js +66 -0
  71. package/dist/lib/session-company-lock.d.ts +7 -0
  72. package/dist/lib/session-company-lock.js +97 -0
  73. package/dist/types.d.ts +82 -0
  74. package/package.json +1 -1
@@ -63,10 +63,13 @@ import { containsNoReplySentinel, ProgressPoster, } from "./progress.js";
63
63
  import { splitBody } from "./split.js";
64
64
  import { createBotLogger } from "./log.js";
65
65
  import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
66
+ import { resolvePackSetupPrompt } from "./pack-setup.js";
67
+ import { writeLeanClaudeSettings } from "./lean-settings.js";
68
+ import { companyDisplayName, firstQuestionNote, questionDefault, questionMessage, readPackSetupState, writePackSetupState, } from "./pack-setup-driver.js";
66
69
  import { loadSetupExplainer, patchSetupIntroState, readSetupIntroState, readSetupProgressIntro, replyOffersExplain, SETUP_EXPLAINED_NOTE, setupExplainDue, } from "./setup-explain.js";
67
70
  import { detectCodingToolActivity, SIGNIFICANT_CODING_TOOL_ACTIVITY, } from "./coding-tool-activity.js";
68
71
  import { setupKeptHereNote, setupOfferAnsweredInWordsNote, setupToolOfferAnswer, setupToolOfferText, setupWelcomeOpening, } from "./setup-tool-offer.js";
69
- import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled } from "./runtime/claude.js";
72
+ import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled, onPath } from "./runtime/claude.js";
70
73
  import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
71
74
  import { botAttachmentsDir, dmSessionScope, isDmSessionScope } from "./paths.js";
72
75
  import { appendDmHistory, dmHistoryHasModelThread, markDmHistoryModelTurn, readDmHistory, withRecentDmHistory } from "./dm-history.js";
@@ -78,7 +81,7 @@ import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOut
78
81
  import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
79
82
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
80
83
  import { SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
81
- import { classifyRuntimeError, classifyRuntimeFailure, runtimeDidNotFinishText, runtimeFailureReason, runtimeFailureText, runtimeRepairNotice, } from "./runtime-failure.js";
84
+ import { classifyRuntimeError, classifyRuntimeFailure, runtimeDidNotFinishText, runtimeFailureReason, runtimeFailureText, runtimeRepairNotice, withRetrySuggestion, } from "./runtime-failure.js";
82
85
  import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
83
86
  import { captureHeartbeatIdentityMismatch, identityMismatchMessage, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
84
87
  import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
@@ -183,6 +186,24 @@ export function turnEnv(base, kind, agentUid, companySlug) {
183
186
  }
184
187
  /** The core worker HQ's setup bot runs. */
185
188
  export const SETUP_WORKER_ID = SETUP_BOT_WORKER_ID;
189
+ /**
190
+ * The executable a bot's turns will spawn, for the start-up log: an absolute
191
+ * path when the runtime found one (the HQ app toolchain, a system prefix, the
192
+ * Claude desktop app), the bare name when it relies on PATH, or "not found"
193
+ * when the bare name is on no PATH entry either.
194
+ */
195
+ export function describeRuntimeBinary(runtime, env, cwd) {
196
+ let binary;
197
+ try {
198
+ binary = runtime.resolveBinary?.(env, cwd, process.platform) ?? runtime.binary;
199
+ }
200
+ catch {
201
+ binary = runtime.binary;
202
+ }
203
+ if (path.isAbsolute(binary))
204
+ return binary;
205
+ return onPath(binary, env) ? `${binary} (on PATH)` : `${binary} (not found on PATH; turns will fail as not-installed)`;
206
+ }
186
207
  /** The one message a person gets when a turn on their message did not finish. */
187
208
  export function didNotFinishText(reason, progressPosted) {
188
209
  const r = reason.trim().replace(/[.\s]+$/, "");
@@ -194,10 +215,16 @@ export { containsNoReplySentinel };
194
215
  export function isReasoningOnlyError(excerpt) {
195
216
  return excerpt.includes("reasoning_only");
196
217
  }
197
- /** The one note a person gets when the bot pauses after repeated failures. */
198
- export function holdNoticeText(holdMs, messagePending) {
218
+ /**
219
+ * The one note a person gets when the bot pauses after repeated failures. With
220
+ * `reason` (the plain failure text of the last turn) it is the summary that
221
+ * replaces the fifth per-turn line: one message for the run of failures, with
222
+ * what went wrong and the one next step.
223
+ */
224
+ export function holdNoticeText(holdMs, messagePending, reason) {
199
225
  const minutes = Math.max(1, Math.round(holdMs / 60_000));
200
- 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"}.`;
226
+ const base = `I'm pausing for ${minutes} minute${minutes === 1 ? "" : "s"} after ${reason ? `${FAILURE_LIMIT} failed tries` : "a few failed tries"}, then I'll ${messagePending ? "pick your message up" : "pick up from your next message"}.`;
227
+ return reason ? `${base} ${reason}` : base;
201
228
  }
202
229
  /** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
203
230
  export const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
@@ -459,6 +486,7 @@ export async function runBot(deps) {
459
486
  // A restart is the owner's "try again" after signing in: start clean.
460
487
  setStatus({ ...fallback(), pid, state: "starting", canDrainForPromotion: false, runtimeSignIn: null, cliVersion });
461
488
  log("info", `starting ${config.name} on hq-cli ${cliVersion} (${config.runtime}, ${kind} bot${companies.length ? ` of ${companies.join(", ")}` : ""}) as ${config.agentUid} for ${config.ownerUid}`);
489
+ log("info", `${runtime.id} binary: ${describeRuntimeBinary(runtime, turnEnv(process.env, kind, config.agentUid, config.companySlug), turnCwd)}`);
462
490
  deps.cliUpdate?.startup(log);
463
491
  try {
464
492
  const record = await api.getAgent(config.agentUid);
@@ -479,8 +507,19 @@ export async function runBot(deps) {
479
507
  return { stop: async () => { }, done, status: () => null, idle: () => true };
480
508
  }
481
509
  // ── System prompt ──────────────────────────────────────────────────────────
510
+ // A pack worker's bot gets the pack's "Pack setup" section (and the HQ setup
511
+ // takeover when the pack asks for it and setup is unfinished).
512
+ // Resolved even under a prompt override: the instant pack setup reads it too.
513
+ let packSetup = null;
514
+ try {
515
+ packSetup = resolvePackSetupPrompt({ hqRoot: config.hqRoot, workerDir: config.workerDir, workerSource: config.workerSource, log });
516
+ }
517
+ catch (err) {
518
+ log("warn", `pack setup section skipped: ${err instanceof Error ? err.message : String(err)}`);
519
+ }
482
520
  const systemPrompt = deps.systemPromptOverride ??
483
521
  buildSystemPrompt({
522
+ packSetup,
484
523
  botName: config.name,
485
524
  ...(config.displayName ? { displayName: config.displayName } : {}),
486
525
  agentUid: config.agentUid,
@@ -502,6 +541,16 @@ export async function runBot(deps) {
502
541
  const systemPromptFile = path.join(dir, "system-prompt.md");
503
542
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
504
543
  fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 });
544
+ // A lean bot's Claude Code turns load only HQ's tool hooks (lean-settings.ts).
545
+ let leanSettingsFile = null;
546
+ if (config.lean && config.runtime === "claude") {
547
+ try {
548
+ leanSettingsFile = writeLeanClaudeSettings(dir, config.hqRoot);
549
+ }
550
+ catch (err) {
551
+ log("warn", `lean settings not written; turns load the full settings: ${err instanceof Error ? err.message : String(err)}`);
552
+ }
553
+ }
505
554
  if (hasPromotionHold(dir)) {
506
555
  await finish("stopped", 0, "local bot held for cloud promotion");
507
556
  return { stop: async () => { }, done, status: () => null, idle: () => true };
@@ -580,7 +629,7 @@ export async function runBot(deps) {
580
629
  */
581
630
  const modelHoldMs = Math.max(0, deps.modelHoldMs ?? FAILURE_WINDOW_MS);
582
631
  let modelHoldUntil = 0;
583
- const holdAfterFailures = async (excerpt, tell, messagePending = false) => {
632
+ const holdAfterFailures = async (excerpt, tell, messagePending = false, cls) => {
584
633
  const until = now().getTime() + modelHoldMs;
585
634
  modelHoldUntil = until;
586
635
  failures.clear();
@@ -592,7 +641,7 @@ export async function runBot(deps) {
592
641
  // nothing while their next messages wait out the hold.
593
642
  if (tell) {
594
643
  try {
595
- await tell(holdNoticeText(modelHoldMs, messagePending));
644
+ await tell(holdNoticeText(modelHoldMs, messagePending, cls ? runtimeFailureText(cls, runtime.id) : undefined));
596
645
  }
597
646
  catch (err) {
598
647
  log("warn", `could not say that turns are on hold: ${err instanceof Error ? err.message : String(err)}`);
@@ -863,6 +912,7 @@ export async function runBot(deps) {
863
912
  sessionId: session?.sessionId,
864
913
  ...(live.model ? { model: live.model } : {}),
865
914
  ...(live.effort ? { effort: live.effort } : {}),
915
+ ...(leanSettingsFile ? { leanSettingsFile } : {}),
866
916
  ...bind,
867
917
  ...stream,
868
918
  });
@@ -1046,6 +1096,12 @@ export async function runBot(deps) {
1046
1096
  * A server that refuses the block (400) still gets the plain body.
1047
1097
  */
1048
1098
  const postRepair = async (target, notice, rootEventId) => {
1099
+ if (target.kind === "dm" && !notice.richContent) {
1100
+ // No repair card for this class: a tap-to-answer Retry so the failure is
1101
+ // never a dead end in the person's DM (rooms get the plain line).
1102
+ await api.sendDm({ toPersonUid: target.peerUid, body: withRetrySuggestion(notice.text), audience: "human", ...(rootEventId ? { rootEventId } : {}) });
1103
+ return;
1104
+ }
1049
1105
  if (target.kind === "dm" && notice.richContent) {
1050
1106
  const base = { toPersonUid: target.peerUid, body: notice.text, audience: "human", ...(rootEventId ? { rootEventId } : {}) };
1051
1107
  try {
@@ -1400,12 +1456,56 @@ export async function runBot(deps) {
1400
1456
  recordToolOffer(tool);
1401
1457
  return true;
1402
1458
  };
1459
+ // ── Instant first question (pack-setup-driver.ts) ─────────────────────────
1460
+ /** The one company this pack is set up in: the owner's only company with a folder here. */
1461
+ const resolvePackFirm = async () => {
1462
+ if (!deps.ownerContext)
1463
+ return null;
1464
+ const owner = await deps.ownerContext();
1465
+ if (owner.status !== "ok")
1466
+ return null;
1467
+ const local = owner.companies
1468
+ .map((c) => (c.companySlug ?? "").trim())
1469
+ .filter((slug, i, all) => slug && all.indexOf(slug) === i && fs.existsSync(path.join(config.hqRoot, "companies", slug)));
1470
+ return local.length === 1 ? local[0] : null;
1471
+ };
1472
+ /**
1473
+ * First start of a pack bot: send the first setup question from code. False
1474
+ * (run the kickoff model turn) when there is no pack setup, it already
1475
+ * started, or the owner has no single company here to set the pack up in.
1476
+ */
1477
+ const startPackSetup = async () => {
1478
+ if (!packSetup || readPackSetupState(dir))
1479
+ return false;
1480
+ try {
1481
+ const firm = await resolvePackFirm();
1482
+ if (!firm) {
1483
+ log("info", "pack setup: no single company on this computer to set up in; the kickoff turn handles it");
1484
+ return false;
1485
+ }
1486
+ const q = packSetup.setup.questions[0];
1487
+ const firmName = companyDisplayName(config.hqRoot, firm);
1488
+ await sendToTarget({ kind: "dm", peerUid: config.ownerUid }, questionMessage(q, questionDefault(q, { firmName })), undefined, "human");
1489
+ writePackSetupState(dir, { v: 1, pack: packSetup.packName, firm, askedFirst: q.id, modelNotePending: true, updatedAt: now().toISOString() });
1490
+ log("info", `pack setup for ${firm}: asked ${q.id} from code (no kickoff turn)`);
1491
+ return true;
1492
+ }
1493
+ catch (err) {
1494
+ log("warn", `first pack setup question not sent from code (${err instanceof Error ? err.message : String(err)}); running the kickoff`);
1495
+ return false;
1496
+ }
1497
+ };
1403
1498
  const runKickoff = async (introEventId) => {
1404
1499
  const prompt = (config.kickoff ?? "").trim();
1405
1500
  if (!prompt || stopping || state !== "running")
1406
1501
  return;
1407
1502
  if (isSetupBot && (await offerCodingTool()))
1408
1503
  return;
1504
+ if (packSetup && (await startPackSetup())) {
1505
+ if (readBotConfig(dir)?.kickoffPendingSignIn)
1506
+ patchBotConfig(dir, { kickoffPendingSignIn: false });
1507
+ return;
1508
+ }
1409
1509
  const peerUid = config.ownerUid;
1410
1510
  const tReceived = now().getTime();
1411
1511
  log("info", `kickoff turn starting (${prompt.length} chars)`);
@@ -1462,6 +1562,13 @@ export async function runBot(deps) {
1462
1562
  // The person sees one plain sentence and one next step; the raw runtime
1463
1563
  // text is in the log (recordTurnFailure) and status.json only.
1464
1564
  const cls = failureClass(outcome);
1565
+ if (!outcome.aborted && failures.exceeded(now().getTime())) {
1566
+ // The failure that hit the limit: one summary carries the reason and the pause.
1567
+ clearInflight(dir, KICKOFF_MESSAGE_ID);
1568
+ log("error", `kickoff turn failed (${count}/${FAILURE_LIMIT} in window); not retried`);
1569
+ await holdAfterFailures(outcome.excerpt, sendToPerson, false, cls);
1570
+ return;
1571
+ }
1465
1572
  const notice = outcome.aborted
1466
1573
  ? { cls, text: `${didNotFinishText(failureReason(outcome), poster.postedCount > 0)} Send me any message and I'll pick up from there.` }
1467
1574
  : runtimeRepairNotice({
@@ -1479,9 +1586,6 @@ export async function runBot(deps) {
1479
1586
  /* the marker stays: the next start says it did not finish */
1480
1587
  }
1481
1588
  log("error", `kickoff turn ${outcome.aborted ? "stopped by the owner" : `failed (${count}/${FAILURE_LIMIT} in window)`}; not retried`);
1482
- if (!outcome.aborted && failures.exceeded(now().getTime())) {
1483
- await holdAfterFailures(outcome.excerpt, sendToPerson);
1484
- }
1485
1589
  };
1486
1590
  /**
1487
1591
  * One fired job: a model turn on the job's own prompt, in the job's own
@@ -1629,12 +1733,14 @@ export async function runBot(deps) {
1629
1733
  // The model never ran, so nothing happened outside this Mac: retry.
1630
1734
  clearInflight(dir, id);
1631
1735
  const { excerpt } = recordTurnFailure(outcome.error, target.sessionScope);
1736
+ const cls = classifyRuntimeError(outcome.error);
1632
1737
  if (failures.exceeded(now().getTime())) {
1633
- await holdAfterFailures(excerpt, sendToPerson, true);
1738
+ await holdAfterFailures(excerpt, sendToPerson, true, cls);
1634
1739
  return;
1635
1740
  }
1636
- if (attempt >= MAX_ATTEMPTS_PER_MESSAGE) {
1637
- const plain = runtimeFailureText(classifyRuntimeError(outcome.error), runtime.id);
1741
+ // A missing tool does not come back between retries: say so now.
1742
+ if (attempt >= MAX_ATTEMPTS_PER_MESSAGE || cls === "not-installed") {
1743
+ const plain = runtimeFailureText(cls, runtime.id);
1638
1744
  await sendToPerson(`I couldn't run this one. ${plain}`).catch(() => undefined);
1639
1745
  await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(plain), disposition: `${runtime.id} could not be started` });
1640
1746
  markReported("failed");
@@ -1674,7 +1780,7 @@ export async function runBot(deps) {
1674
1780
  attempts.delete(id);
1675
1781
  log("error", `scheduled run ${id} of job ${jobId} ${outcome.aborted ? "stopped by the owner" : "failed"} after it started; not re-run`);
1676
1782
  if (!outcome.aborted && failures.exceeded(now().getTime())) {
1677
- await holdAfterFailures(outcome.excerpt, sendToPerson);
1783
+ await holdAfterFailures(outcome.excerpt, sendToPerson, false, failureClass(outcome));
1678
1784
  }
1679
1785
  };
1680
1786
  /**
@@ -1865,14 +1971,24 @@ export async function runBot(deps) {
1865
1971
  const toolOffer = isSetupBot && ownerDm ? pendingToolOffer() : null;
1866
1972
  const toolAnswer = toolOffer ? setupToolOfferAnswer(messageText, attachmentRefs.length) : null;
1867
1973
  const kickoffPrompt = (config.kickoff ?? "").trim();
1974
+ // While (or right after) the app runs the pack setup, the model is told where it stands.
1975
+ const packState = packSetup && ownerDm ? readPackSetupState(dir) : null;
1976
+ const packTurnNote = packSetup && packState?.modelNotePending ? firstQuestionNote(packSetup.setup, packState, companyDisplayName(config.hqRoot, packState.firm)) : null;
1868
1977
  const ownerText = toolOffer && toolAnswer === "kept_here" && kickoffPrompt
1869
1978
  ? `${setupKeptHereNote(toolOffer)}\n\n${kickoffPrompt}`
1870
1979
  : toolOffer
1871
1980
  ? `${setupOfferAnsweredInWordsNote(toolOffer)}\n\n${text}`
1872
1981
  : setupNote
1873
1982
  ? `${SETUP_EXPLAINED_NOTE}\n\n${text}`
1874
- : text;
1875
- const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? ownerText :
1983
+ : packTurnNote
1984
+ ? `${packTurnNote}\n\n${text}`
1985
+ : text;
1986
+ // A note the desktop app sends the setup bot as it finishes visual setup
1987
+ // steps. It often lands while the bot's previous question is still open;
1988
+ // answering every note re-posted that question (three times in a row on
1989
+ // 2026-10-12). Such a turn may stay quiet.
1990
+ const appSetupNote = isSetupBot && ownerDm && isAppSetupNote(messageText);
1991
+ const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? (appSetupNote ? `${APP_SETUP_NOTE_TURN_NOTE}\n\n${ownerText}` : ownerText) :
1876
1992
  `[HQ DM sender: ${target.peerUid}; owner: ${config.ownerUid}; sender is not the owner.]\n\n${text}`;
1877
1993
  const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
1878
1994
  const { sendProgress, sendToPerson, saved } = trackingSend(markerTarget, item.rootEventId);
@@ -1888,7 +2004,7 @@ export async function runBot(deps) {
1888
2004
  tReceived,
1889
2005
  sendProgress,
1890
2006
  sendToPerson,
1891
- silenceable: target.kind === "room" && !target.mentioned,
2007
+ silenceable: (target.kind === "room" && !target.mentioned) || appSetupNote,
1892
2008
  ...(readDirs.length ? { readDirs } : {}),
1893
2009
  ...(target.kind === "dm" ? { historyEventId: item.eventId ?? id } : {}),
1894
2010
  ...(target.kind === "room"
@@ -1917,6 +2033,11 @@ export async function runBot(deps) {
1917
2033
  }
1918
2034
  if (outcome.ok && ownerDm)
1919
2035
  noteSetupReply(outcome.reply);
2036
+ if (outcome.ok && packState?.modelNotePending) {
2037
+ const latest = readPackSetupState(dir);
2038
+ if (latest)
2039
+ writePackSetupState(dir, { ...latest, modelNotePending: false, updatedAt: now().toISOString() });
2040
+ }
1920
2041
  if (outcome.ok) {
1921
2042
  // A no_reply sentinel or intentionally empty reply: ack silently, post nothing.
1922
2043
  if (outcome.silent) {
@@ -1966,14 +2087,17 @@ export async function runBot(deps) {
1966
2087
  // outside this Mac, so retrying is safe.
1967
2088
  clearInflight(dir, id);
1968
2089
  const { excerpt } = recordTurnFailure(outcome.error, sessionScope);
2090
+ const cls = classifyRuntimeError(outcome.error);
1969
2091
  if (failures.exceeded(now().getTime())) {
1970
- await holdAfterFailures(excerpt, sendToPerson, true);
2092
+ await holdAfterFailures(excerpt, sendToPerson, true, cls);
1971
2093
  return;
1972
2094
  }
1973
- if (attempt >= MAX_ATTEMPTS_PER_MESSAGE) {
2095
+ // A missing tool does not come back between retries: tell the person on
2096
+ // the first attempt (with Retry) instead of after a silent minute.
2097
+ if (attempt >= MAX_ATTEMPTS_PER_MESSAGE || cls === "not-installed") {
1974
2098
  try {
1975
2099
  await postRepair(markerTarget, runtimeRepairNotice({
1976
- cls: classifyRuntimeError(outcome.error),
2100
+ cls,
1977
2101
  runtime: runtime.id,
1978
2102
  botName: config.name,
1979
2103
  context: "message",
@@ -2010,15 +2134,21 @@ export async function runBot(deps) {
2010
2134
  log("info", `unmentioned room turn ${id} failed with no post (${where}); acked silently`);
2011
2135
  // Nobody asked the bot here, so the hold note goes to the owner's DM, not the room.
2012
2136
  if (failures.exceeded(now().getTime())) {
2013
- await holdAfterFailures(outcome.excerpt, (body) => sendToTarget({ kind: "dm", peerUid: config.ownerUid }, body, undefined, "human"));
2137
+ await holdAfterFailures(outcome.excerpt, (body) => sendToTarget({ kind: "dm", peerUid: config.ownerUid }, body, undefined, "human"), false, failureClass(outcome));
2014
2138
  }
2015
2139
  return;
2016
2140
  }
2141
+ const holdNow = !outcome.aborted && failures.exceeded(now().getTime());
2017
2142
  try {
2018
2143
  // DM or @-mentioned room reasoning_only: the person asked directly, so
2019
2144
  // still say we did not finish — but with a plain reason, never the raw
2020
2145
  // internal error string.
2021
- if (reasoningOnly || outcome.aborted) {
2146
+ if (holdNow) {
2147
+ // The failure that hit the limit: one summary (reason, next step, the
2148
+ // pause) instead of a per-turn line followed by a pause note.
2149
+ await holdAfterFailures(outcome.excerpt, (body) => sendToPerson(body), false, failureClass(outcome));
2150
+ }
2151
+ else if (reasoningOnly || outcome.aborted) {
2022
2152
  const reason = reasoningOnly ? "the model returned no answer" : failureReason(outcome);
2023
2153
  await sendToPerson(didNotFinishText(reason, poster.postedCount > 0));
2024
2154
  }
@@ -2044,9 +2174,6 @@ export async function runBot(deps) {
2044
2174
  clearInflight(dir, id);
2045
2175
  attempts.delete(id);
2046
2176
  log("error", `turn on ${id} ${outcome.aborted ? "stopped by the owner" : "failed"} after it started; told the owner, not re-run`);
2047
- if (!outcome.aborted && failures.exceeded(now().getTime())) {
2048
- await holdAfterFailures(outcome.excerpt, sendToPerson);
2049
- }
2050
2177
  };
2051
2178
  // The kickoff runs alongside the heartbeat, before the first inbox poll, so
2052
2179
  // an owner message sent meanwhile is answered after it in the same session.
@@ -2259,4 +2386,15 @@ export async function runBot(deps) {
2259
2386
  });
2260
2387
  return { stop, done, status: readStatus, idle: () => pending.size === 0 };
2261
2388
  }
2389
+ /** The prefix the HQ desktop app puts on the setup notes it sends the setup bot. */
2390
+ const APP_SETUP_NOTE_PREFIX = "Setup note from the HQ desktop app:";
2391
+ /** True for a setup note the desktop app sent, not words the person typed. */
2392
+ export function isAppSetupNote(text) {
2393
+ return text.trimStart().startsWith(APP_SETUP_NOTE_PREFIX);
2394
+ }
2395
+ /** Put ahead of an app setup note: fold it in, and stay quiet unless it changes what to say. */
2396
+ export const APP_SETUP_NOTE_TURN_NOTE = "[This message is a setup note from the HQ desktop app, not words from the person. Record what it settles. " +
2397
+ "If you already asked the person something that is still unanswered and this note does not change it, reply with exactly " +
2398
+ '{"action":"no_reply"} and nothing else, so nothing is posted. Never repeat a question you already asked. ' +
2399
+ "Write to the person only when the note changes what they need to know or answer next, in one or two sentences.]";
2262
2400
  //# sourceMappingURL=run.js.map
@@ -45,6 +45,31 @@ export declare function onPath(name: string, env: NodeJS.ProcessEnv): boolean;
45
45
  * skipped), matching the HQ desktop app's own lookup.
46
46
  */
47
47
  export declare function bundledClaudeBin(homeDir?: string): string | null;
48
- export declare function resolveClaudeBinary(env: NodeJS.ProcessEnv, homeDir?: string, platform?: NodeJS.Platform, cwd?: string): string;
49
- export declare function createClaudeRuntime(env?: NodeJS.ProcessEnv): BotRuntime;
48
+ /**
49
+ * Where a bot looks for `claude`, in order, outside the PATH walk. The HQ
50
+ * desktop app's toolchain comes first: on a clean Mac it is the only copy, and
51
+ * it is the one the app keeps updated. Then the usual install prefixes, so a
52
+ * bot under launchd (no shell profile) still finds a Homebrew or npm global
53
+ * install even when its PATH is thin.
54
+ */
55
+ export declare const CLAUDE_SYSTEM_BIN_DIRS: readonly ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"];
56
+ /**
57
+ * The `claude` a bot spawns. Order (contract "Claude resolution for bots"):
58
+ *
59
+ * 1. `~/Library/Application Support/Indigo HQ/toolchain/npm-global/bin/claude`
60
+ * (the HQ app's own install; macOS only)
61
+ * 2. /usr/local/bin, /opt/homebrew/bin, /usr/bin
62
+ * 3. PATH (returned as the bare name, so the spawn uses the same lookup)
63
+ * 4. the Claude desktop app's bundled Claude Code
64
+ * 5. the bare name, so the failure reads "claude is not installed".
65
+ *
66
+ * Absolute paths come back for 1, 2 and 4 so the bot log can say which one
67
+ * ran.
68
+ */
69
+ export declare function resolveClaudeBinary(env: NodeJS.ProcessEnv, homeDir?: string, platform?: NodeJS.Platform, cwd?: string, systemBinDirs?: readonly string[]): string;
70
+ export interface ClaudeRuntimeOptions {
71
+ /** Install prefixes checked after the HQ app toolchain (test seam; default CLAUDE_SYSTEM_BIN_DIRS). */
72
+ systemBinDirs?: readonly string[];
73
+ }
74
+ export declare function createClaudeRuntime(env?: NodeJS.ProcessEnv, opts?: ClaudeRuntimeOptions): BotRuntime;
50
75
  //# sourceMappingURL=claude.d.ts.map
@@ -19,6 +19,7 @@ import * as fs from "node:fs";
19
19
  import * as os from "node:os";
20
20
  import * as path from "node:path";
21
21
  import { messagesStreamEvents, messagesStreamFailure, messagesStreamResult } from "./messages-stream.js";
22
+ import { hqAppToolchainBin } from "../toolchain.js";
22
23
  import { resolveRuntimeBinary } from "./windows.js";
23
24
  export const CLAUDE_PERMISSION_MODE_ENV = "HQ_BOT_CLAUDE_PERMISSION_MODE";
24
25
  export const DEFAULT_CLAUDE_PERMISSION_MODE = "bypassPermissions";
@@ -104,18 +105,59 @@ export function bundledClaudeBin(homeDir = os.homedir()) {
104
105
  });
105
106
  return candidates.at(-1)?.bin ?? null;
106
107
  }
107
- export function resolveClaudeBinary(env, homeDir, platform = process.platform, cwd = process.cwd()) {
108
+ /**
109
+ * Where a bot looks for `claude`, in order, outside the PATH walk. The HQ
110
+ * desktop app's toolchain comes first: on a clean Mac it is the only copy, and
111
+ * it is the one the app keeps updated. Then the usual install prefixes, so a
112
+ * bot under launchd (no shell profile) still finds a Homebrew or npm global
113
+ * install even when its PATH is thin.
114
+ */
115
+ export const CLAUDE_SYSTEM_BIN_DIRS = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"];
116
+ function executableAt(file) {
117
+ try {
118
+ fs.accessSync(file, fs.constants.X_OK);
119
+ return fs.statSync(file).isFile();
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ /**
126
+ * The `claude` a bot spawns. Order (contract "Claude resolution for bots"):
127
+ *
128
+ * 1. `~/Library/Application Support/Indigo HQ/toolchain/npm-global/bin/claude`
129
+ * (the HQ app's own install; macOS only)
130
+ * 2. /usr/local/bin, /opt/homebrew/bin, /usr/bin
131
+ * 3. PATH (returned as the bare name, so the spawn uses the same lookup)
132
+ * 4. the Claude desktop app's bundled Claude Code
133
+ * 5. the bare name, so the failure reads "claude is not installed".
134
+ *
135
+ * Absolute paths come back for 1, 2 and 4 so the bot log can say which one
136
+ * ran.
137
+ */
138
+ export function resolveClaudeBinary(env, homeDir, platform = process.platform, cwd = process.cwd(), systemBinDirs = CLAUDE_SYSTEM_BIN_DIRS) {
108
139
  if (platform === "win32")
109
140
  return resolveRuntimeBinary("claude", env, platform, fs.existsSync, cwd);
141
+ const home = homeDir ?? env.HOME ?? os.homedir();
142
+ if (platform === "darwin") {
143
+ const toolchain = hqAppToolchainBin("claude", home);
144
+ if (executableAt(toolchain))
145
+ return toolchain;
146
+ }
147
+ for (const dir of systemBinDirs) {
148
+ const candidate = path.join(dir, "claude");
149
+ if (executableAt(candidate))
150
+ return candidate;
151
+ }
110
152
  if (onPath("claude", env))
111
153
  return "claude";
112
- return bundledClaudeBin(homeDir ?? env.HOME ?? os.homedir()) ?? "claude";
154
+ return bundledClaudeBin(home) ?? "claude";
113
155
  }
114
- export function createClaudeRuntime(env = process.env) {
156
+ export function createClaudeRuntime(env = process.env, opts = {}) {
115
157
  return {
116
158
  id: "claude",
117
159
  binary: "claude",
118
- resolveBinary: (turnEnv, cwd, platform) => resolveClaudeBinary(turnEnv, undefined, platform ?? process.platform, cwd),
160
+ resolveBinary: (turnEnv, cwd, platform) => resolveClaudeBinary(turnEnv, undefined, platform ?? process.platform, cwd, opts.systemBinDirs ?? CLAUDE_SYSTEM_BIN_DIRS),
119
161
  buildArgs(input, scratch) {
120
162
  const usesWindowsStdin = input.platform === "win32";
121
163
  const args = [
@@ -142,6 +184,9 @@ export function createClaudeRuntime(env = process.env) {
142
184
  }
143
185
  if (input.model)
144
186
  args.push("--model", input.model);
187
+ if (input.leanSettingsFile) {
188
+ args.push("--strict-mcp-config", "--setting-sources", "", "--settings", input.leanSettingsFile);
189
+ }
145
190
  if (input.sessionId)
146
191
  args.push("--resume", input.sessionId);
147
192
  else
@@ -33,6 +33,11 @@ export interface RuntimeTurnInput {
33
33
  sessionId?: string;
34
34
  /** Optional model override. */
35
35
  model?: string;
36
+ /**
37
+ * Claude only: a lean turn. The settings file holds HQ's tool hooks; the turn
38
+ * loads no other settings, plugins or MCP servers (BotConfig.lean).
39
+ */
40
+ leanSettingsFile?: string;
36
41
  /** Thinking level for the CLI (runtimes fall back to medium). */
37
42
  effort?: string;
38
43
  /**
@@ -89,7 +89,7 @@ export interface RuntimeRepairNotice {
89
89
  cls: RuntimeFailureClass;
90
90
  /** The plain DM body. */
91
91
  text: string;
92
- /** Absent for `not-installed` and `unknown`: those get the plain text only. */
92
+ /** Absent for `not-installed` and `unknown`: those get the plain text (plus a Retry suggestion in a DM). */
93
93
  richContent?: RuntimeRichContent;
94
94
  }
95
95
  export declare function runtimeRepairBlock(args: {
@@ -99,7 +99,23 @@ export declare function runtimeRepairBlock(args: {
99
99
  retryOf?: string;
100
100
  suggestedModel?: string;
101
101
  }): RuntimeRepairBlock;
102
- /** The plain body plus, for a repairable class, the repair block. */
102
+ /** The tap-to-answer button under a failure that carries no repair card. */
103
+ export declare const RETRY_SUGGESTION = "Retry";
104
+ /**
105
+ * The `hq-block` suggestions envelope the desktop draws as buttons (the same
106
+ * one the setup bot uses for its questions). Tapping sends the text back as
107
+ * the person's next message, so a failed turn is never a dead end.
108
+ */
109
+ export declare function suggestionsBlock(items: string[]): string;
110
+ /** `text` followed by a one-button Retry envelope. */
111
+ export declare function withRetrySuggestion(text: string): string;
112
+ /**
113
+ * The plain body plus, for a repairable class, the repair block. Classes
114
+ * without a card (`not-installed`, `unknown`) get the plain text; the DM post
115
+ * path adds a Retry suggestion under it (see run.ts postRepair): on 2026-10-11
116
+ * a fresh bot whose Claude Code was missing left the person with nothing to
117
+ * press.
118
+ */
103
119
  export declare function runtimeRepairNotice(args: {
104
120
  cls: RuntimeFailureClass;
105
121
  runtime: BotRuntimeId | string;
@@ -155,7 +155,27 @@ export function runtimeRepairBlock(args) {
155
155
  ...(args.suggestedModel ? { suggestedModel: args.suggestedModel.slice(0, 128) } : {}),
156
156
  };
157
157
  }
158
- /** The plain body plus, for a repairable class, the repair block. */
158
+ /** The tap-to-answer button under a failure that carries no repair card. */
159
+ export const RETRY_SUGGESTION = "Retry";
160
+ /**
161
+ * The `hq-block` suggestions envelope the desktop draws as buttons (the same
162
+ * one the setup bot uses for its questions). Tapping sends the text back as
163
+ * the person's next message, so a failed turn is never a dead end.
164
+ */
165
+ export function suggestionsBlock(items) {
166
+ return "```hq-block\n" + JSON.stringify({ v: 1, blocks: [{ kind: "suggestions", items }] }) + "\n```";
167
+ }
168
+ /** `text` followed by a one-button Retry envelope. */
169
+ export function withRetrySuggestion(text) {
170
+ return `${text}\n\n${suggestionsBlock([RETRY_SUGGESTION])}`;
171
+ }
172
+ /**
173
+ * The plain body plus, for a repairable class, the repair block. Classes
174
+ * without a card (`not-installed`, `unknown`) get the plain text; the DM post
175
+ * path adds a Retry suggestion under it (see run.ts postRepair): on 2026-10-11
176
+ * a fresh bot whose Claude Code was missing left the person with nothing to
177
+ * press.
178
+ */
159
179
  export function runtimeRepairNotice(args) {
160
180
  const text = args.text ?? runtimeFailureText(args.cls, args.runtime, args.context);
161
181
  if (!isRuntimeRepairClass(args.cls))
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The HQ desktop app's managed toolchain.
3
+ *
4
+ * The app installs node, hq-cli and @anthropic-ai/claude-code under one
5
+ * prefix so a clean Mac can run bots without the person installing anything:
6
+ *
7
+ * ~/Library/Application Support/Indigo HQ/toolchain/
8
+ * node/bin/node
9
+ * npm-global/bin/hq
10
+ * npm-global/bin/claude
11
+ *
12
+ * The bot daemon's PATH, the hq-cli update watcher and the Claude Code
13
+ * resolver all key on the same root, so it lives here once.
14
+ */
15
+ /** Path segments under the home directory. macOS only; other platforms have no app toolchain. */
16
+ export declare const HQ_APP_TOOLCHAIN_SEGMENTS: readonly ["Library", "Application Support", "Indigo HQ", "toolchain"];
17
+ /** The npm prefix the app installs CLIs into, relative to the toolchain root. */
18
+ export declare const HQ_APP_TOOLCHAIN_NPM_GLOBAL_BIN: string;
19
+ /** `~/Library/Application Support/Indigo HQ/toolchain`. */
20
+ export declare function hqAppToolchainRoot(homeDir?: string): string;
21
+ /** `<toolchain>/npm-global/bin`, where `hq` and `claude` live. */
22
+ export declare function hqAppToolchainBinDir(homeDir?: string): string;
23
+ /** `<toolchain>/npm-global/bin/<name>`: where the app puts a CLI it manages. */
24
+ export declare function hqAppToolchainBin(name: string, homeDir?: string): string;
25
+ //# sourceMappingURL=toolchain.d.ts.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The HQ desktop app's managed toolchain.
3
+ *
4
+ * The app installs node, hq-cli and @anthropic-ai/claude-code under one
5
+ * prefix so a clean Mac can run bots without the person installing anything:
6
+ *
7
+ * ~/Library/Application Support/Indigo HQ/toolchain/
8
+ * node/bin/node
9
+ * npm-global/bin/hq
10
+ * npm-global/bin/claude
11
+ *
12
+ * The bot daemon's PATH, the hq-cli update watcher and the Claude Code
13
+ * resolver all key on the same root, so it lives here once.
14
+ */
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ /** Path segments under the home directory. macOS only; other platforms have no app toolchain. */
18
+ export const HQ_APP_TOOLCHAIN_SEGMENTS = ["Library", "Application Support", "Indigo HQ", "toolchain"];
19
+ /** The npm prefix the app installs CLIs into, relative to the toolchain root. */
20
+ export const HQ_APP_TOOLCHAIN_NPM_GLOBAL_BIN = path.join("npm-global", "bin");
21
+ /** `~/Library/Application Support/Indigo HQ/toolchain`. */
22
+ export function hqAppToolchainRoot(homeDir = os.homedir()) {
23
+ return path.join(homeDir, ...HQ_APP_TOOLCHAIN_SEGMENTS);
24
+ }
25
+ /** `<toolchain>/npm-global/bin`, where `hq` and `claude` live. */
26
+ export function hqAppToolchainBinDir(homeDir = os.homedir()) {
27
+ return path.join(hqAppToolchainRoot(homeDir), HQ_APP_TOOLCHAIN_NPM_GLOBAL_BIN);
28
+ }
29
+ /** `<toolchain>/npm-global/bin/<name>`: where the app puts a CLI it manages. */
30
+ export function hqAppToolchainBin(name, homeDir = os.homedir()) {
31
+ return path.join(hqAppToolchainBinDir(homeDir), name);
32
+ }
33
+ //# sourceMappingURL=toolchain.js.map
@@ -48,6 +48,7 @@ export interface WorkerSourceFs {
48
48
  }) => Array<{
49
49
  name: string;
50
50
  isDirectory: () => boolean;
51
+ isSymbolicLink?: () => boolean;
51
52
  }>;
52
53
  }
53
54
  /** Collapse whitespace and take the first sentence, capped at `max` chars. */