@indigoai-us/hq-cli 5.345.190 → 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 +420 -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 +7 -4
  8. package/dist/commands/bot.js +289 -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 +113 -2
  30. package/dist/lib/bot/fleet-local.js +597 -35
  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
@@ -0,0 +1,12 @@
1
+ export interface OwnerMembership {
2
+ companyUid: string;
3
+ companySlug?: string;
4
+ status: string;
5
+ }
6
+ export interface FleetOwnerCompaniesDependencies {
7
+ /** The signed-in owner's memberships; never prompts for sign-in. */
8
+ memberships?: () => Promise<OwnerMembership[]>;
9
+ }
10
+ /** Every active company the signed-in owner can access, independent of rollout flags or local folders. */
11
+ export declare function fleetOwnerCompanies(dependencies?: FleetOwnerCompaniesDependencies): Promise<string[]>;
12
+ //# sourceMappingURL=fleet-owner-companies.d.ts.map
@@ -0,0 +1,30 @@
1
+ import { ensureCognitoToken } from "../../utils/cognito-session.js";
2
+ import { vaultApiFetch } from "../../utils/vault-api.js";
3
+ const MEMBERSHIP_TIMEOUT_MS = 10_000;
4
+ const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
5
+ const COMPANY_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
6
+ async function signedInMemberships() {
7
+ const token = await ensureCognitoToken({ tokenSource: "person", interactive: false });
8
+ const res = await vaultApiFetch({ token, path: "/membership/me", signal: AbortSignal.timeout(MEMBERSHIP_TIMEOUT_MS) });
9
+ if (!res.ok)
10
+ throw new Error(`membership lookup returned ${res.status}`);
11
+ const data = (await res.json());
12
+ if (!Array.isArray(data.memberships))
13
+ throw new Error("membership lookup returned no list");
14
+ return data.memberships;
15
+ }
16
+ /** Every active company the signed-in owner can access, independent of rollout flags or local folders. */
17
+ export async function fleetOwnerCompanies(dependencies = {}) {
18
+ const memberships = await (dependencies.memberships ?? signedInMemberships)();
19
+ const companies = new Set();
20
+ for (const membership of memberships) {
21
+ const slug = membership.companySlug?.trim() ?? "";
22
+ if (membership.status !== "active" || !COMPANY_UID_RE.test(membership.companyUid))
23
+ continue;
24
+ if (!COMPANY_SLUG_RE.test(slug) || slug === "personal")
25
+ continue;
26
+ companies.add(slug);
27
+ }
28
+ return [...companies].sort((a, b) => a.localeCompare(b));
29
+ }
30
+ //# sourceMappingURL=fleet-owner-companies.js.map
@@ -0,0 +1,25 @@
1
+ import { type BotConfig } from "./config.js";
2
+ import type { FleetLocalReleasePin, FleetLocalTargetRelease } from "./fleet-local.js";
3
+ export interface FleetWatchBot {
4
+ name: string;
5
+ dir: string;
6
+ config: BotConfig;
7
+ }
8
+ export interface FleetWatchDeps {
9
+ listBots(): FleetWatchBot[];
10
+ target(): Promise<FleetLocalTargetRelease | null>;
11
+ installed(bot: FleetWatchBot): boolean;
12
+ heldForPromotion(bot: FleetWatchBot): boolean;
13
+ needsAdoption(bot: FleetWatchBot, release: FleetLocalReleasePin): boolean;
14
+ busy(bot: FleetWatchBot): string | null;
15
+ update(bot: FleetWatchBot): Promise<"unchanged" | "updated" | "waiting">;
16
+ log(line: string): void;
17
+ }
18
+ export interface FleetWatchResult {
19
+ updated: string[];
20
+ waiting: string[];
21
+ failed: string[];
22
+ }
23
+ export declare function listFleetBots(root: string): FleetWatchBot[];
24
+ export declare function runFleetBotsUpdate(deps: FleetWatchDeps): Promise<FleetWatchResult>;
25
+ //# sourceMappingURL=fleet-watch.d.ts.map
@@ -0,0 +1,51 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { HQ_FLEET_RUNTIME_ENGINE_ID, readBotConfig } from "./config.js";
4
+ export function listFleetBots(root) {
5
+ if (!fs.existsSync(root))
6
+ return [];
7
+ return fs
8
+ .readdirSync(root, { withFileTypes: true })
9
+ .filter((entry) => entry.isDirectory())
10
+ .map((entry) => ({ name: entry.name, dir: path.join(root, entry.name), config: readBotConfig(path.join(root, entry.name)) }))
11
+ .filter((bot) => bot.config !== null && bot.config.engine?.id === HQ_FLEET_RUNTIME_ENGINE_ID)
12
+ .sort((a, b) => a.name.localeCompare(b.name));
13
+ }
14
+ export async function runFleetBotsUpdate(deps) {
15
+ const result = { updated: [], waiting: [], failed: [] };
16
+ const candidates = deps
17
+ .listBots()
18
+ .filter((bot) => bot.config.fleetAuthPending !== true && !deps.heldForPromotion(bot) && deps.installed(bot));
19
+ if (candidates.length === 0)
20
+ return result;
21
+ const target = await deps.target();
22
+ const version = target?.release.version ?? "release";
23
+ for (const bot of candidates) {
24
+ if (bot.config.fleetRestartPending !== true && (!target || !deps.needsAdoption(bot, target.release)))
25
+ continue;
26
+ const busy = deps.busy(bot);
27
+ if (busy) {
28
+ deps.log(`${bot.name}: HQ Fleet Local ${version} is ready; waiting because ${busy}; trying again next run`);
29
+ result.waiting.push(bot.name);
30
+ continue;
31
+ }
32
+ try {
33
+ const outcome = await deps.update(bot);
34
+ if (outcome === "waiting") {
35
+ deps.log(`${bot.name}: HQ Fleet Local ${version} is installed; the restart waits until the bot is idle; trying again next run`);
36
+ result.waiting.push(bot.name);
37
+ continue;
38
+ }
39
+ if (outcome === "unchanged")
40
+ continue;
41
+ deps.log(`${bot.name}: moved to HQ Fleet Local ${version} and restarted`);
42
+ result.updated.push(bot.name);
43
+ }
44
+ catch (err) {
45
+ deps.log(`${bot.name}: could not move to HQ Fleet Local ${version}: ${err instanceof Error ? err.message : String(err)}`);
46
+ result.failed.push(bot.name);
47
+ }
48
+ }
49
+ return result;
50
+ }
51
+ //# sourceMappingURL=fleet-watch.js.map
@@ -26,6 +26,6 @@ export * from "./lock.js";
26
26
  export * from "./display-name.js";
27
27
  export * from "./decisions.js";
28
28
  export * from "./fleet-local.js";
29
- export * from "./fleet-default-flag.js";
29
+ export * from "./fleet-owner-companies.js";
30
30
  export * from "./fleet-python.js";
31
31
  //# sourceMappingURL=index.d.ts.map
@@ -26,6 +26,6 @@ export * from "./lock.js";
26
26
  export * from "./display-name.js";
27
27
  export * from "./decisions.js";
28
28
  export * from "./fleet-local.js";
29
- export * from "./fleet-default-flag.js";
29
+ export * from "./fleet-owner-companies.js";
30
30
  export * from "./fleet-python.js";
31
31
  //# sourceMappingURL=index.js.map
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Scheduled job runs: the third kind of inbox item a bot can be handed.
3
3
  *
4
- * The cloud does all the scheduling. hq-pro-agents fires a job on an
5
- * EventBridge schedule and writes a DURABLE inbox item for the agent
6
- * (`channel: "job"`) carrying the job's own prompt. Nothing is scheduled on
7
- * this computer — there is no crontab, no launchd timer, no local clock.
4
+ * A local bot's jobs live in its own jobs.json and its inbox loop fires them
5
+ * (lib/bot/local-jobs.ts): a due job becomes a `channel: "job"` inbox item
6
+ * carrying the job's own prompt. A hosted agent's job arrives the same way,
7
+ * written by hq-pro-agents from its cloud schedule. Either way there is no
8
+ * crontab, no launchd timer and no system scheduler on this computer.
8
9
  *
9
10
  * Two rules shape everything here (owner decisions, 2026-09-15):
10
11
  *
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Scheduled job runs: the third kind of inbox item a bot can be handed.
3
3
  *
4
- * The cloud does all the scheduling. hq-pro-agents fires a job on an
5
- * EventBridge schedule and writes a DURABLE inbox item for the agent
6
- * (`channel: "job"`) carrying the job's own prompt. Nothing is scheduled on
7
- * this computer — there is no crontab, no launchd timer, no local clock.
4
+ * A local bot's jobs live in its own jobs.json and its inbox loop fires them
5
+ * (lib/bot/local-jobs.ts): a due job becomes a `channel: "job"` inbox item
6
+ * carrying the job's own prompt. A hosted agent's job arrives the same way,
7
+ * written by hq-pro-agents from its cloud schedule. Either way there is no
8
+ * crontab, no launchd timer and no system scheduler on this computer.
8
9
  *
9
10
  * Two rules shape everything here (owner decisions, 2026-09-15):
10
11
  *
@@ -15,8 +15,19 @@
15
15
  * `hq bot jobs <bot> --reschedule <jobId> --every "<when>"`, or removes it with
16
16
  * `--rm`, and a removed job is not added back on the next start.
17
17
  *
18
- * When the job fires the bot runs the sync in-process, with no model turn
19
- * (`runHotSignalSyncJob`): the same hot-channel sync `hq sources sync` runs,
18
+ * When the job fires the bot runs the signals engine in-process (US-006):
19
+ * `runSignals({ trigger: "job", catchUp })`, the same engine `hq signals run`
20
+ * uses (garden -> adapters -> extract -> garden -> upload, with the caps from
21
+ * core/settings/signals.yaml and a run record), and reports the run to the
22
+ * owner in the storyboarded reply format (lib/signals/reply.ts). When a run
23
+ * fails with a transient error, the run record carries `nextRetryAt` and the
24
+ * bot runs the job once more at that time, in-process (lib/bot/run.ts); only
25
+ * then does the failure reply offer to wait for the retry. The run started
26
+ * right after registration uses the `manual` trigger, so it never counts as
27
+ * the day's scheduled job. Extract
28
+ * spawns the member's own claude/codex under the bot's login, so the
29
+ * orchestration itself has no model turn. Without an engine wired in, the
30
+ * job falls back to the sync alone: the same hot-channel sync `hq sources sync` runs,
20
31
  * with `trigger: "scheduled"` and `catchUp` set when the run fired late after
21
32
  * a missed window. Catch-up is bounded by each adapter's `catchUpWindow`, and
22
33
  * per-connection cursors keep a redelivered run from landing anything twice.
@@ -26,15 +37,23 @@
26
37
  import type { HotChannelRunResult, WriteGuard } from "../../hot-channels/loop.js";
27
38
  import type { RunTrigger } from "../../hot-channels/ledger.js";
28
39
  import type { OnDemandSyncReport } from "../../hot-channels/run.js";
40
+ import { type RunSignalsOptions, type RunSignalsResult } from "../../signals/engine.js";
41
+ import { type FormattedReply } from "../../signals/reply.js";
29
42
  import { type BotConfig } from "../config.js";
30
43
  import { type LocalJob } from "../local-jobs.js";
31
44
  export declare const HOT_SIGNAL_SYNC_JOB_KIND = "hot-signal-sync";
32
45
  /**
33
- * 06:30 every day, in the job's time zone (the stored EventBridge form of
46
+ * 06:30 every day, in the job's time zone (the stored six-field form of
34
47
  * "30 6 * * *"). Only the first registration uses it; after that the job's
35
48
  * own schedule in `jobs.json` is the one that runs.
36
49
  */
37
50
  export declare const HOT_SIGNAL_SYNC_DEFAULT_CRON = "cron(30 6 * * ? *)";
51
+ export declare function hotSignalSyncRegistered(dir: string, company: string): boolean;
52
+ export declare function fleetHotSignalSyncRegistered(dir: string, company: string): boolean;
53
+ export declare function markFleetHotSignalSyncRegistered(dir: string, company: string, now?: Date): void;
54
+ export declare function markHotSignalSyncRegistered(dir: string, company: string, now?: Date): void;
55
+ export declare function forgetHotSignalSyncRegistrations(dir: string, keep: string | null): string[];
56
+ export declare function forgetHotSignalSyncRegistrationsExcept(dir: string, keep: readonly string[]): string[];
38
57
  /** The private context that marks a job as this built-in, for one company. */
39
58
  export declare function hotSignalSyncJobContext(company: string): Record<string, unknown>;
40
59
  /** The company a job item syncs, when its context marks it as this built-in; else null. */
@@ -146,12 +165,47 @@ export interface HotSignalSyncRunInput {
146
165
  */
147
166
  sendAlert?: (text: string) => Promise<void>;
148
167
  log?: (message: string) => void;
168
+ /**
169
+ * The signals engine (`runSignals`). When set, a fired job runs the whole
170
+ * engine for the company instead of the sync alone; `hqRoot` is required.
171
+ */
172
+ signals?: (options: SignalsEngineJobOptions) => Promise<RunSignalsResult>;
173
+ hqRoot?: string;
174
+ /** The job's zone, for the times in the reply. */
175
+ timeZone?: string;
176
+ /** When the run was due (the fired item's `firedAt`), for the catch-up line. */
177
+ scheduledFor?: string;
178
+ /** This run is the bot's automatic retry of a failed run; it never schedules another. */
179
+ retry?: boolean;
180
+ /**
181
+ * Asks the bot to run the job once more at `at` (the run record's
182
+ * `nextRetryAt`). Returns true when the retry is scheduled; only then does
183
+ * the failure reply say when it tries again. Unset = no automatic retry.
184
+ */
185
+ scheduleRetry?: (at: string) => boolean;
149
186
  }
187
+ /**
188
+ * The engine options a fired job passes. `signalsAlert` is the owner-DM
189
+ * sender for the signals-failure alert (US-018, hq-cli #2314). Until #2314
190
+ * adds it to `RunSignalsOptions`, `runSignals` ignores the field; once it
191
+ * lands, the engine alerts when a run lands sources and produces no signals.
192
+ */
193
+ export type SignalsEngineJobOptions = RunSignalsOptions & {
194
+ signalsAlert?: {
195
+ sendOwnerDm: (text: string) => Promise<void>;
196
+ log?: (message: string) => void;
197
+ };
198
+ };
150
199
  export interface HotSignalSyncRunOutcome {
151
200
  ok: boolean;
152
201
  /** Plain report for the owner's DM and the job's run output. */
153
202
  text: string;
154
203
  report: OnDemandSyncReport | null;
204
+ /** The engine's result and the storyboarded reply, when the engine ran. */
205
+ signals?: RunSignalsResult;
206
+ reply?: FormattedReply;
207
+ /** When the bot runs the automatic retry (the record's nextRetryAt), if one was scheduled. */
208
+ retryAt?: string;
155
209
  }
156
210
  /** Run the scheduled sync once and describe it in plain words. Never throws. */
157
211
  export declare function runHotSignalSyncJob(input: HotSignalSyncRunInput): Promise<HotSignalSyncRunOutcome>;
@@ -15,8 +15,19 @@
15
15
  * `hq bot jobs <bot> --reschedule <jobId> --every "<when>"`, or removes it with
16
16
  * `--rm`, and a removed job is not added back on the next start.
17
17
  *
18
- * When the job fires the bot runs the sync in-process, with no model turn
19
- * (`runHotSignalSyncJob`): the same hot-channel sync `hq sources sync` runs,
18
+ * When the job fires the bot runs the signals engine in-process (US-006):
19
+ * `runSignals({ trigger: "job", catchUp })`, the same engine `hq signals run`
20
+ * uses (garden -> adapters -> extract -> garden -> upload, with the caps from
21
+ * core/settings/signals.yaml and a run record), and reports the run to the
22
+ * owner in the storyboarded reply format (lib/signals/reply.ts). When a run
23
+ * fails with a transient error, the run record carries `nextRetryAt` and the
24
+ * bot runs the job once more at that time, in-process (lib/bot/run.ts); only
25
+ * then does the failure reply offer to wait for the retry. The run started
26
+ * right after registration uses the `manual` trigger, so it never counts as
27
+ * the day's scheduled job. Extract
28
+ * spawns the member's own claude/codex under the bot's login, so the
29
+ * orchestration itself has no model turn. Without an engine wired in, the
30
+ * job falls back to the sync alone: the same hot-channel sync `hq sources sync` runs,
20
31
  * with `trigger: "scheduled"` and `catchUp` set when the run fired late after
21
32
  * a missed window. Catch-up is bounded by each adapter's `catchUpWindow`, and
22
33
  * per-connection cursors keep a redelivered run from landing anything twice.
@@ -26,11 +37,13 @@
26
37
  import * as fs from "node:fs";
27
38
  import * as path from "node:path";
28
39
  import { JOB_RUN_OPTIONS_CONTEXT_KEY } from "../job-run.js";
40
+ import { localDate, runRecordRel } from "../../signals/engine.js";
41
+ import { formatJobErrorReply, formatLockedReply, formatRunReply, timeInWords } from "../../signals/reply.js";
29
42
  import { effectiveBotCompanies, effectiveBotKind } from "../config.js";
30
43
  import { ensureLocalJob, readLocalJobs, removeLocalJobsWhere, withJobsLock } from "../local-jobs.js";
31
44
  export const HOT_SIGNAL_SYNC_JOB_KIND = "hot-signal-sync";
32
45
  /**
33
- * 06:30 every day, in the job's time zone (the stored EventBridge form of
46
+ * 06:30 every day, in the job's time zone (the stored six-field form of
34
47
  * "30 6 * * *"). Only the first registration uses it; after that the job's
35
48
  * own schedule in `jobs.json` is the one that runs.
36
49
  */
@@ -40,6 +53,9 @@ const BUILTIN_JOBS_FILE = "builtin-jobs.json";
40
53
  function markerKey(company) {
41
54
  return `${HOT_SIGNAL_SYNC_JOB_KIND}:${company}`;
42
55
  }
56
+ function fleetMarkerKey(company) {
57
+ return `${HOT_SIGNAL_SYNC_JOB_KIND}:fleet:${company}`;
58
+ }
43
59
  function readMarker(dir) {
44
60
  try {
45
61
  const parsed = JSON.parse(fs.readFileSync(path.join(dir, BUILTIN_JOBS_FILE), "utf-8"));
@@ -58,6 +74,64 @@ function writeMarker(dir, marker) {
58
74
  fs.writeFileSync(tmp, JSON.stringify(marker, null, 2) + "\n", { mode: 0o600 });
59
75
  fs.renameSync(tmp, file);
60
76
  }
77
+ export function hotSignalSyncRegistered(dir, company) {
78
+ return Boolean(readMarker(dir).registered[markerKey(company)]);
79
+ }
80
+ export function fleetHotSignalSyncRegistered(dir, company) {
81
+ return Boolean(readMarker(dir).registered[fleetMarkerKey(company)]);
82
+ }
83
+ export function markFleetHotSignalSyncRegistered(dir, company, now = new Date()) {
84
+ withJobsLock(dir, () => {
85
+ const marker = readMarker(dir);
86
+ if (marker.registered[fleetMarkerKey(company)])
87
+ return;
88
+ marker.registered[fleetMarkerKey(company)] = now.toISOString();
89
+ writeMarker(dir, marker);
90
+ });
91
+ }
92
+ export function markHotSignalSyncRegistered(dir, company, now = new Date()) {
93
+ withJobsLock(dir, () => {
94
+ const marker = readMarker(dir);
95
+ if (marker.registered[markerKey(company)])
96
+ return;
97
+ marker.registered[markerKey(company)] = now.toISOString();
98
+ writeMarker(dir, marker);
99
+ });
100
+ }
101
+ export function forgetHotSignalSyncRegistrations(dir, keep) {
102
+ const keepKey = keep ? markerKey(keep) : null;
103
+ return withJobsLock(dir, () => {
104
+ const marker = readMarker(dir);
105
+ const forgotten = [];
106
+ for (const key of Object.keys(marker.registered)) {
107
+ if (key.startsWith(HOT_SIGNAL_SYNC_JOB_KIND + ":") && key !== keepKey) {
108
+ delete marker.registered[key];
109
+ forgotten.push(key.slice(HOT_SIGNAL_SYNC_JOB_KIND.length + 1));
110
+ }
111
+ }
112
+ if (forgotten.length > 0)
113
+ writeMarker(dir, marker);
114
+ return forgotten;
115
+ });
116
+ }
117
+ export function forgetHotSignalSyncRegistrationsExcept(dir, keep) {
118
+ const keepKeys = new Set(keep.flatMap((company) => [markerKey(company), fleetMarkerKey(company)]));
119
+ return withJobsLock(dir, () => {
120
+ const marker = readMarker(dir);
121
+ const forgotten = [];
122
+ for (const key of Object.keys(marker.registered)) {
123
+ if (key.startsWith(HOT_SIGNAL_SYNC_JOB_KIND + ":") && !keepKeys.has(key)) {
124
+ delete marker.registered[key];
125
+ const company = key.slice(HOT_SIGNAL_SYNC_JOB_KIND.length + 1).replace(/^fleet:/, "");
126
+ if (!forgotten.includes(company))
127
+ forgotten.push(company);
128
+ }
129
+ }
130
+ if (forgotten.length > 0)
131
+ writeMarker(dir, marker);
132
+ return forgotten;
133
+ });
134
+ }
61
135
  /** The private context that marks a job as this built-in, for one company. */
62
136
  export function hotSignalSyncJobContext(company) {
63
137
  return { [JOB_RUN_OPTIONS_CONTEXT_KEY]: { builtin: HOT_SIGNAL_SYNC_JOB_KIND, company } };
@@ -117,7 +191,8 @@ export function otherBotWithHotSignalSync(dir, company, botDirs) {
117
191
  for (const other of botDirs) {
118
192
  if (path.resolve(other) === self)
119
193
  continue;
120
- if (readMarker(other).registered[markerKey(company)])
194
+ const registered = readMarker(other).registered;
195
+ if (registered[markerKey(company)] || registered[fleetMarkerKey(company)])
121
196
  return other;
122
197
  if (readLocalJobs(other).some((job) => hotSignalSyncJobCompany(job.context) === company))
123
198
  return other;
@@ -227,8 +302,92 @@ function errorMessage(err) {
227
302
  return err instanceof Error ? err.message : String(err);
228
303
  }
229
304
  export const noLocalSignalsPass = async () => 0;
305
+ /**
306
+ * Today's run record for the company, or null. The automatic retry reads it
307
+ * first: when another run (a "Try now", a brief run) already finished the day
308
+ * complete or partial since the failure, the retry has nothing to do.
309
+ */
310
+ function readTodaysRecord(hqRoot, company, now) {
311
+ try {
312
+ const parsed = JSON.parse(fs.readFileSync(path.join(hqRoot, runRecordRel(localDate(now), company)), "utf8"));
313
+ return parsed && typeof parsed === "object" ? parsed : null;
314
+ }
315
+ catch {
316
+ return null;
317
+ }
318
+ }
319
+ async function runSignalsEngineJob(input) {
320
+ if (!input.hqRoot) {
321
+ const reply = formatJobErrorReply(`Morning signals for ${input.company} did not run.`, "The bot has no HQ folder set, so it cannot find the company's files.");
322
+ return { ok: false, text: reply.text, report: null, reply };
323
+ }
324
+ const timeZone = input.timeZone ?? "UTC";
325
+ if (input.retry) {
326
+ const today = readTodaysRecord(input.hqRoot, input.company, input.now());
327
+ if (today && (today.status === "complete" || today.status === "partial")) {
328
+ const at = timeInWords(today.finishedAt, timeZone);
329
+ const text = `Morning signals for ${input.company} already finished${at ? ` at ${at}` : ""} after another run, so the automatic retry had nothing to do.`;
330
+ return { ok: true, text, report: null };
331
+ }
332
+ }
333
+ const options = {
334
+ hqRoot: input.hqRoot,
335
+ company: input.company,
336
+ // The run right after the job is registered is a setup run, not the day's
337
+ // scheduled run: a non-job trigger leaves jobRanAt alone, so the morning
338
+ // job still runs (a bot first started at 01:00 is not "already ran").
339
+ trigger: input.firstRun ? "manual" : "job",
340
+ catchUp: input.catchUp,
341
+ now: input.now,
342
+ // The signals-failure alert (US-018): the owner DM, sent even when the job is quiet.
343
+ ...(input.sendAlert
344
+ ? { signalsAlert: { sendOwnerDm: input.sendAlert, ...(input.log ? { log: input.log } : {}) } }
345
+ : {}),
346
+ };
347
+ let result;
348
+ try {
349
+ result = await input.signals(options);
350
+ }
351
+ catch (err) {
352
+ const reason = err instanceof Error ? err.message : String(err);
353
+ const reply = formatJobErrorReply(`Morning signals for ${input.company} failed.`, reason);
354
+ return { ok: false, text: reply.text, report: null, reply };
355
+ }
356
+ const replyOptions = {
357
+ timeZone,
358
+ ...(input.scheduledFor ? { scheduledFor: input.scheduledFor } : {}),
359
+ };
360
+ const record = result.records.find((r) => r.company === input.company) ?? result.records[0];
361
+ if (!record) {
362
+ const locked = (result.locked ?? []).find((l) => l.company === input.company);
363
+ if (locked) {
364
+ const reply = formatLockedReply(locked, replyOptions);
365
+ return { ok: true, text: reply.text, report: null, signals: result, reply };
366
+ }
367
+ const reply = formatJobErrorReply(`Morning signals for ${input.company} did not finish.`, "The run left no run record, so I cannot say what happened.");
368
+ return { ok: false, text: reply.text, report: null, signals: result, reply };
369
+ }
370
+ // One automatic retry per failed scheduled run, only when the engine set a
371
+ // retry time (a transient failure) and the bot really scheduled it. The
372
+ // retry itself never schedules another.
373
+ const retryScheduled = record.status === "failed" &&
374
+ !record.alreadyRanToday &&
375
+ typeof record.nextRetryAt === "string" &&
376
+ !input.retry &&
377
+ input.scheduleRetry !== undefined &&
378
+ input.scheduleRetry(record.nextRetryAt);
379
+ const reply = formatRunReply(record, result.summary, {
380
+ ...replyOptions,
381
+ ...(retryScheduled ? { retryScheduled: true } : {}),
382
+ ...(input.retry ? { wasRetry: true } : {}),
383
+ });
384
+ const ok = reply.variant !== "failed" && reply.variant !== "partial";
385
+ return { ok, text: reply.text, report: null, signals: result, reply, ...(retryScheduled ? { retryAt: record.nextRetryAt } : {}) };
386
+ }
230
387
  /** Run the scheduled sync once and describe it in plain words. Never throws. */
231
388
  export async function runHotSignalSyncJob(input) {
389
+ if (input.signals)
390
+ return runSignalsEngineJob({ ...input, signals: input.signals });
232
391
  const signalsPass = input.signalsPass ?? noLocalSignalsPass;
233
392
  let report;
234
393
  try {
@@ -45,6 +45,7 @@ export interface LocalJob {
45
45
  runCount?: number;
46
46
  }
47
47
  export declare function readLocalJobs(dir: string): LocalJob[];
48
+ export declare function writeLocalJobs(dir: string, jobs: LocalJob[]): void;
48
49
  /**
49
50
  * Run a read-change-write of `jobs.json` under an exclusive lock. The running
50
51
  * bot (firing, finishing) and `hq bot jobs` (add, remove) are separate
@@ -63,8 +64,9 @@ export interface CronMatcher {
63
64
  }
64
65
  /**
65
66
  * The job's schedule as a standard 5-field cron. Jobs are stored in the same
66
- * form the cloud uses (`cron(m h dom mon dow year)`, Sunday = 1, `?` for the
67
- * unused day field), so the same `hq bot jobs` input works for either home.
67
+ * six-field form hosted agent jobs use (`cron(m h dom mon dow year)`, Sunday =
68
+ * 1, `?` for the unused day field), so the same `hq bot jobs` input works for
69
+ * either home.
68
70
  */
69
71
  export declare function toStandardCron(expr: string): string | null;
70
72
  /** Parse a standard 5-field cron, or null when it is not one. */
@@ -106,6 +108,15 @@ export declare function removeLocalJobsWhere(dir: string, matches: (job: LocalJo
106
108
  /** Forget the once-per-minute check (tests, and a bot restarted in-process). */
107
109
  export declare function resetLocalJobChecks(): void;
108
110
  export declare function takeDueLocalJobRuns(dir: string, now?: Date): InboxItem[];
111
+ /** A run of a job in this bot's own jobs.json (its ack and completion stay local). */
112
+ export declare function isLocalJobMessage(messageId: string): boolean;
113
+ /**
114
+ * The message id of the bot's one automatic retry of a local job run (the
115
+ * signals job after a transient failure). It keeps the local prefix, so its
116
+ * ack and completion stay on this computer like any other local run.
117
+ */
118
+ export declare function localJobRetryMessageId(jobId: string, dueMs: number): string;
119
+ export declare function isLocalJobRetryMessage(messageId: string): boolean;
109
120
  /**
110
121
  * The bot's cloud client, with its scheduled runs answered on this computer:
111
122
  * due local jobs join the inbox, and their ack and completion are recorded
@@ -69,7 +69,7 @@ function readLocalJobsForAdd(dir) {
69
69
  }
70
70
  return parsed.jobs.filter((job) => !!job && typeof job.jobId === "string");
71
71
  }
72
- function writeLocalJobs(dir, jobs) {
72
+ export function writeLocalJobs(dir, jobs) {
73
73
  const file = localJobsPath(dir);
74
74
  const tmp = `${file}.${process.pid}.tmp`;
75
75
  fs.writeFileSync(tmp, JSON.stringify({ v: 1, jobs }, null, 2));
@@ -170,8 +170,9 @@ function replaceNames(field, names) {
170
170
  }
171
171
  /**
172
172
  * The job's schedule as a standard 5-field cron. Jobs are stored in the same
173
- * form the cloud uses (`cron(m h dom mon dow year)`, Sunday = 1, `?` for the
174
- * unused day field), so the same `hq bot jobs` input works for either home.
173
+ * six-field form hosted agent jobs use (`cron(m h dom mon dow year)`, Sunday =
174
+ * 1, `?` for the unused day field), so the same `hq bot jobs` input works for
175
+ * either home.
175
176
  */
176
177
  export function toStandardCron(expr) {
177
178
  const trimmed = expr.trim();
@@ -465,9 +466,21 @@ export function takeDueLocalJobRuns(dir, now = new Date()) {
465
466
  return items;
466
467
  });
467
468
  }
468
- function isLocalJobMessage(messageId) {
469
+ /** A run of a job in this bot's own jobs.json (its ack and completion stay local). */
470
+ export function isLocalJobMessage(messageId) {
469
471
  return messageId.startsWith(LOCAL_JOB_MESSAGE_PREFIX);
470
472
  }
473
+ /**
474
+ * The message id of the bot's one automatic retry of a local job run (the
475
+ * signals job after a transient failure). It keeps the local prefix, so its
476
+ * ack and completion stay on this computer like any other local run.
477
+ */
478
+ export function localJobRetryMessageId(jobId, dueMs) {
479
+ return `${LOCAL_JOB_MESSAGE_PREFIX}${jobId}-retry-${dueMs}`;
480
+ }
481
+ export function isLocalJobRetryMessage(messageId) {
482
+ return isLocalJobMessage(messageId) && /-retry-\d+$/.test(messageId);
483
+ }
471
484
  /** Record how a local run finished and release the job for its next fire. */
472
485
  function finishLocalJobRun(dir, jobId, messageId, completion, now = new Date()) {
473
486
  withJobsLock(dir, () => {
@@ -70,6 +70,16 @@ export declare const PRE_TOOL_ASIDE_MAX_CHARS = 40;
70
70
  * your agency docs now, then I'll look at the client folders.") and run past
71
71
  * the limit; an hq-block is always content the desktop app must draw.
72
72
  */
73
+ /**
74
+ * A heads-up the model writes right before a long piece of work ("Give me
75
+ * about two minutes to check what's on this computer, then I'll ask my
76
+ * first question."). A bot that drops every pre-tool message
77
+ * (`dropPreToolMessages`) still lets ONE of these through per turn, so the
78
+ * person hears what is happening and how long it takes instead of watching
79
+ * a spinner. Short, one or two sentences, names a wait, no hq-block.
80
+ */
81
+ export declare const WAIT_HEADS_UP_MAX_CHARS = 240;
82
+ export declare function isWaitHeadsUp(text: string): boolean;
73
83
  export declare function isPreToolAside(text: string): boolean;
74
84
  /** Whitespace-insensitive form used to recognise repeated text. */
75
85
  export declare function normalizeForCompare(text: string): string;
@@ -87,6 +97,8 @@ export declare class ProgressPoster {
87
97
  private readonly workingNoticeText;
88
98
  private readonly buffer;
89
99
  private readonly dropPreTool;
100
+ /** With dropPreToolMessages: one wait heads-up (isWaitHeadsUp) per turn is kept. */
101
+ private headsUpKept;
90
102
  private readonly onDropped;
91
103
  /**
92
104
  * Messages waiting to learn whether a tool call follows: every message when
@@ -119,6 +131,8 @@ export declare class ProgressPoster {
119
131
  constructor(opts: ProgressPosterOptions);
120
132
  /** Arm the one-time "working on it" note. Call when the turn starts. */
121
133
  start(): void;
134
+ /** Arm the still-working line again after a kept heads-up (start() skips a turn that said something). */
135
+ private rearmNoticeAfter;
122
136
  /** One finished assistant message from the model. */
123
137
  message(text: string): void;
124
138
  /** The model called a tool: whatever it wrote just before was a note to itself. */