@indigoai-us/hq-cli 5.345.104 → 5.345.106

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 (83) hide show
  1. package/CHANGELOG.md +100 -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/logout.d.ts +6 -1
  15. package/dist/commands/logout.js +18 -2
  16. package/dist/commands/pack-install.d.ts +20 -1
  17. package/dist/commands/pack-install.js +80 -5
  18. package/dist/commands/pack-start-bot.d.ts +24 -0
  19. package/dist/commands/pack-start-bot.js +76 -0
  20. package/dist/commands/packs.d.ts +9 -0
  21. package/dist/commands/packs.js +26 -3
  22. package/dist/commands/pkg-install.js +11 -2
  23. package/dist/commands/secrets.d.ts +1 -5
  24. package/dist/commands/secrets.js +2 -92
  25. package/dist/fast-scaffold.d.ts +13 -0
  26. package/dist/fast-scaffold.js +136 -0
  27. package/dist/index.js +15 -1
  28. package/dist/lib/bot/config.d.ts +7 -0
  29. package/dist/lib/bot/daemon.js +2 -1
  30. package/dist/lib/bot/index.d.ts +2 -0
  31. package/dist/lib/bot/index.js +2 -0
  32. package/dist/lib/bot/lean-settings.d.ts +16 -0
  33. package/dist/lib/bot/lean-settings.js +34 -0
  34. package/dist/lib/bot/pack-link.d.ts +66 -0
  35. package/dist/lib/bot/pack-link.js +153 -0
  36. package/dist/lib/bot/pack-setup-driver.d.ts +37 -0
  37. package/dist/lib/bot/pack-setup-driver.js +83 -0
  38. package/dist/lib/bot/pack-setup.d.ts +62 -0
  39. package/dist/lib/bot/pack-setup.js +103 -0
  40. package/dist/lib/bot/prompt.d.ts +8 -0
  41. package/dist/lib/bot/prompt.js +5 -0
  42. package/dist/lib/bot/run.d.ts +18 -2
  43. package/dist/lib/bot/run.js +163 -25
  44. package/dist/lib/bot/runtime/claude.d.ts +27 -2
  45. package/dist/lib/bot/runtime/claude.js +49 -4
  46. package/dist/lib/bot/runtime/types.d.ts +5 -0
  47. package/dist/lib/bot/runtime-failure.d.ts +18 -2
  48. package/dist/lib/bot/runtime-failure.js +21 -1
  49. package/dist/lib/bot/toolchain.d.ts +25 -0
  50. package/dist/lib/bot/toolchain.js +33 -0
  51. package/dist/lib/bot/worker-source.d.ts +1 -0
  52. package/dist/lib/bot/worker-source.js +7 -1
  53. package/dist/lib/daemon/catalog.d.ts +15 -0
  54. package/dist/lib/daemon/catalog.js +49 -0
  55. package/dist/lib/daemon/paths.d.ts +1 -0
  56. package/dist/lib/daemon/paths.js +1 -0
  57. package/dist/lib/daemon/remote-control-forwarder.d.ts +14 -0
  58. package/dist/lib/daemon/remote-control-forwarder.js +104 -0
  59. package/dist/lib/daemon/remote-control-pools.d.ts +44 -0
  60. package/dist/lib/daemon/remote-control-pools.js +245 -0
  61. package/dist/lib/daemon/remote-control.d.ts +4 -0
  62. package/dist/lib/daemon/remote-control.js +17 -2
  63. package/dist/lib/daemon/run.js +126 -0
  64. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +5 -4
  65. package/dist/lib/doctor/fix/refresh-sync-progress.js +5 -4
  66. package/dist/lib/doctor/json-output.d.ts +5 -5
  67. package/dist/lib/doctor/json-output.js +2 -5
  68. package/dist/lib/packages/setup-block.d.ts +43 -0
  69. package/dist/lib/packages/setup-block.js +236 -0
  70. package/dist/lib/packages/setup-prompt.d.ts +34 -0
  71. package/dist/lib/packages/setup-prompt.js +128 -0
  72. package/dist/lib/packages/setup-takeover.d.ts +50 -0
  73. package/dist/lib/packages/setup-takeover.js +75 -0
  74. package/dist/lib/packages/start-bot.d.ts +47 -0
  75. package/dist/lib/packages/start-bot.js +66 -0
  76. package/dist/lib/session-company-lock.d.ts +7 -0
  77. package/dist/lib/session-company-lock.js +97 -0
  78. package/dist/types.d.ts +82 -0
  79. package/dist/utils/cognito-config.d.ts +5 -0
  80. package/dist/utils/cognito-config.js +14 -0
  81. package/dist/utils/cognito-revoke.d.ts +33 -0
  82. package/dist/utils/cognito-revoke.js +42 -0
  83. package/package.json +1 -1
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A bot whose worker belongs to an installed pack with a `setup:` block gets
3
+ * an auto-generated "Pack setup" section in its system prompt, and, when the
4
+ * pack asks for it and HQ's own setup is unfinished, a takeover section with
5
+ * HQ's setup worker skills (build contract section 3).
6
+ *
7
+ * scan-packages links a pack worker at `core/workers/public/<id>` ->
8
+ * `core/packages/<pack>/workers/<id>`, so the pack is found by resolving the
9
+ * worker folder's real path and checking whether it sits under
10
+ * `<hqRoot>/core/packages/<pack>/`.
11
+ */
12
+ import type { PackSetup } from "../../types.js";
13
+ import { type WorkerSkillSource } from "./prompt.js";
14
+ export interface PackSetupResolveFs {
15
+ realpathSync: (p: string) => string;
16
+ existsSync: (p: string) => boolean;
17
+ }
18
+ /** The installed pack (name + root) a worker folder belongs to, or null. */
19
+ export declare function findPackForWorker(hqRoot: string, workerDir: string, io?: PackSetupResolveFs): {
20
+ packName: string;
21
+ packRoot: string;
22
+ } | null;
23
+ export interface PackSetupPromptSections {
24
+ packName: string;
25
+ /** Absolute installed pack root. */
26
+ packRoot: string;
27
+ /** The validated `setup:` block. */
28
+ setup: PackSetup;
29
+ /** "## Finish HQ setup first", present only when the bot takes over HQ setup. */
30
+ takeover?: string;
31
+ /** "## Pack setup (...)". */
32
+ packSetup: string;
33
+ }
34
+ export interface ResolvePackSetupPromptInput {
35
+ hqRoot: string;
36
+ /** hqRoot-relative worker folder (bot.json `workerDir`). */
37
+ workerDir: string;
38
+ /** Only worker-sourced bots can belong to a pack. */
39
+ workerSource?: "scaffold" | "worker";
40
+ /** Seams for tests. */
41
+ io?: PackSetupResolveFs;
42
+ readProgressNote?: () => {
43
+ path: string | null;
44
+ note: string | null;
45
+ };
46
+ setupWorkerSkills?: (hqRoot: string) => {
47
+ workerDir: string;
48
+ skills: WorkerSkillSource[];
49
+ } | null;
50
+ log?: (level: "info" | "warn", message: string) => void;
51
+ }
52
+ /** HQ's setup worker folder and skills, resolved like `hq bot create --worker setup`. */
53
+ export declare function loadSetupWorkerSkills(hqRoot: string): {
54
+ workerDir: string;
55
+ skills: WorkerSkillSource[];
56
+ } | null;
57
+ /**
58
+ * The prompt sections for a pack bot, or null when the bot's worker is not a
59
+ * pack worker or its pack has no valid `setup:` block.
60
+ */
61
+ export declare function resolvePackSetupPrompt(input: ResolvePackSetupPromptInput): PackSetupPromptSections | null;
62
+ //# sourceMappingURL=pack-setup.d.ts.map
@@ -0,0 +1,103 @@
1
+ /**
2
+ * A bot whose worker belongs to an installed pack with a `setup:` block gets
3
+ * an auto-generated "Pack setup" section in its system prompt, and, when the
4
+ * pack asks for it and HQ's own setup is unfinished, a takeover section with
5
+ * HQ's setup worker skills (build contract section 3).
6
+ *
7
+ * scan-packages links a pack worker at `core/workers/public/<id>` ->
8
+ * `core/packages/<pack>/workers/<id>`, so the pack is found by resolving the
9
+ * worker folder's real path and checking whether it sits under
10
+ * `<hqRoot>/core/packages/<pack>/`.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { readInstalledPackSetup } from "../packages/setup-block.js";
15
+ import { renderPackSetupSection } from "../packages/setup-prompt.js";
16
+ import { decideTakeover, readHqSetupProgressNote, renderTakeoverSection } from "../packages/setup-takeover.js";
17
+ import { readPackManifest } from "../../utils/pack-contributions.js";
18
+ import { readPromptSources } from "./prompt.js";
19
+ import { resolveBotWorker } from "./worker-source.js";
20
+ import { SETUP_BOT_WORKER_ID } from "./config.js";
21
+ const defaultFs = {
22
+ realpathSync: (p) => fs.realpathSync(p),
23
+ existsSync: (p) => fs.existsSync(p),
24
+ };
25
+ /** The installed pack (name + root) a worker folder belongs to, or null. */
26
+ export function findPackForWorker(hqRoot, workerDir, io = defaultFs) {
27
+ let packagesRoot;
28
+ let workerReal;
29
+ try {
30
+ packagesRoot = io.realpathSync(path.join(hqRoot, "core", "packages"));
31
+ workerReal = io.realpathSync(path.isAbsolute(workerDir) ? workerDir : path.join(hqRoot, workerDir));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ const rel = path.relative(packagesRoot, workerReal);
37
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
38
+ return null;
39
+ const packName = rel.split(path.sep)[0];
40
+ if (!packName || packName.startsWith("."))
41
+ return null;
42
+ return { packName, packRoot: path.join(packagesRoot, packName) };
43
+ }
44
+ /** HQ's setup worker folder and skills, resolved like `hq bot create --worker setup`. */
45
+ export function loadSetupWorkerSkills(hqRoot) {
46
+ try {
47
+ const worker = resolveBotWorker(hqRoot, SETUP_BOT_WORKER_ID);
48
+ const sources = readPromptSources(hqRoot, worker.path, undefined, { workerSource: "worker" });
49
+ return { workerDir: worker.path, skills: sources.worker?.skills ?? [] };
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /**
56
+ * The prompt sections for a pack bot, or null when the bot's worker is not a
57
+ * pack worker or its pack has no valid `setup:` block.
58
+ */
59
+ export function resolvePackSetupPrompt(input) {
60
+ if (input.workerSource !== "worker")
61
+ return null;
62
+ const log = input.log ?? (() => { });
63
+ const found = findPackForWorker(input.hqRoot, input.workerDir, input.io);
64
+ if (!found)
65
+ return null;
66
+ const { manifest, error } = readPackManifest(found.packRoot);
67
+ if (!manifest) {
68
+ log("warn", `worker ${input.workerDir} belongs to pack ${found.packName} but its package.yaml is unreadable (${error ?? "unknown"}); no pack setup section`);
69
+ return null;
70
+ }
71
+ if (manifest.setup === undefined)
72
+ return null;
73
+ const setup = readInstalledPackSetup(manifest, found.packRoot, input.io?.existsSync);
74
+ if (!setup) {
75
+ log("warn", `pack ${found.packName} has a setup: block that does not validate; no pack setup section`);
76
+ return null;
77
+ }
78
+ const promptInput = { packName: found.packName, packRoot: found.packRoot, setup };
79
+ const sections = {
80
+ packName: found.packName,
81
+ packRoot: found.packRoot,
82
+ setup,
83
+ packSetup: renderPackSetupSection(promptInput),
84
+ };
85
+ if (setup.takeover_setup) {
86
+ const progress = (input.readProgressNote ?? readHqSetupProgressNote)();
87
+ if (decideTakeover({ takeoverSetup: true, progressNote: progress.note })) {
88
+ const setupWorker = (input.setupWorkerSkills ?? loadSetupWorkerSkills)(input.hqRoot);
89
+ sections.takeover = renderTakeoverSection({
90
+ packName: found.packName,
91
+ setupWorkerDir: setupWorker?.workerDir ?? path.join("core", "workers", SETUP_BOT_WORKER_ID),
92
+ skills: setupWorker?.skills ?? [],
93
+ progressNotePath: progress.path,
94
+ });
95
+ log("info", `pack ${found.packName}: HQ setup is not finished; this bot finishes it before the pack setup`);
96
+ }
97
+ else {
98
+ log("info", `pack ${found.packName}: HQ setup is finished; going straight to the pack setup`);
99
+ }
100
+ }
101
+ return sections;
102
+ }
103
+ //# sourceMappingURL=pack-setup.js.map
@@ -93,6 +93,14 @@ export interface BuildSystemPromptInput {
93
93
  kind?: BotKind;
94
94
  /** Company bots: the slugs of the companies the bot is a member of. */
95
95
  companies?: string[];
96
+ /**
97
+ * Pack bot: the auto-generated "Pack setup" section (and the HQ setup
98
+ * takeover section when it applies), rendered by lib/bot/pack-setup.ts.
99
+ */
100
+ packSetup?: {
101
+ takeover?: string;
102
+ packSetup: string;
103
+ } | null;
96
104
  /** hqRoot-relative (default `${workerDir}/memory`) or absolute. */
97
105
  memoryDir?: string;
98
106
  }
@@ -373,6 +373,11 @@ export function buildSystemPrompt(input) {
373
373
  parts.push(`## Persona (${path.join(input.workerDir, "persona.md")})\n\n${input.sources.persona.trim()}`);
374
374
  }
375
375
  }
376
+ if (input.packSetup) {
377
+ if (input.packSetup.takeover)
378
+ parts.push(input.packSetup.takeover);
379
+ parts.push(input.packSetup.packSetup);
380
+ }
376
381
  if (input.sources.memory.length > 0) {
377
382
  const blocks = input.sources.memory.map((m) => `### ${m.name}\n\n${m.body.trim()}`);
378
383
  parts.push(`## Memory (${memoryDir}/)\n\n${blocks.join("\n\n")}`);
@@ -201,13 +201,25 @@ export declare const KICKOFF_MESSAGE_ID = "kickoff";
201
201
  export declare function turnEnv(base: NodeJS.ProcessEnv, kind: BotKind, agentUid?: string, companySlug?: string): NodeJS.ProcessEnv;
202
202
  /** The core worker HQ's setup bot runs. */
203
203
  export declare const SETUP_WORKER_ID = "setup";
204
+ /**
205
+ * The executable a bot's turns will spawn, for the start-up log: an absolute
206
+ * path when the runtime found one (the HQ app toolchain, a system prefix, the
207
+ * Claude desktop app), the bare name when it relies on PATH, or "not found"
208
+ * when the bare name is on no PATH entry either.
209
+ */
210
+ export declare function describeRuntimeBinary(runtime: BotRuntime, env: NodeJS.ProcessEnv, cwd: string): string;
204
211
  /** The one message a person gets when a turn on their message did not finish. */
205
212
  export declare function didNotFinishText(reason: string, progressPosted: boolean): string;
206
213
  export { containsNoReplySentinel };
207
214
  /** Grok emits is_error with this message when the model produces only reasoning tokens. */
208
215
  export declare function isReasoningOnlyError(excerpt: string): boolean;
209
- /** The one note a person gets when the bot pauses after repeated failures. */
210
- export declare function holdNoticeText(holdMs: number, messagePending: boolean): string;
216
+ /**
217
+ * The one note a person gets when the bot pauses after repeated failures. With
218
+ * `reason` (the plain failure text of the last turn) it is the summary that
219
+ * replaces the fifth per-turn line: one message for the run of failures, with
220
+ * what went wrong and the one next step.
221
+ */
222
+ export declare function holdNoticeText(holdMs: number, messagePending: boolean, reason?: string): string;
211
223
  /** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
212
224
  export declare const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
213
225
  export declare const RESTARTED_REASON = "I was restarted while I was working on it";
@@ -229,4 +241,8 @@ export interface BotHandle {
229
241
  export declare function inboxLaneKey(item: InboxItem, config: Pick<BotConfig, "ownerUid" | "agentUid" | "name">): string;
230
242
  export declare function preflightCreds(dir: string, config: BotConfig): string | null;
231
243
  export declare function runBot(deps: BotRunDeps): Promise<BotHandle>;
244
+ /** True for a setup note the desktop app sent, not words the person typed. */
245
+ export declare function isAppSetupNote(text: string): boolean;
246
+ /** Put ahead of an app setup note: fold it in, and stay quiet unless it changes what to say. */
247
+ export declare const APP_SETUP_NOTE_TURN_NOTE: string;
232
248
  //# sourceMappingURL=run.d.ts.map
@@ -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