@indigoai-us/hq-cli 5.345.191 → 5.345.193

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 (122) hide show
  1. package/CHANGELOG.md +398 -0
  2. package/assets/bot-workers/setup/worker.yaml +91 -34
  3. package/dist/command-catalog.generated.d.ts +96 -19
  4. package/dist/command-catalog.generated.js +124 -21
  5. package/dist/commands/bot-jobs.d.ts +21 -18
  6. package/dist/commands/bot-jobs.js +127 -39
  7. package/dist/commands/bot.d.ts +4 -3
  8. package/dist/commands/bot.js +265 -140
  9. package/dist/commands/core-jobs-schedule.js +10 -0
  10. package/dist/commands/daemon.js +37 -0
  11. package/dist/commands/files.d.ts +41 -6
  12. package/dist/commands/files.js +108 -27
  13. package/dist/commands/pack-start-bot.d.ts +4 -6
  14. package/dist/commands/pack-start-bot.js +4 -8
  15. package/dist/commands/signals-run.d.ts +45 -0
  16. package/dist/commands/signals-run.js +175 -0
  17. package/dist/commands/signals.d.ts +1 -0
  18. package/dist/commands/signals.js +3 -0
  19. package/dist/commands/sources.d.ts +11 -0
  20. package/dist/commands/sources.js +137 -25
  21. package/dist/commands/staff/reset-test-account.d.ts +10 -0
  22. package/dist/commands/staff/reset-test-account.js +18 -2
  23. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +31171 -52512
  24. package/dist/hq-cloud-sync-runner/package.json +1 -1
  25. package/dist/lib/bot/api.d.ts +3 -2
  26. package/dist/lib/bot/config.d.ts +2 -0
  27. package/dist/lib/bot/fleet-cron.d.ts +121 -0
  28. package/dist/lib/bot/fleet-cron.js +319 -0
  29. package/dist/lib/bot/fleet-local.d.ts +93 -1
  30. package/dist/lib/bot/fleet-local.js +546 -34
  31. package/dist/lib/bot/fleet-owner-companies.d.ts +12 -0
  32. package/dist/lib/bot/fleet-owner-companies.js +30 -0
  33. package/dist/lib/bot/fleet-watch.d.ts +25 -0
  34. package/dist/lib/bot/fleet-watch.js +51 -0
  35. package/dist/lib/bot/index.d.ts +1 -1
  36. package/dist/lib/bot/index.js +1 -1
  37. package/dist/lib/bot/job-run.d.ts +5 -4
  38. package/dist/lib/bot/job-run.js +5 -4
  39. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -3
  40. package/dist/lib/bot/jobs/hot-signal-sync.js +163 -4
  41. package/dist/lib/bot/local-jobs.d.ts +13 -2
  42. package/dist/lib/bot/local-jobs.js +17 -4
  43. package/dist/lib/bot/progress.d.ts +14 -0
  44. package/dist/lib/bot/progress.js +47 -1
  45. package/dist/lib/bot/run.d.ts +55 -2
  46. package/dist/lib/bot/run.js +192 -10
  47. package/dist/lib/bot/setup-tool-offer.d.ts +1 -0
  48. package/dist/lib/bot/setup-tool-offer.js +16 -0
  49. package/dist/lib/bot/signals-engine-flag.d.ts +18 -0
  50. package/dist/lib/bot/signals-engine-flag.js +30 -0
  51. package/dist/lib/daemon/catalog.d.ts +4 -0
  52. package/dist/lib/daemon/catalog.js +29 -9
  53. package/dist/lib/daemon/legacy.d.ts +6 -0
  54. package/dist/lib/daemon/legacy.js +16 -10
  55. package/dist/lib/daemon/run.d.ts +1 -0
  56. package/dist/lib/daemon/run.js +10 -0
  57. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  58. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  59. package/dist/lib/flag-registry.d.ts +13 -1
  60. package/dist/lib/flag-registry.js +46 -0
  61. package/dist/lib/hot-channels/adapters/attio.d.ts +75 -2
  62. package/dist/lib/hot-channels/adapters/attio.js +367 -29
  63. package/dist/lib/hot-channels/adapters/gmail.d.ts +14 -0
  64. package/dist/lib/hot-channels/adapters/gmail.js +59 -2
  65. package/dist/lib/hot-channels/adapters/linear.d.ts +24 -0
  66. package/dist/lib/hot-channels/adapters/linear.js +71 -1
  67. package/dist/lib/hot-channels/adapters/meetings.js +4 -1
  68. package/dist/lib/hot-channels/adapters/slack.js +7 -3
  69. package/dist/lib/hot-channels/alert.js +1 -1
  70. package/dist/lib/hot-channels/contract.d.ts +11 -0
  71. package/dist/lib/hot-channels/contract.js +1 -0
  72. package/dist/lib/hot-channels/cursors.js +1 -1
  73. package/dist/lib/hot-channels/landing.d.ts +6 -2
  74. package/dist/lib/hot-channels/landing.js +13 -6
  75. package/dist/lib/hot-channels/ledger.js +2 -0
  76. package/dist/lib/hot-channels/loop.js +18 -4
  77. package/dist/lib/hot-channels/registry.js +9 -1
  78. package/dist/lib/hot-channels/run.d.ts +18 -0
  79. package/dist/lib/hot-channels/run.js +15 -4
  80. package/dist/lib/hot-channels/signals-pass.d.ts +1 -0
  81. package/dist/lib/hot-channels/signals-pass.js +7 -5
  82. package/dist/lib/hot-channels/sync-lock.js +22 -6
  83. package/dist/lib/mesh/live/daemon/install.d.ts +22 -0
  84. package/dist/lib/mesh/live/daemon/install.js +117 -30
  85. package/dist/lib/signals/adapters-catalog.d.ts +77 -0
  86. package/dist/lib/signals/adapters-catalog.js +194 -0
  87. package/dist/lib/signals/engine.d.ts +196 -0
  88. package/dist/lib/signals/engine.js +495 -0
  89. package/dist/lib/signals/failure-alert.d.ts +89 -0
  90. package/dist/lib/signals/failure-alert.js +175 -0
  91. package/dist/lib/signals/local-date.d.ts +3 -0
  92. package/dist/lib/signals/local-date.js +6 -0
  93. package/dist/lib/signals/projection.d.ts +42 -0
  94. package/dist/lib/signals/projection.js +67 -0
  95. package/dist/lib/signals/reply.d.ts +112 -0
  96. package/dist/lib/signals/reply.js +368 -0
  97. package/dist/lib/signals/run-lock.d.ts +36 -0
  98. package/dist/lib/signals/run-lock.js +148 -0
  99. package/dist/lib/signals/run-record.d.ts +24 -0
  100. package/dist/lib/signals/run-record.js +82 -0
  101. package/dist/lib/signals/sanitize.d.ts +105 -0
  102. package/dist/lib/signals/sanitize.js +439 -0
  103. package/dist/lib/signals/settings.d.ts +10 -0
  104. package/dist/lib/signals/settings.js +48 -0
  105. package/dist/lib/signals/steps/adapters.d.ts +14 -0
  106. package/dist/lib/signals/steps/adapters.js +156 -0
  107. package/dist/lib/signals/steps/entity-similarity.d.ts +10 -0
  108. package/dist/lib/signals/steps/entity-similarity.js +47 -0
  109. package/dist/lib/signals/steps/extract.d.ts +113 -0
  110. package/dist/lib/signals/steps/extract.js +765 -0
  111. package/dist/lib/signals/steps/garden.d.ts +24 -0
  112. package/dist/lib/signals/steps/garden.js +250 -0
  113. package/dist/lib/signals/steps/types.d.ts +112 -0
  114. package/dist/lib/signals/steps/types.js +4 -0
  115. package/dist/lib/signals/steps/upload.d.ts +43 -0
  116. package/dist/lib/signals/steps/upload.js +324 -0
  117. package/dist/lib/signals/write-guard.d.ts +63 -0
  118. package/dist/lib/signals/write-guard.js +287 -0
  119. package/dist/utils/resident-package-preload.js +16 -0
  120. package/package.json +9 -9
  121. package/dist/lib/bot/fleet-default-flag.d.ts +0 -31
  122. package/dist/lib/bot/fleet-default-flag.js +0 -127
@@ -37,6 +37,25 @@ export const PRE_TOOL_ASIDE_MAX_CHARS = 40;
37
37
  * your agency docs now, then I'll look at the client folders.") and run past
38
38
  * the limit; an hq-block is always content the desktop app must draw.
39
39
  */
40
+ /**
41
+ * A heads-up the model writes right before a long piece of work ("Give me
42
+ * about two minutes to check what's on this computer, then I'll ask my
43
+ * first question."). A bot that drops every pre-tool message
44
+ * (`dropPreToolMessages`) still lets ONE of these through per turn, so the
45
+ * person hears what is happening and how long it takes instead of watching
46
+ * a spinner. Short, one or two sentences, names a wait, no hq-block.
47
+ */
48
+ export const WAIT_HEADS_UP_MAX_CHARS = 240;
49
+ export function isWaitHeadsUp(text) {
50
+ const t = text.trim();
51
+ // A question is not a heads-up: it would be held as an in-between question
52
+ // and dropped when the turn ends with a final answer.
53
+ if (!t || t.length > WAIT_HEADS_UP_MAX_CHARS || t.includes("```") || t.includes("?"))
54
+ return false;
55
+ // It has to name a wait. "I'll check the manifest first." is narration
56
+ // (the setup worker forbids it); "Give me about two minutes" is not.
57
+ return /\b(?:minutes?|seconds?|a moment|one moment|right back|be back)\b/i.test(t);
58
+ }
40
59
  export function isPreToolAside(text) {
41
60
  const t = text.trim();
42
61
  return t.length < PRE_TOOL_ASIDE_MAX_CHARS && !t.includes("```hq-block");
@@ -61,6 +80,8 @@ export class ProgressPoster {
61
80
  workingNoticeText;
62
81
  buffer;
63
82
  dropPreTool;
83
+ /** With dropPreToolMessages: one wait heads-up (isWaitHeadsUp) per turn is kept. */
84
+ headsUpKept = false;
64
85
  onDropped;
65
86
  /**
66
87
  * Messages waiting to learn whether a tool call follows: every message when
@@ -124,6 +145,18 @@ export class ProgressPoster {
124
145
  this.enqueueWith(this.workingNoticeText, this.sendProgress);
125
146
  }, this.workingNoticeMs);
126
147
  }
148
+ /** Arm the still-working line again after a kept heads-up (start() skips a turn that said something). */
149
+ rearmNoticeAfter(saidCount) {
150
+ if (this.buffer || this.finished || this.noticeTimer !== null || this.workingNoticeMs <= 0)
151
+ return;
152
+ this.noticeTimer = this.setTimer(() => {
153
+ this.noticeTimer = null;
154
+ if (this.finished || this.noticeSent || this.said.length !== saidCount)
155
+ return;
156
+ this.noticeSent = true;
157
+ this.enqueueWith(this.workingNoticeText, this.sendProgress);
158
+ }, this.workingNoticeMs);
159
+ }
127
160
  /** One finished assistant message from the model. */
128
161
  message(text) {
129
162
  const t = text.trim();
@@ -145,8 +178,21 @@ export class ProgressPoster {
145
178
  tool() {
146
179
  if (this.finished)
147
180
  return;
148
- const dropped = this.candidates;
181
+ let dropped = this.candidates;
149
182
  this.candidates = [];
183
+ // The last thing said before the tool call, when it tells the person
184
+ // what is about to happen and how long, is for them: post it.
185
+ if (this.dropPreTool && !this.headsUpKept) {
186
+ const last = dropped.at(-1);
187
+ if (last !== undefined && isWaitHeadsUp(last)) {
188
+ this.headsUpKept = true;
189
+ dropped = dropped.slice(0, -1);
190
+ this.accept(last);
191
+ // The heads-up named a wait; if the work then outlives that silence
192
+ // budget with nothing more said, the still-working line goes out too.
193
+ this.rearmNoticeAfter(this.said.length);
194
+ }
195
+ }
150
196
  for (const t of dropped) {
151
197
  try {
152
198
  this.onDropped(t);
@@ -15,8 +15,9 @@
15
15
  * - first start only: the intro DM, then (when bot.json has `kickoff`) ONE
16
16
  * model turn on that prompt as if the owner sent it, answered in the DM, so
17
17
  * a guided bot starts working without waiting for the owner to type
18
- * - job items (channel "job"): a run the cloud fired on an EventBridge
19
- * schedule. Nothing is scheduled on this computer. The run is one model turn
18
+ * - job items (channel "job"): a scheduled run, fired by the bot's own loop
19
+ * from its jobs.json (lib/bot/local-jobs.ts) or, for a hosted agent, by
20
+ * the cloud. No system scheduler is used on this computer. The run is one model turn
20
21
  * in its own session (one per jobId), reported in the owner's DM with a
21
22
  * "scheduled job" header through the same split/recovery path as every other
22
23
  * reply, and reported back to the cloud EXACTLY ONCE — nothing else clears
@@ -152,8 +153,20 @@ export interface BotRunDeps {
152
153
  company: string;
153
154
  catchUp: boolean;
154
155
  now: () => Date;
156
+ /** When the run was due (the fired item's `firedAt`); the catch-up reply names it. */
157
+ scheduledFor?: string;
158
+ /** The job's zone, for the times in the reply. */
159
+ timeZone?: string;
155
160
  /** The signals-failure alert to the owner, sent even when the job is quiet (US-012). */
156
161
  sendAlert: (text: string) => Promise<void>;
162
+ /** This run is the bot's automatic retry of a failed run. */
163
+ retry?: boolean;
164
+ /**
165
+ * Schedules one automatic retry of this run at `at` (the run record's
166
+ * nextRetryAt), in this process. Returns true when it is scheduled. Only
167
+ * offered for a local job's scheduled run, never for a retry.
168
+ */
169
+ scheduleRetry?: (at: string) => boolean;
157
170
  }) => Promise<{
158
171
  ok: boolean;
159
172
  text: string;
@@ -315,6 +328,46 @@ export declare function preflightCreds(dir: string, config: BotConfig): string |
315
328
  export declare function runBot(deps: BotRunDeps): Promise<BotHandle>;
316
329
  /** True for a setup note the desktop app sent, not words the person typed. */
317
330
  export declare function isAppSetupNote(text: string): boolean;
331
+ /**
332
+ * True for the ONE note the desktop app sends when its visual first run
333
+ * closes: the handoff that names the assistant (`name` in `done`). Older
334
+ * builds sent partial notes (an import, a company) with no `name`; those
335
+ * fold in quietly (`APP_SETUP_NOTE_TURN_NOTE`).
336
+ */
337
+ export declare function isFirstRunHandoffNote(text: string): boolean;
338
+ /**
339
+ * Put ahead of the end-of-setup handoff note: it is the assistant's first
340
+ * turn, so it always answers, and in seconds: everything it needs is in the
341
+ * handoff, the Owner context block and the facts appended below it, so it
342
+ * runs no command and reads no file first. The shape of that first message
343
+ * is in the setup worker's instructions ("When the app hands you settled
344
+ * steps").
345
+ */
346
+ export declare const FIRST_RUN_HANDOFF_TURN_NOTE: string;
347
+ export interface FirstRunHandoffFacts {
348
+ /** Company folders under companies/ on this computer (slugs). */
349
+ companyFolders: string[];
350
+ /** The bot's setup-progress.md, or null when there is none yet. */
351
+ progressNote: string | null;
352
+ }
353
+ /**
354
+ * What the first-run turn would otherwise spend tool calls finding out: the
355
+ * company folders on this computer and the progress note. Read here, once,
356
+ * so the first reply needs no command. Never throws.
357
+ */
358
+ export declare function firstRunHandoffFacts(hqRoot: string, memoryDir: string): FirstRunHandoffFacts;
359
+ /** The whole first-run turn note: the fixed lead, then the facts. */
360
+ export declare function firstRunHandoffTurnNote(facts: FirstRunHandoffFacts): string;
361
+ /** The setup bot's "still working" line: plain words, no dash, nothing about mechanics. */
362
+ export declare const SETUP_WORKING_NOTICE_TEXT = "Still working on it. I'll post here as soon as I'm done.";
363
+ /**
364
+ * The setup bot's silence budget before that line. Half the general one:
365
+ * the person is new and watching. The bot is told to say what it is about
366
+ * to do and how long first (a wait heads-up, kept by the poster); the line
367
+ * goes out when it said nothing, or when the work outlives the heads-up by
368
+ * this much with nothing more said.
369
+ */
370
+ export declare const SETUP_WORKING_NOTICE_MS = 30000;
318
371
  /** Put ahead of an app setup note: fold it in, and stay quiet unless it changes what to say. */
319
372
  export declare const APP_SETUP_NOTE_TURN_NOTE: string;
320
373
  //# sourceMappingURL=run.d.ts.map
@@ -15,8 +15,9 @@
15
15
  * - first start only: the intro DM, then (when bot.json has `kickoff`) ONE
16
16
  * model turn on that prompt as if the owner sent it, answered in the DM, so
17
17
  * a guided bot starts working without waiting for the owner to type
18
- * - job items (channel "job"): a run the cloud fired on an EventBridge
19
- * schedule. Nothing is scheduled on this computer. The run is one model turn
18
+ * - job items (channel "job"): a scheduled run, fired by the bot's own loop
19
+ * from its jobs.json (lib/bot/local-jobs.ts) or, for a hosted agent, by
20
+ * the cloud. No system scheduler is used on this computer. The run is one model turn
20
21
  * in its own session (one per jobId), reported in the owner's DM with a
21
22
  * "scheduled job" header through the same split/recovery path as every other
22
23
  * reply, and reported back to the cloud EXACTLY ONCE — nothing else clears
@@ -84,6 +85,7 @@ import { resolveRoomMentionsForBot, roomPostHasMentionTokens, } from "./room-men
84
85
  import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
85
86
  import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
86
87
  import { currentHotSignalSyncCompany, hotSignalSyncJobCompany, readActiveCompanySlug, removeStaleHotSignalSyncJobs } from "./jobs/hot-signal-sync.js";
88
+ import { isLocalJobMessage, isLocalJobRetryMessage, localJobRetryMessageId, readLocalJobs } from "./local-jobs.js";
87
89
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
88
90
  import { SIGN_IN_CHECK_FRESH_MS, SIGN_IN_CHECK_MS, SIGN_IN_RETRY_MS, signInNeededText, signedOutExcerpt, } from "./runtime-sign-in.js";
89
91
  import { checkRuntimeSignIn } from "./runtime-sign-in-check.js";
@@ -1374,13 +1376,18 @@ export async function runBot(deps) {
1374
1376
  };
1375
1377
  const poster = new ProgressPoster({
1376
1378
  // The setup bot talks to someone new to HQ: its notes to itself never
1377
- // reach them, and it posts no "still working" notice either — the
1378
- // desktop app shows that the bot is working.
1379
+ // reach them.
1379
1380
  ...(isSetupBot ? { dropPreToolMessages: true } : {}),
1380
1381
  // Short asides before a tool call never reach the person either (isPreToolAside).
1381
1382
  onDropped: (text) => log("debug", `dropped a pre-tool aside for ${messageId} (${text.length} chars): ${text}`),
1383
+ // The setup bot talks to someone new to HQ, so its "still working" line
1384
+ // is the plain one (no dash, no mechanics) and comes sooner. It used to
1385
+ // post none at all because the desktop app shows the bot working; a
1386
+ // first run on 2026-11-02 sat on that spinner for over three minutes
1387
+ // with nothing said, so the line is back for it too.
1388
+ ...(isSetupBot ? { workingNoticeText: SETUP_WORKING_NOTICE_TEXT, workingNoticeMs: SETUP_WORKING_NOTICE_MS } : {}),
1382
1389
  ...deps.progress,
1383
- ...(args.workingNotice === false || silenceable || isSetupBot ? { workingNoticeMs: 0 } : {}),
1390
+ ...(args.workingNotice === false || silenceable ? { workingNoticeMs: 0 } : {}),
1384
1391
  ...(silenceable || args.suppressOutput ? { buffer: true } : {}),
1385
1392
  sendProgress: makeSendFn(args.sendProgress),
1386
1393
  sendToPerson: makeSendFn(args.sendToPerson),
@@ -1578,6 +1585,27 @@ export async function runBot(deps) {
1578
1585
  const handledJobFires = new Map();
1579
1586
  /** Runs an older fire of a job that a newer pending run supersedes. */
1580
1587
  const supersededJobRuns = new Set();
1588
+ /**
1589
+ * The automatic retry of a failed signals job run, per jobId: one synthetic
1590
+ * local run, due at the run record's nextRetryAt. Kept in memory only, so a
1591
+ * stop or restart drops it (the next scheduled fire, or "Try now", runs
1592
+ * instead). The inbox loop enqueues it once it is due.
1593
+ */
1594
+ const signalsRetries = new Map();
1595
+ const scheduleSignalsRetry = (item, jobId, at) => {
1596
+ const dueMs = Date.parse(at);
1597
+ if (!Number.isFinite(dueMs) || stopping || state !== "running")
1598
+ return false;
1599
+ const { requestId: _requestId, receivedAt: _receivedAt, ...rest } = item;
1600
+ signalsRetries.set(jobId, {
1601
+ ...rest,
1602
+ messageId: localJobRetryMessageId(jobId, dueMs),
1603
+ firedAt: new Date(dueMs).toISOString(),
1604
+ staleAfter: new Date(dueMs + 10 * 60_000).toISOString(),
1605
+ });
1606
+ log("info", `signals job ${jobId}: the run failed; one automatic retry at ${new Date(dueMs).toISOString()}`);
1607
+ return true;
1608
+ };
1581
1609
  /**
1582
1610
  * Report one fired run as finished. This is the ONLY thing that clears the
1583
1611
  * job's in-progress flag in the cloud, so a failure is logged loudly and the
@@ -1887,8 +1915,9 @@ export async function runBot(deps) {
1887
1915
  tReceived,
1888
1916
  sendProgress,
1889
1917
  sendToPerson,
1890
- // The intro already said the bot is starting, and the app shows it thinking.
1891
- workingNotice: false,
1918
+ // A kickoff turn that stays silent for a minute posts the "still
1919
+ // working" line like any other turn: the app's thinking row alone
1920
+ // left a first run looking stuck (2026-11-02).
1892
1921
  });
1893
1922
  await clearThinking();
1894
1923
  if (!outcome.ok && outcome.error instanceof CompanyBindingError) {
@@ -2107,8 +2136,23 @@ export async function runBot(deps) {
2107
2136
  log("info", `hot-signal-sync job ${jobId} run ${id} skipped: company ${syncCompany} no longer served (current: ${currentCompany ?? "none"})`);
2108
2137
  return;
2109
2138
  }
2139
+ // Times in the reply are in the job's own zone (jobs.json), not this machine's.
2140
+ const jobZone = readLocalJobs(dir).find((j) => j.jobId === jobId)?.timezone ?? timeZone;
2141
+ const isRetryRun = isLocalJobRetryMessage(id);
2110
2142
  const result = deps.hotSignalSync
2111
- ? await deps.hotSignalSync({ company: syncCompany, catchUp, now, sendAlert: (text) => sendToPerson(text) })
2143
+ ? await deps.hotSignalSync({
2144
+ company: syncCompany,
2145
+ catchUp,
2146
+ now,
2147
+ ...(job.firedAt ? { scheduledFor: job.firedAt } : {}),
2148
+ timeZone: jobZone,
2149
+ sendAlert: (text) => sendToPerson(text),
2150
+ ...(isRetryRun ? { retry: true } : {}),
2151
+ // One in-process retry per failed scheduled run of a local job.
2152
+ ...(isLocalJobMessage(id) && !isRetryRun
2153
+ ? { scheduleRetry: (at) => scheduleSignalsRetry(item, jobId, at) }
2154
+ : {}),
2155
+ })
2112
2156
  : { ok: false, text: `This bot cannot run the hot-channel sync for ${syncCompany}.` };
2113
2157
  markProcessed(dir, id, now().toISOString());
2114
2158
  const status = result.ok ? "succeeded" : "failed";
@@ -2542,8 +2586,13 @@ export async function runBot(deps) {
2542
2586
  // answering every note re-posted that question (three times in a row on
2543
2587
  // 2026-10-12). Such a turn may stay quiet.
2544
2588
  const appSetupNote = isSetupBot && ownerDm && isAppSetupNote(messageText);
2589
+ // The one note the app sends when its visual setup closes is different:
2590
+ // the assistant was created without a kickoff, so this note is its first
2591
+ // turn and the person is in the chat waiting for its first message. It
2592
+ // never stays quiet.
2593
+ const firstRunHandoff = appSetupNote && isFirstRunHandoffNote(messageText);
2545
2594
  const ownerTurnText = decisionTurn.note ? `${decisionTurn.note}\n\n${ownerText}` : ownerText;
2546
- const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? (appSetupNote ? `${APP_SETUP_NOTE_TURN_NOTE}\n\n${ownerTurnText}` : ownerTurnText) :
2595
+ const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? (firstRunHandoff ? `${firstRunHandoffTurnNote(firstRunHandoffFacts(config.hqRoot, resolveBotMemoryDir(config)))}\n\n${ownerTurnText}` : appSetupNote ? `${APP_SETUP_NOTE_TURN_NOTE}\n\n${ownerTurnText}` : ownerTurnText) :
2547
2596
  `[HQ DM sender: ${target.peerUid}; owner: ${config.ownerUid}; sender is not the owner.]\n\n${text}`;
2548
2597
  const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
2549
2598
  const { sendProgress, sendToPerson, saved } = trackingSend(markerTarget, item.rootEventId);
@@ -2559,7 +2608,7 @@ export async function runBot(deps) {
2559
2608
  tReceived,
2560
2609
  sendProgress,
2561
2610
  sendToPerson,
2562
- silenceable: (target.kind === "room" && !target.mentioned) || appSetupNote,
2611
+ silenceable: (target.kind === "room" && !target.mentioned) || (appSetupNote && !firstRunHandoff),
2563
2612
  ...(readDirs.length ? { readDirs } : {}),
2564
2613
  ...(target.kind === "dm" ? { historyEventId: item.eventId ?? id } : {}),
2565
2614
  ...(target.kind === "room"
@@ -2814,6 +2863,27 @@ export async function runBot(deps) {
2814
2863
  else
2815
2864
  lanes.set(lane, [item]);
2816
2865
  };
2866
+ /** Hands a due signals retry to its job lane, unless the job was removed meanwhile. */
2867
+ const enqueueDueSignalsRetries = () => {
2868
+ if (signalsRetries.size === 0)
2869
+ return;
2870
+ const t = now().getTime();
2871
+ let added = false;
2872
+ for (const [jobId, retryItem] of signalsRetries) {
2873
+ if (Date.parse(retryItem.firedAt ?? "") > t)
2874
+ continue;
2875
+ signalsRetries.delete(jobId);
2876
+ if (!readLocalJobs(dir).some((j) => j.jobId === jobId)) {
2877
+ log("info", `signals job ${jobId} was removed; its automatic retry is dropped`);
2878
+ continue;
2879
+ }
2880
+ log("info", `signals job ${jobId}: running the automatic retry`);
2881
+ enqueue(retryItem);
2882
+ added = true;
2883
+ }
2884
+ if (added)
2885
+ pump();
2886
+ };
2817
2887
  const inboxLoop = (async () => {
2818
2888
  // Turns cut off by the last restart are reported before anything new runs.
2819
2889
  for (const leftover of leftovers) {
@@ -2838,6 +2908,8 @@ export async function runBot(deps) {
2838
2908
  // the work), alongside the poll rather than in front of it. Also during
2839
2909
  // a hold: a report owed to the cloud has nothing to do with the model.
2840
2910
  kickCompletionRetries();
2911
+ // A failed signals run's automatic retry, once due (no model turn).
2912
+ enqueueDueSignalsRetries();
2841
2913
  // Holding after repeated model failures: no inbox pulls until the
2842
2914
  // cooldown is over (the update watcher above keeps running meanwhile).
2843
2915
  if (modelHoldUntil > now().getTime()) {
@@ -2942,6 +3014,8 @@ export async function runBot(deps) {
2942
3014
  // An owner stop or a promotion while an update restart drains wins: the
2943
3015
  // bot then exits 0 and stays down instead of being restarted.
2944
3016
  cliUpdatePending = null;
3017
+ // A pending signals retry is in-process only: a stop cancels it.
3018
+ signalsRetries.clear();
2945
3019
  if (stopping) {
2946
3020
  // Already stopping (an earlier stop, a startup signal, or an update
2947
3021
  // restart): still wait for the loops and finish, so this call ends the
@@ -2993,6 +3067,114 @@ const APP_SETUP_NOTE_PREFIX = "Setup note from the HQ desktop app:";
2993
3067
  export function isAppSetupNote(text) {
2994
3068
  return text.trimStart().startsWith(APP_SETUP_NOTE_PREFIX);
2995
3069
  }
3070
+ /**
3071
+ * True for the ONE note the desktop app sends when its visual first run
3072
+ * closes: the handoff that names the assistant (`name` in `done`). Older
3073
+ * builds sent partial notes (an import, a company) with no `name`; those
3074
+ * fold in quietly (`APP_SETUP_NOTE_TURN_NOTE`).
3075
+ */
3076
+ export function isFirstRunHandoffNote(text) {
3077
+ if (!isAppSetupNote(text))
3078
+ return false;
3079
+ const m = /Handoff from the app:\s*(\{.*\})/.exec(text);
3080
+ if (!m)
3081
+ return false;
3082
+ // The JSON ends at the `}` that closes the object; the note's own
3083
+ // sentences follow it on the same line. Braces inside string values (a
3084
+ // company name, a report path) do not count.
3085
+ let depth = 0;
3086
+ let end = -1;
3087
+ let inString = false;
3088
+ for (let i = 0; i < m[1].length; i += 1) {
3089
+ const ch = m[1][i];
3090
+ if (inString) {
3091
+ if (ch === "\\")
3092
+ i += 1;
3093
+ else if (ch === '"')
3094
+ inString = false;
3095
+ continue;
3096
+ }
3097
+ if (ch === '"')
3098
+ inString = true;
3099
+ else if (ch === "{")
3100
+ depth += 1;
3101
+ else if (ch === "}") {
3102
+ depth -= 1;
3103
+ if (depth === 0) {
3104
+ end = i + 1;
3105
+ break;
3106
+ }
3107
+ }
3108
+ }
3109
+ if (end < 0)
3110
+ return false;
3111
+ try {
3112
+ const handoff = JSON.parse(m[1].slice(0, end));
3113
+ return handoff.from === "desktop-visual-first-run" && Array.isArray(handoff.done) && handoff.done.includes("name");
3114
+ }
3115
+ catch {
3116
+ return false;
3117
+ }
3118
+ }
3119
+ /**
3120
+ * Put ahead of the end-of-setup handoff note: it is the assistant's first
3121
+ * turn, so it always answers, and in seconds: everything it needs is in the
3122
+ * handoff, the Owner context block and the facts appended below it, so it
3123
+ * runs no command and reads no file first. The shape of that first message
3124
+ * is in the setup worker's instructions ("When the app hands you settled
3125
+ * steps").
3126
+ */
3127
+ export const FIRST_RUN_HANDOFF_TURN_NOTE = "[This message is the HQ desktop app's end-of-setup handoff, not words from the person. The person has just left the app's setup screens " +
3128
+ "and is in this chat now, waiting for your first message, so always reply; never answer with {\"action\":\"no_reply\"}. " +
3129
+ "Reply now, from this message alone: the handoff, the Owner context block above, and the facts from this HQ listed below. " +
3130
+ "Run no command and read no file before this reply. Do not install, repair or check tools; that comes after they answer, and you will say first how long it takes. " +
3131
+ "Write the first message your instructions describe for this handoff: who you are and what you can help with, " +
3132
+ "then exactly what setup is left (or that none is) and one question.";
3133
+ /** Longest progress note quoted into the first-run handoff turn. */
3134
+ const FIRST_RUN_NOTE_MAX_CHARS = 4_000;
3135
+ /**
3136
+ * What the first-run turn would otherwise spend tool calls finding out: the
3137
+ * company folders on this computer and the progress note. Read here, once,
3138
+ * so the first reply needs no command. Never throws.
3139
+ */
3140
+ export function firstRunHandoffFacts(hqRoot, memoryDir) {
3141
+ let companyFolders;
3142
+ try {
3143
+ companyFolders = fs
3144
+ .readdirSync(path.join(hqRoot, "companies"), { withFileTypes: true })
3145
+ .filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith("."))
3146
+ .map((e) => e.name)
3147
+ .sort();
3148
+ }
3149
+ catch {
3150
+ companyFolders = [];
3151
+ }
3152
+ let progressNote;
3153
+ try {
3154
+ const note = fs.readFileSync(path.join(memoryDir, "setup-progress.md"), "utf8").trim();
3155
+ progressNote = note ? note.slice(0, FIRST_RUN_NOTE_MAX_CHARS) : null;
3156
+ }
3157
+ catch {
3158
+ progressNote = null;
3159
+ }
3160
+ return { companyFolders, progressNote };
3161
+ }
3162
+ /** The whole first-run turn note: the fixed lead, then the facts. */
3163
+ export function firstRunHandoffTurnNote(facts) {
3164
+ const folders = facts.companyFolders.length ? facts.companyFolders.join(", ") : "none yet";
3165
+ const note = facts.progressNote ? `\n---\n${facts.progressNote}\n---` : " none (this is a new setup).";
3166
+ return `${FIRST_RUN_HANDOFF_TURN_NOTE} Facts from this HQ: company folders on this computer: ${folders}. Your progress note:${note}]`;
3167
+ }
3168
+ /** The setup bot's "still working" line: plain words, no dash, nothing about mechanics. */
3169
+ export const SETUP_WORKING_NOTICE_TEXT = "Still working on it. I'll post here as soon as I'm done.";
3170
+ /**
3171
+ * The setup bot's silence budget before that line. Half the general one:
3172
+ * the person is new and watching. The bot is told to say what it is about
3173
+ * to do and how long first (a wait heads-up, kept by the poster); the line
3174
+ * goes out when it said nothing, or when the work outlives the heads-up by
3175
+ * this much with nothing more said.
3176
+ */
3177
+ export const SETUP_WORKING_NOTICE_MS = 30_000;
2996
3178
  /** Put ahead of an app setup note: fold it in, and stay quiet unless it changes what to say. */
2997
3179
  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. " +
2998
3180
  "If you already asked the person something that is still unanswered and this note does not change it, reply with exactly " +
@@ -47,4 +47,5 @@ export declare function setupOfferAnsweredInWordsNote(tool: CodingTool): string;
47
47
  export type SetupToolOfferAnswer = "kept_here" | "wrote";
48
48
  /** How the person answered a pending offer: the button, or anything else. */
49
49
  export declare function setupToolOfferAnswer(text: string, attachments: number): SetupToolOfferAnswer;
50
+ export declare function setupToolOfferPendingSection(tool: CodingTool, kickoff: string): string;
50
51
  //# sourceMappingURL=setup-tool-offer.d.ts.map
@@ -88,4 +88,20 @@ export function setupOfferAnsweredInWordsNote(tool) {
88
88
  export function setupToolOfferAnswer(text, attachments) {
89
89
  return text === KEEP_GOING_HERE_REPLY && attachments === 0 ? "kept_here" : "wrote";
90
90
  }
91
+ export function setupToolOfferPendingSection(tool, kickoff) {
92
+ const app = CODING_TOOL_NAMES[tool];
93
+ return [
94
+ "## The app offer waiting for an answer",
95
+ "",
96
+ `Your first message, sent by the HQ app, offered to continue setup in the ${app} app, because the person uses that app a lot on this computer. ` +
97
+ `It showed a button that opens the ${app} app in their HQ folder and a "${KEEP_GOING_HERE_REPLY}" button. Setup has not started and no first task has run.`,
98
+ "",
99
+ `- If the person replies exactly "${KEEP_GOING_HERE_REPLY}", setup happens here. Do not mention the offer; carry out this first task now:`,
100
+ "",
101
+ kickoff.trim(),
102
+ "",
103
+ `- If they write anything else and want to continue in the ${app} app, tell them in plain words to click the button above to open it. ` +
104
+ "Otherwise begin setup here as your instructions say for a first message.",
105
+ ].join("\n");
106
+ }
91
107
  //# sourceMappingURL=setup-tool-offer.js.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Default-off rollout gate for the signals engine in the bot's built-in
3
+ * hot-signal-sync job (hq-cli #2306, hot-signal-sync US-006).
4
+ *
5
+ * Off (the default): a fired job runs the hot-channel sync alone, as before
6
+ * #2306. On: the job runs the full signals engine (`runSignals`) and replies in
7
+ * the storyboarded format. Turn it on for a company in the flag registry, or on
8
+ * one computer with `HQ_FLAG_CLI_BOT_SIGNALS_ENGINE_JOB=true` in the bot's
9
+ * environment.
10
+ */
11
+ export declare const BOT_SIGNALS_ENGINE_JOB_FLAG = "cli.bot-signals-engine-job";
12
+ export declare function botSignalsEngineJobEnabled(): Promise<boolean>;
13
+ /**
14
+ * The engine a fired hot-signal-sync job gets: `engine` when the flag is on,
15
+ * otherwise undefined, so `runHotSignalSyncJob` takes its sync-only path.
16
+ */
17
+ export declare function signalsEngineForJob<T>(engine: T, enabled?: () => Promise<boolean>): Promise<T | undefined>;
18
+ //# sourceMappingURL=signals-engine-flag.d.ts.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Default-off rollout gate for the signals engine in the bot's built-in
3
+ * hot-signal-sync job (hq-cli #2306, hot-signal-sync US-006).
4
+ *
5
+ * Off (the default): a fired job runs the hot-channel sync alone, as before
6
+ * #2306. On: the job runs the full signals engine (`runSignals`) and replies in
7
+ * the storyboarded format. Turn it on for a company in the flag registry, or on
8
+ * one computer with `HQ_FLAG_CLI_BOT_SIGNALS_ENGINE_JOB=true` in the bot's
9
+ * environment.
10
+ */
11
+ export const BOT_SIGNALS_ENGINE_JOB_FLAG = "cli.bot-signals-engine-job";
12
+ export async function botSignalsEngineJobEnabled() {
13
+ try {
14
+ const { resolveProcessFlagGate } = await import("../flag-registry.js");
15
+ return resolveProcessFlagGate(BOT_SIGNALS_ENGINE_JOB_FLAG, { fallback: false }, () => false);
16
+ }
17
+ catch (error) {
18
+ const errorClass = error instanceof Error ? error.name : "unknown";
19
+ console.warn(`hq: Could not resolve ${BOT_SIGNALS_ENGINE_JOB_FLAG} (${errorClass}); the bot's signals job runs the sync only.`);
20
+ return false;
21
+ }
22
+ }
23
+ /**
24
+ * The engine a fired hot-signal-sync job gets: `engine` when the flag is on,
25
+ * otherwise undefined, so `runHotSignalSyncJob` takes its sync-only path.
26
+ */
27
+ export async function signalsEngineForJob(engine, enabled = botSignalsEngineJobEnabled) {
28
+ return (await enabled()) ? engine : undefined;
29
+ }
30
+ //# sourceMappingURL=signals-engine-flag.js.map
@@ -92,6 +92,9 @@ export interface SyncCoreRunning {
92
92
  personal: boolean;
93
93
  companies: ReadonlySet<string>;
94
94
  }
95
+ export declare const FLEET_BOTS_UPDATE_UNIT = "fleet-bots-update";
96
+ export declare const FLEET_BOTS_UPDATE_INTERVAL_SEC: number;
97
+ export declare const FLEET_BOTS_UPDATE_INITIAL_DELAY_SEC = 120;
95
98
  export declare const SYNC_CORE_RECONCILE_UNIT = "sync-core";
96
99
  export declare const SYNC_CORE_RECONCILE_INTERVAL_SEC = 600;
97
100
  export declare const SYNC_CORE_MANIFEST_UNIT = "sync-core-manifest";
@@ -148,6 +151,7 @@ export interface PlanContext {
148
151
  /** ~/.hq-agent/kit.json exists. */
149
152
  kitConfigured: boolean;
150
153
  bots: readonly string[];
154
+ fleetBots?: readonly string[];
151
155
  /** Name of a pre-daemon unit that still runs this unit id, or null. */
152
156
  legacyOwner: (unitId: string) => string | null;
153
157
  /** This CLI is a global install the updater can replace (not a project dependency). */
@@ -92,6 +92,9 @@ export function enabledServices(machineType, overrides) {
92
92
  };
93
93
  return SERVICE_NAMES.filter((s) => wanted(s) || implied(s));
94
94
  }
95
+ export const FLEET_BOTS_UPDATE_UNIT = "fleet-bots-update";
96
+ export const FLEET_BOTS_UPDATE_INTERVAL_SEC = 30 * 60;
97
+ export const FLEET_BOTS_UPDATE_INITIAL_DELAY_SEC = 120;
95
98
  export const SYNC_CORE_RECONCILE_UNIT = "sync-core";
96
99
  export const SYNC_CORE_RECONCILE_INTERVAL_SEC = 600;
97
100
  export const SYNC_CORE_MANIFEST_UNIT = "sync-core-manifest";
@@ -322,17 +325,34 @@ function planService(service, ctx) {
322
325
  ];
323
326
  }
324
327
  case "bots": {
328
+ const fleetWatch = (ctx.fleetBots ?? []).length > 0
329
+ ? [
330
+ base(service, {
331
+ id: FLEET_BOTS_UPDATE_UNIT,
332
+ description: "moves HQ Fleet bots to the current runtime release while they are idle",
333
+ kind: "scheduled",
334
+ intervalSec: FLEET_BOTS_UPDATE_INTERVAL_SEC,
335
+ initialDelaySec: FLEET_BOTS_UPDATE_INITIAL_DELAY_SEC,
336
+ command: { type: "hq", args: ["daemon", "service", "fleet-bots-update"] },
337
+ env: { HQ_NO_UPDATE_CHECK: "1" },
338
+ blocked: firstBlock([[!ctx.signedIn, NOT_SIGNED_IN]]),
339
+ }),
340
+ ]
341
+ : [];
325
342
  if (ctx.bots.length === 0) {
326
- return [base(service, { blocked: { state: "waiting", reason: "no local bots" } })];
343
+ return fleetWatch.length > 0 ? fleetWatch : [base(service, { blocked: { state: "waiting", reason: "no local bots" } })];
327
344
  }
328
- return ctx.bots.map((name) => base(service, {
329
- id: `bots:${name}`,
330
- description: `runs the local bot ${name}`,
331
- restart: "on-failure",
332
- command: { type: "hq", args: ["bot", "run", name] },
333
- // A bot drains on SIGUSR2: running turns finish, then it exits.
334
- drainSignal: "SIGUSR2",
335
- }));
345
+ return [
346
+ ...ctx.bots.map((name) => base(service, {
347
+ id: `bots:${name}`,
348
+ description: `runs the local bot ${name}`,
349
+ restart: "on-failure",
350
+ command: { type: "hq", args: ["bot", "run", name] },
351
+ // A bot drains on SIGUSR2: running turns finish, then it exits.
352
+ drainSignal: "SIGUSR2",
353
+ })),
354
+ ...fleetWatch,
355
+ ];
336
356
  }
337
357
  case "session-host":
338
358
  return [
@@ -32,6 +32,12 @@ export interface LegacyProbeDeps {
32
32
  launchdJobLoaded: (label: string) => boolean;
33
33
  /** Whether the task exists and runs as the current user. Another user's task is never taken over. */
34
34
  windowsTaskOwner: (taskName: string) => WindowsTaskOwner;
35
+ /**
36
+ * Mesh task name `hq mesh daemon install` recorded for this user. It is a
37
+ * per-user name (`ai.getindigo.hq-mesh-daemon.<user>`) when the shared name
38
+ * belonged to another account.
39
+ */
40
+ windowsRecordedMeshTask?: () => string;
35
41
  }
36
42
  export declare function describeLegacyUnit(unit: LegacyUnit): string;
37
43
  export declare function findLegacyUnits(deps: LegacyProbeDeps): LegacyUnit[];
@@ -100,16 +100,22 @@ function linuxUnits(deps) {
100
100
  return units;
101
101
  }
102
102
  function windowsUnits(deps) {
103
- const task = "ai.getindigo.hq-mesh-daemon";
104
- const owner = deps.windowsTaskOwner(task);
105
- if (owner === "none")
106
- return [];
107
- const unit = { unitId: "mesh", service: "mesh", kind: "schtasks", name: task, migratable: owner === "mine" };
108
- if (owner === "other")
109
- unit.notMineReason = "it belongs to another Windows user";
110
- if (owner === "unreadable")
111
- unit.notMineReason = "HQ could not read which Windows user it belongs to";
112
- return [unit];
103
+ const shared = "ai.getindigo.hq-mesh-daemon";
104
+ const recorded = deps.windowsRecordedMeshTask?.();
105
+ const tasks = recorded && recorded !== shared ? [recorded, shared] : [shared];
106
+ const units = [];
107
+ for (const task of tasks) {
108
+ const owner = deps.windowsTaskOwner(task);
109
+ if (owner === "none")
110
+ continue;
111
+ const unit = { unitId: "mesh", service: "mesh", kind: "schtasks", name: task, migratable: owner === "mine" };
112
+ if (owner === "other")
113
+ unit.notMineReason = "it belongs to another Windows user";
114
+ if (owner === "unreadable")
115
+ unit.notMineReason = "HQ could not read which Windows user it belongs to";
116
+ units.push(unit);
117
+ }
118
+ return units;
113
119
  }
114
120
  export function findLegacyUnits(deps) {
115
121
  if (deps.platform === "darwin")