@indigoai-us/hq-cli 5.345.177 → 5.345.179

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 (100) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/command-catalog.generated.d.ts +79 -1
  3. package/dist/command-catalog.generated.js +96 -1
  4. package/dist/commands/bot-jobs.d.ts +11 -9
  5. package/dist/commands/bot-jobs.js +56 -21
  6. package/dist/commands/bot.d.ts +13 -0
  7. package/dist/commands/bot.js +230 -21
  8. package/dist/commands/cloud.d.ts +49 -0
  9. package/dist/commands/cloud.js +155 -15
  10. package/dist/commands/daemon.d.ts +2 -0
  11. package/dist/commands/daemon.js +15 -4
  12. package/dist/commands/dm-attach.js +15 -3
  13. package/dist/commands/integrations-core.d.ts +2 -0
  14. package/dist/commands/integrations-core.js +1 -0
  15. package/dist/commands/library-add.d.ts +9 -0
  16. package/dist/commands/library-add.js +19 -2
  17. package/dist/commands/run.js +9 -1
  18. package/dist/commands/sources.d.ts +89 -0
  19. package/dist/commands/sources.js +363 -1
  20. package/dist/lib/bot/config.d.ts +2 -0
  21. package/dist/lib/bot/fleet-default-flag.d.ts +31 -0
  22. package/dist/lib/bot/fleet-default-flag.js +127 -0
  23. package/dist/lib/bot/fleet-local.d.ts +30 -1
  24. package/dist/lib/bot/fleet-local.js +86 -11
  25. package/dist/lib/bot/index.d.ts +1 -0
  26. package/dist/lib/bot/index.js +1 -0
  27. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +109 -0
  28. package/dist/lib/bot/jobs/hot-signal-sync.js +177 -0
  29. package/dist/lib/bot/local-config.js +1 -1
  30. package/dist/lib/bot/local-jobs.d.ts +40 -4
  31. package/dist/lib/bot/local-jobs.js +72 -5
  32. package/dist/lib/bot/run.d.ts +15 -0
  33. package/dist/lib/bot/run.js +61 -0
  34. package/dist/lib/daemon/catalog.d.ts +2 -0
  35. package/dist/lib/daemon/catalog.js +7 -2
  36. package/dist/lib/daemon/config.d.ts +2 -1
  37. package/dist/lib/daemon/config.js +4 -3
  38. package/dist/lib/daemon/handoff.d.ts +4 -0
  39. package/dist/lib/daemon/handoff.js +6 -2
  40. package/dist/lib/daemon/install.d.ts +70 -2
  41. package/dist/lib/daemon/install.js +185 -27
  42. package/dist/lib/daemon/legacy.d.ts +9 -3
  43. package/dist/lib/daemon/legacy.js +11 -4
  44. package/dist/lib/daemon/migrate.js +1 -1
  45. package/dist/lib/daemon/run.d.ts +3 -2
  46. package/dist/lib/daemon/run.js +10 -6
  47. package/dist/lib/doctor/checks/anywhere.d.ts +6 -0
  48. package/dist/lib/doctor/checks/anywhere.js +35 -6
  49. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.d.ts +33 -0
  50. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.js +53 -0
  51. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.d.ts +38 -0
  52. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.js +64 -0
  53. package/dist/lib/hot-channels/adapters/attio.d.ts +67 -0
  54. package/dist/lib/hot-channels/adapters/attio.js +374 -0
  55. package/dist/lib/hot-channels/adapters/gmail.d.ts +111 -0
  56. package/dist/lib/hot-channels/adapters/gmail.js +413 -0
  57. package/dist/lib/hot-channels/adapters/index.d.ts +23 -0
  58. package/dist/lib/hot-channels/adapters/index.js +39 -0
  59. package/dist/lib/hot-channels/adapters/linear.d.ts +79 -0
  60. package/dist/lib/hot-channels/adapters/linear.js +321 -0
  61. package/dist/lib/hot-channels/adapters/meeting-providers.d.ts +86 -0
  62. package/dist/lib/hot-channels/adapters/meeting-providers.js +253 -0
  63. package/dist/lib/hot-channels/adapters/meetings.d.ts +81 -0
  64. package/dist/lib/hot-channels/adapters/meetings.js +523 -0
  65. package/dist/lib/hot-channels/adapters/slack.d.ts +70 -0
  66. package/dist/lib/hot-channels/adapters/slack.js +277 -0
  67. package/dist/lib/hot-channels/alert.d.ts +78 -0
  68. package/dist/lib/hot-channels/alert.js +179 -0
  69. package/dist/lib/hot-channels/audience.d.ts +15 -0
  70. package/dist/lib/hot-channels/audience.js +25 -0
  71. package/dist/lib/hot-channels/contract.d.ts +140 -0
  72. package/dist/lib/hot-channels/contract.js +20 -0
  73. package/dist/lib/hot-channels/cursors.d.ts +41 -0
  74. package/dist/lib/hot-channels/cursors.js +84 -0
  75. package/dist/lib/hot-channels/index.d.ts +10 -0
  76. package/dist/lib/hot-channels/index.js +10 -0
  77. package/dist/lib/hot-channels/landing.d.ts +83 -0
  78. package/dist/lib/hot-channels/landing.js +175 -0
  79. package/dist/lib/hot-channels/ledger.d.ts +101 -0
  80. package/dist/lib/hot-channels/ledger.js +186 -0
  81. package/dist/lib/hot-channels/loop.d.ts +74 -0
  82. package/dist/lib/hot-channels/loop.js +219 -0
  83. package/dist/lib/hot-channels/prompts/signals-pass.md +22 -0
  84. package/dist/lib/hot-channels/registry.d.ts +28 -0
  85. package/dist/lib/hot-channels/registry.js +121 -0
  86. package/dist/lib/hot-channels/run.d.ts +137 -0
  87. package/dist/lib/hot-channels/run.js +324 -0
  88. package/dist/lib/hot-channels/signals-pass.d.ts +150 -0
  89. package/dist/lib/hot-channels/signals-pass.js +581 -0
  90. package/dist/lib/hot-channels/sync-lock.d.ts +51 -0
  91. package/dist/lib/hot-channels/sync-lock.js +224 -0
  92. package/dist/lib/install/hook-target.d.ts +6 -2
  93. package/dist/lib/install/hook-target.js +9 -4
  94. package/dist/lib/lanes/loop.d.ts +10 -0
  95. package/dist/lib/lanes/loop.js +18 -1
  96. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -0
  97. package/dist/lib/mesh/live/daemon/work-subscribe.js +26 -6
  98. package/dist/run/hq-plugin.d.ts +3 -0
  99. package/dist/run/hq-plugin.js +18 -0
  100. package/package.json +7 -7
@@ -7,20 +7,46 @@ import { HQ_FLEET_RUNTIME_ENGINE_ID, } from "./config.js";
7
7
  import { ensureFleetPython, fleetUvEnv } from "./fleet-python.js";
8
8
  import { ensureCognitoToken } from "../../utils/cognito-session.js";
9
9
  import { vaultApiFetch } from "../../utils/vault-api.js";
10
+ /** The model the HQ Fleet Runtime asks for when a Claude bot names none. */
11
+ export const FLEET_CLAUDE_DEFAULT_MODEL = "claude-opus-5-5";
12
+ /**
13
+ * The runtime provider for a bot created from a model CLI choice. Claude uses
14
+ * the runtime's native Anthropic provider, which reads the owner's Claude Code
15
+ * login, so it needs no sign-in of its own; ChatGPT and Grok sign in with
16
+ * `hq bot auth`.
17
+ */
18
+ export function fleetProviderForRuntime(runtime, model) {
19
+ if (runtime === "claude") {
20
+ const requested = model?.trim() ?? "";
21
+ return { id: "anthropic", model: /^claude-[a-z0-9.-]+$/.test(requested) ? requested : FLEET_CLAUDE_DEFAULT_MODEL };
22
+ }
23
+ const preset = providerProfiles[runtime === "grok" ? "grok" : "chatgpt"];
24
+ return {
25
+ id: preset.provider,
26
+ model: model?.trim() || preset.model,
27
+ credentialProvider: preset.runtimeProvider,
28
+ baseUrl: preset.baseUrl,
29
+ transport: preset.transport,
30
+ };
31
+ }
32
+ /** True when a bot with this provider still needs `hq bot auth` before it can answer. */
33
+ export function fleetProviderNeedsSignIn(provider) {
34
+ return provider.id !== "anthropic";
35
+ }
10
36
  export const HQ_FLEET_LOCAL_RELEASE = {
11
37
  repository: "indigoai-us/hq-fleet-local",
12
- tag: "v0.4.6",
13
- version: "0.4.6",
14
- artifact: "hq-fleet-local-0.4.6.zip",
15
- artifactSha256: "24e48887dd4450ad63eda11e61f78363b0cc53b3fc87e3fd21bfe97e4a430865",
38
+ tag: "v0.4.13",
39
+ version: "0.4.13",
40
+ artifact: "hq-fleet-local-0.4.13.zip",
41
+ artifactSha256: "81b129ac129578240af9091b06ae190f47f6be38ff933b35ae5459d5b78c72df",
16
42
  runtimeArtifact: "hq-fleet-runtime-abccac13bd9e.tar",
17
43
  runtimeArtifactSha256: "29bcfe718ad7fbd3636afd00623772f2a25bc84c4f6f10d8d57f38e549d528d5",
18
- manifestSha256: "5cc06b30a724419c22f4ab116db1ae745158d33c4aa46c7be04d29639172af1d",
19
- signatureSha256: "b7a6c771dc674423b56db36f58b5a2a2a49eb2e27b33a41206fcc228a11d1def",
44
+ manifestSha256: "42654f91264099ce173ce3a9f42e207f566e1412ea4b550dc21775d764327bbb",
45
+ signatureSha256: "93cf9b0a94d75c532454243e3391e421e2743f59f8e65989a5a56ec9152e2fa4",
20
46
  runtimeCommit: "abccac13bd9ea593b24c8cd9c0ba28d5c6050cc9",
21
47
  };
22
48
  export const HQ_FLEET_LOCAL_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
23
- MCowBQYDK2VwAyEAOOL7NRKQgjMYI5cWxLY+HGppXf2AtwpQbCg5v+nBksM=
49
+ MCowBQYDK2VwAyEAbYvc1pwOmiCUPJv8yIa3E9RXfniFA84nH2+ROmLMGZU=
24
50
  -----END PUBLIC KEY-----
25
51
  `;
26
52
  function digest(value) {
@@ -417,7 +443,9 @@ function replaceProfileProvider(profilePath, agentUid, provider) {
417
443
  const credentialProviders = Array.isArray(profile.credentialProviders)
418
444
  ? profile.credentialProviders.filter((value) => typeof value === "string")
419
445
  : [];
420
- profile.credentialProviders = [...new Set([...credentialProviders, provider.credentialProvider])];
446
+ profile.credentialProviders = [
447
+ ...new Set([...credentialProviders, ...(provider.credentialProvider ? [provider.credentialProvider] : [])]),
448
+ ];
421
449
  const temporary = `${profilePath}.${process.pid}.${crypto.randomUUID()}`;
422
450
  fs.writeFileSync(temporary, `${JSON.stringify(profile, null, 2)}\n`, { mode: 0o600 });
423
451
  fs.renameSync(temporary, profilePath);
@@ -498,6 +526,16 @@ export function authenticateFleetLocalBot(input, deps = {}) {
498
526
  if (!/^[a-z0-9][a-z0-9._:-]*$/.test(requested)) {
499
527
  throw expectedError("HQ Fleet provider identifier is invalid.");
500
528
  }
529
+ if ((requested === "claude" || requested === "anthropic") && !input.apiKey) {
530
+ const files = fleetLocalExecutables(input.agentUid, deps);
531
+ const run = deps.run ?? execFileSync;
532
+ const native = fleetProviderForRuntime("claude", input.model);
533
+ replaceProfileProvider(files.profile, input.agentUid, native);
534
+ runJson(run, files.helper, ["render-profile", "--spec", files.profile, "--mode", "update"], { stdio: ["ignore", "pipe", "pipe"] }, "profile update");
535
+ runJson(run, files.helper, ["doctor-profile", "--profile", files.profileDir, "--runtime", files.runtime], { stdio: ["ignore", "pipe", "pipe"] }, "profile validation");
536
+ const restarted = controlFleetLocalBot(input.agentUid, input.installService ? "install" : "restart", deps).ok === true;
537
+ return { provider: requested, runtimeProvider: "anthropic", model: native.model, authenticated: true, restarted };
538
+ }
501
539
  const known = providerProfiles[requested];
502
540
  const provider = known ?? {
503
541
  provider: `custom:personal-${requested}`,
@@ -558,7 +596,7 @@ export function authenticateFleetLocalBot(input, deps = {}) {
558
596
  });
559
597
  runJson(run, files.helper, ["render-profile", "--spec", files.profile, "--mode", "update"], { stdio: ["ignore", "pipe", "pipe"] }, "profile update");
560
598
  runJson(run, files.helper, ["doctor-profile", "--profile", files.profileDir, "--runtime", files.runtime], { stdio: ["ignore", "pipe", "pipe"] }, "profile validation");
561
- const restarted = controlFleetLocalBot(input.agentUid, "restart", deps).ok === true;
599
+ const restarted = controlFleetLocalBot(input.agentUid, input.installService ? "install" : "restart", deps).ok === true;
562
600
  return {
563
601
  provider: requested,
564
602
  runtimeProvider: provider.runtimeProvider,
@@ -583,7 +621,8 @@ export function writeFleetLocalProfile(input, deps = {}) {
583
621
  schemaVersion: 1,
584
622
  botId: input.agentUid,
585
623
  ownerUid: input.ownerUid,
586
- provider: { id: "custom:personal-chatgpt", model: "gpt-5.6-sol" },
624
+ ...(input.ownerCompanies?.length ? { ownerCompanies: [...new Set(input.ownerCompanies)].sort() } : {}),
625
+ provider: input.provider ?? { id: "custom:personal-chatgpt", model: "gpt-5.6-sol" },
587
626
  hqRoot: path.resolve(input.hqRoot),
588
627
  profileDir: path.resolve(dir),
589
628
  runtime: { commit: input.installation.release.runtimeCommit, path: path.resolve(runtimeDir) },
@@ -599,6 +638,24 @@ export function writeFleetLocalProfile(input, deps = {}) {
599
638
  fs.renameSync(temporary, destination);
600
639
  if (input.systemPrompt !== undefined)
601
640
  writeFleetLocalSoul(input.agentUid, input.systemPrompt, { fleetHome: root });
641
+ if (input.firstStart && (input.firstStart.intro || input.firstStart.kickoff)) {
642
+ writeFleetLocalFirstStart(dir, input.firstStart);
643
+ }
644
+ return destination;
645
+ }
646
+ /** The runtime's inbox plugin sends the intro and runs the kickoff once, then stamps this file. */
647
+ export function writeFleetLocalFirstStart(profileDir, firstStart) {
648
+ const destination = path.join(profileDir, "first-start.json");
649
+ if (fs.existsSync(destination))
650
+ return destination;
651
+ const temporary = path.join(profileDir, `.first-start-${process.pid}-${crypto.randomUUID()}`);
652
+ const record = {
653
+ schemaVersion: 1,
654
+ ...(firstStart.intro ? { intro: firstStart.intro } : {}),
655
+ ...(firstStart.kickoff ? { kickoff: firstStart.kickoff } : {}),
656
+ };
657
+ fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
658
+ fs.renameSync(temporary, destination);
602
659
  return destination;
603
660
  }
604
661
  export function writeFleetLocalSoul(agentUid, systemPrompt, deps = {}) {
@@ -669,6 +726,24 @@ function checkedRun(run, executable, args, options, label) {
669
726
  throw expectedError(`HQ Fleet Local ${label} failed.`);
670
727
  }
671
728
  }
729
+ /** Moves a fresh machine credential into the bot's secure store and deletes the file. */
730
+ export function importFleetLocalMachineCredential(agentUid, machineCredentials, deps = {}) {
731
+ const run = deps.run ?? execFileSync;
732
+ const files = fleetLocalExecutables(agentUid, deps);
733
+ const machineValue = fs.readFileSync(machineCredentials, "utf8").trim();
734
+ if (!machineValue) {
735
+ throw expectedError(`HQ Fleet Local machine credential file is empty: ${machineCredentials}`);
736
+ }
737
+ checkedRun(run, files.helper, [
738
+ "machine-credential",
739
+ "import",
740
+ "--profile",
741
+ files.profileDir,
742
+ "--bot-id",
743
+ agentUid,
744
+ ], { input: machineValue, stdio: ["pipe", "ignore", "pipe"] }, "machine credential import");
745
+ fs.rmSync(machineCredentials, { force: true });
746
+ }
672
747
  export async function bootstrapFleetLocalBot(input, deps = {}) {
673
748
  const run = deps.run ?? execFileSync;
674
749
  const platform = deps.platform ?? process.platform;
@@ -716,7 +791,7 @@ export async function bootstrapFleetLocalBot(input, deps = {}) {
716
791
  "--python",
717
792
  runtimePython,
718
793
  "--editable",
719
- `${runtimeSource}[all,messaging,slack,anthropic]`,
794
+ `${runtimeSource}[mcp,anthropic]`,
720
795
  distributionSource,
721
796
  ], { stdio: ["ignore", 2, 2], env: uvEnv }, "package installation");
722
797
  fs.writeFileSync(marker, `${JSON.stringify({ schemaVersion: 1, engine: input.installation.engine }, null, 2)}\n`, { mode: 0o600 });
@@ -26,5 +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
30
  export * from "./fleet-python.js";
30
31
  //# sourceMappingURL=index.d.ts.map
@@ -26,5 +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
30
  export * from "./fleet-python.js";
30
31
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The bot's built-in hot-signal-sync job.
3
+ *
4
+ * A local bot that is bound to a company registers one recurring job in its
5
+ * own `jobs.json` the first time it starts (`ensureHotSignalSyncJob`). The job
6
+ * fires from the bot's inbox loop like every other local job
7
+ * (lib/bot/local-jobs.ts): no crontab, launchd or systemd entry is written.
8
+ * The default schedule is 06:30 daily in the machine's time zone, ahead of a
9
+ * morning brief; the owner changes it with
10
+ * `hq bot jobs <bot> --reschedule <jobId> --every "<when>"`, or removes it with
11
+ * `--rm`, and a removed job is not added back on the next start.
12
+ *
13
+ * When the job fires the bot runs the sync in-process, with no model turn
14
+ * (`runHotSignalSyncJob`): the same hot-channel sync `hq sources sync` runs,
15
+ * with `trigger: "scheduled"` and `catchUp` set when the run fired late after
16
+ * a missed window. Catch-up is bounded by each adapter's `catchUpWindow`, and
17
+ * per-connection cursors keep a redelivered run from landing anything twice.
18
+ * The claude signals pass (US-011) runs after each adapter when the bot wires
19
+ * one in; with none the signals count is 0.
20
+ */
21
+ import type { HotChannelRunResult, WriteGuard } from "../../hot-channels/loop.js";
22
+ import type { RunTrigger } from "../../hot-channels/ledger.js";
23
+ import type { OnDemandSyncReport } from "../../hot-channels/run.js";
24
+ import { type BotConfig } from "../config.js";
25
+ import { type LocalJob } from "../local-jobs.js";
26
+ export declare const HOT_SIGNAL_SYNC_JOB_KIND = "hot-signal-sync";
27
+ /**
28
+ * 06:30 every day, in the job's time zone (the stored EventBridge form of
29
+ * "30 6 * * *"). Only the first registration uses it; after that the job's
30
+ * own schedule in `jobs.json` is the one that runs.
31
+ */
32
+ export declare const HOT_SIGNAL_SYNC_DEFAULT_CRON = "cron(30 6 * * ? *)";
33
+ /** The private context that marks a job as this built-in, for one company. */
34
+ export declare function hotSignalSyncJobContext(company: string): Record<string, unknown>;
35
+ /** The company a job item syncs, when its context marks it as this built-in; else null. */
36
+ export declare function hotSignalSyncJobCompany(context: unknown): string | null;
37
+ /**
38
+ * The company a bot's hot-signal-sync job syncs: the bot's own company, else
39
+ * the first company it serves. Null = none, and no job is registered. There is
40
+ * no fallback to the HQ's active company: a personal bot syncs a company only
41
+ * when it was given one explicitly (`--company`), so the job never writes into
42
+ * a vault the bot does not belong to. Checked again each time the job fires.
43
+ */
44
+ export declare function currentHotSignalSyncCompany(config: Pick<BotConfig, "companySlug" | "kind" | "companies" | "workerId">): string | null;
45
+ /**
46
+ * Remove every hot-signal-sync job whose company is not `current` (all of them
47
+ * when `current` is null), and forget their registration so the company can be
48
+ * registered again if the bot is given it back. Returns the removed jobs.
49
+ */
50
+ export declare function removeStaleHotSignalSyncJobs(dir: string, current: string | null): LocalJob[];
51
+ export interface EnsureHotSignalSyncJobResult {
52
+ /** The job, or null when the owner removed it after it was first registered. */
53
+ job: LocalJob | null;
54
+ created: boolean;
55
+ }
56
+ /**
57
+ * Register the hot-signal-sync job for `company` once. Safe to call on every
58
+ * bot start: an existing job is left as it is (including a changed schedule),
59
+ * and a job the owner removed is not added again.
60
+ */
61
+ export declare function ensureHotSignalSyncJob(dir: string, input: {
62
+ company: string;
63
+ timezone: string;
64
+ }, now?: Date): EnsureHotSignalSyncJobResult;
65
+ /**
66
+ * The signals pass over one adapter's landed sources: how many signals it
67
+ * generated. The bot wires the local `claude -p` pass
68
+ * (`createClaudeSignalsPass`); `noLocalSignalsPass` is the default when no
69
+ * pass is given.
70
+ */
71
+ export type LocalSignalsPass = (result: HotChannelRunResult, guard?: WriteGuard) => Promise<number>;
72
+ export declare const noLocalSignalsPass: LocalSignalsPass;
73
+ /** One company's hot-channel sync, as `runCompanyHotChannelSync` runs it (minus the HQ root). */
74
+ export type HotSignalSyncFn = (input: {
75
+ company: string;
76
+ trigger: RunTrigger;
77
+ catchUp: boolean;
78
+ signalsGenerated: LocalSignalsPass;
79
+ now: () => Date;
80
+ /** The owner DM for the signals-failure alert (US-012); unset = no alert check. */
81
+ signalsAlert?: {
82
+ sendOwnerDm: (text: string) => Promise<void>;
83
+ log?: (message: string) => void;
84
+ };
85
+ }) => Promise<OnDemandSyncReport>;
86
+ export interface HotSignalSyncRunInput {
87
+ company: string;
88
+ /** The run fired late, after a missed window (job-run.ts `isCatchUpRun`). */
89
+ catchUp: boolean;
90
+ now: () => Date;
91
+ sync: HotSignalSyncFn;
92
+ signalsPass?: LocalSignalsPass;
93
+ /**
94
+ * Sends the signals-failure alert to the owner as its own message, even when
95
+ * the job is quiet. Throws when the message was not delivered.
96
+ */
97
+ sendAlert?: (text: string) => Promise<void>;
98
+ log?: (message: string) => void;
99
+ }
100
+ export interface HotSignalSyncRunOutcome {
101
+ ok: boolean;
102
+ /** Plain report for the owner's DM and the job's run output. */
103
+ text: string;
104
+ report: OnDemandSyncReport | null;
105
+ }
106
+ /** Run the scheduled sync once and describe it in plain words. Never throws. */
107
+ export declare function runHotSignalSyncJob(input: HotSignalSyncRunInput): Promise<HotSignalSyncRunOutcome>;
108
+ export declare function describeSyncReport(company: string, report: OnDemandSyncReport): string;
109
+ //# sourceMappingURL=hot-signal-sync.d.ts.map
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The bot's built-in hot-signal-sync job.
3
+ *
4
+ * A local bot that is bound to a company registers one recurring job in its
5
+ * own `jobs.json` the first time it starts (`ensureHotSignalSyncJob`). The job
6
+ * fires from the bot's inbox loop like every other local job
7
+ * (lib/bot/local-jobs.ts): no crontab, launchd or systemd entry is written.
8
+ * The default schedule is 06:30 daily in the machine's time zone, ahead of a
9
+ * morning brief; the owner changes it with
10
+ * `hq bot jobs <bot> --reschedule <jobId> --every "<when>"`, or removes it with
11
+ * `--rm`, and a removed job is not added back on the next start.
12
+ *
13
+ * When the job fires the bot runs the sync in-process, with no model turn
14
+ * (`runHotSignalSyncJob`): the same hot-channel sync `hq sources sync` runs,
15
+ * with `trigger: "scheduled"` and `catchUp` set when the run fired late after
16
+ * a missed window. Catch-up is bounded by each adapter's `catchUpWindow`, and
17
+ * per-connection cursors keep a redelivered run from landing anything twice.
18
+ * The claude signals pass (US-011) runs after each adapter when the bot wires
19
+ * one in; with none the signals count is 0.
20
+ */
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { JOB_RUN_OPTIONS_CONTEXT_KEY } from "../job-run.js";
24
+ import { effectiveBotCompanies } from "../config.js";
25
+ import { ensureLocalJob, readLocalJobs, removeLocalJobsWhere, withJobsLock } from "../local-jobs.js";
26
+ export const HOT_SIGNAL_SYNC_JOB_KIND = "hot-signal-sync";
27
+ /**
28
+ * 06:30 every day, in the job's time zone (the stored EventBridge form of
29
+ * "30 6 * * *"). Only the first registration uses it; after that the job's
30
+ * own schedule in `jobs.json` is the one that runs.
31
+ */
32
+ export const HOT_SIGNAL_SYNC_DEFAULT_CRON = "cron(30 6 * * ? *)";
33
+ /** Which built-in jobs this bot has registered once, so a removed one stays removed. */
34
+ const BUILTIN_JOBS_FILE = "builtin-jobs.json";
35
+ function markerKey(company) {
36
+ return `${HOT_SIGNAL_SYNC_JOB_KIND}:${company}`;
37
+ }
38
+ function readMarker(dir) {
39
+ try {
40
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, BUILTIN_JOBS_FILE), "utf-8"));
41
+ if (parsed && parsed.v === 1 && parsed.registered && typeof parsed.registered === "object") {
42
+ return { v: 1, registered: { ...parsed.registered } };
43
+ }
44
+ }
45
+ catch {
46
+ // Missing or unreadable: nothing registered yet.
47
+ }
48
+ return { v: 1, registered: {} };
49
+ }
50
+ function writeMarker(dir, marker) {
51
+ const file = path.join(dir, BUILTIN_JOBS_FILE);
52
+ const tmp = `${file}.${process.pid}.tmp`;
53
+ fs.writeFileSync(tmp, JSON.stringify(marker, null, 2) + "\n", { mode: 0o600 });
54
+ fs.renameSync(tmp, file);
55
+ }
56
+ /** The private context that marks a job as this built-in, for one company. */
57
+ export function hotSignalSyncJobContext(company) {
58
+ return { [JOB_RUN_OPTIONS_CONTEXT_KEY]: { builtin: HOT_SIGNAL_SYNC_JOB_KIND, company } };
59
+ }
60
+ /** The company a job item syncs, when its context marks it as this built-in; else null. */
61
+ export function hotSignalSyncJobCompany(context) {
62
+ if (!context || typeof context !== "object" || Array.isArray(context))
63
+ return null;
64
+ const options = context[JOB_RUN_OPTIONS_CONTEXT_KEY];
65
+ if (!options || typeof options !== "object" || Array.isArray(options))
66
+ return null;
67
+ const { builtin, company } = options;
68
+ if (builtin !== HOT_SIGNAL_SYNC_JOB_KIND)
69
+ return null;
70
+ return typeof company === "string" && company.trim() ? company.trim() : null;
71
+ }
72
+ /**
73
+ * The company a bot's hot-signal-sync job syncs: the bot's own company, else
74
+ * the first company it serves. Null = none, and no job is registered. There is
75
+ * no fallback to the HQ's active company: a personal bot syncs a company only
76
+ * when it was given one explicitly (`--company`), so the job never writes into
77
+ * a vault the bot does not belong to. Checked again each time the job fires.
78
+ */
79
+ export function currentHotSignalSyncCompany(config) {
80
+ return config.companySlug ?? effectiveBotCompanies(config)[0] ?? null;
81
+ }
82
+ /**
83
+ * Remove every hot-signal-sync job whose company is not `current` (all of them
84
+ * when `current` is null), and forget their registration so the company can be
85
+ * registered again if the bot is given it back. Returns the removed jobs.
86
+ */
87
+ export function removeStaleHotSignalSyncJobs(dir, current) {
88
+ const removed = removeLocalJobsWhere(dir, (job) => {
89
+ const company = hotSignalSyncJobCompany(job.context);
90
+ return company !== null && company !== current;
91
+ });
92
+ const keep = current ? markerKey(current) : null;
93
+ withJobsLock(dir, () => {
94
+ const marker = readMarker(dir);
95
+ let changed = false;
96
+ for (const key of Object.keys(marker.registered)) {
97
+ if (key.startsWith(HOT_SIGNAL_SYNC_JOB_KIND + ":") && key !== keep) {
98
+ delete marker.registered[key];
99
+ changed = true;
100
+ }
101
+ }
102
+ if (changed)
103
+ writeMarker(dir, marker);
104
+ });
105
+ return removed;
106
+ }
107
+ /**
108
+ * Register the hot-signal-sync job for `company` once. Safe to call on every
109
+ * bot start: an existing job is left as it is (including a changed schedule),
110
+ * and a job the owner removed is not added again.
111
+ */
112
+ export function ensureHotSignalSyncJob(dir, input, now = new Date()) {
113
+ const key = markerKey(input.company);
114
+ const matches = (job) => hotSignalSyncJobCompany(job.context) === input.company;
115
+ if (readMarker(dir).registered[key]) {
116
+ return { job: readLocalJobs(dir).find(matches) ?? null, created: false };
117
+ }
118
+ const { job, created } = ensureLocalJob(dir, matches, {
119
+ prompt: `Sync hot channels for ${input.company} and run the signals pass (runs in the bot, no model turn).`,
120
+ cron: HOT_SIGNAL_SYNC_DEFAULT_CRON,
121
+ timezone: input.timezone,
122
+ context: hotSignalSyncJobContext(input.company),
123
+ }, now);
124
+ withJobsLock(dir, () => {
125
+ const marker = readMarker(dir);
126
+ if (!marker.registered[key]) {
127
+ marker.registered[key] = now.toISOString();
128
+ writeMarker(dir, marker);
129
+ }
130
+ });
131
+ return { job, created };
132
+ }
133
+ export const noLocalSignalsPass = async () => 0;
134
+ /** Run the scheduled sync once and describe it in plain words. Never throws. */
135
+ export async function runHotSignalSyncJob(input) {
136
+ const signalsPass = input.signalsPass ?? noLocalSignalsPass;
137
+ let report;
138
+ try {
139
+ report = await input.sync({
140
+ company: input.company,
141
+ trigger: "scheduled",
142
+ catchUp: input.catchUp,
143
+ signalsGenerated: signalsPass,
144
+ now: input.now,
145
+ ...(input.sendAlert
146
+ ? { signalsAlert: { sendOwnerDm: input.sendAlert, ...(input.log ? { log: input.log } : {}) } }
147
+ : {}),
148
+ });
149
+ }
150
+ catch (err) {
151
+ const reason = err instanceof Error ? err.message : String(err);
152
+ return { ok: false, text: `The hot-channel sync for ${input.company} failed: ${reason}`, report: null };
153
+ }
154
+ return { ok: report.exitCode === 0 && !report.signInRequired, text: describeSyncReport(input.company, report), report };
155
+ }
156
+ export function describeSyncReport(company, report) {
157
+ if (report.signInRequired) {
158
+ return `The hot-channel sync for ${company} did not run: ${report.notices.join(" ")}`;
159
+ }
160
+ if (report.skipped)
161
+ return report.notices.join(" ");
162
+ const landed = report.outcomes.reduce((sum, o) => sum + o.landed, 0);
163
+ const signals = report.entries.reduce((sum, e) => sum + e.signalsGenerated, 0);
164
+ const lines = [
165
+ `Hot-channel sync for ${company}: ${landed} new source${landed === 1 ? "" : "s"}, ${signals} signal${signals === 1 ? "" : "s"}.`,
166
+ ];
167
+ for (const outcome of report.outcomes) {
168
+ if (outcome.status === "ok")
169
+ continue;
170
+ const label = outcome.appName ?? outcome.adapter;
171
+ lines.push(`- ${label}: ${outcome.reconnect?.prompt ?? outcome.message ?? outcome.status}`);
172
+ }
173
+ for (const notice of report.notices)
174
+ lines.push(notice);
175
+ return lines.join("\n");
176
+ }
177
+ //# sourceMappingURL=hot-signal-sync.js.map
@@ -41,7 +41,7 @@ export function buildBotLocalConfig(config) {
41
41
  const companies = effectiveBotCompanies(config);
42
42
  const local = {
43
43
  v: 1,
44
- ...(config.engine ? {} : { runtime: config.runtime }),
44
+ runtime: config.runtime,
45
45
  ...(config.engine ? { engine: config.engine } : {}),
46
46
  workerSource: config.workerSource ?? "scaffold",
47
47
  ...(config.workerId ? { workerId: config.workerId } : {}),
@@ -18,7 +18,7 @@
18
18
  * - A fire is recorded before it is handed over, so a crash never runs the
19
19
  * same due time twice.
20
20
  */
21
- import { type BotApi, type InboxItem } from "./api.js";
21
+ import { type BotApi, type BotJobRecord, type InboxItem } from "./api.js";
22
22
  export interface LocalJob {
23
23
  jobId: string;
24
24
  prompt: string;
@@ -27,6 +27,12 @@ export interface LocalJob {
27
27
  timezone: string;
28
28
  context?: Record<string, unknown>;
29
29
  createdAt: string;
30
+ /**
31
+ * When the schedule was last changed (`hq bot jobs --reschedule`). Fire
32
+ * times before it are never treated as missed, so a new schedule does not
33
+ * fire a catch-up for a time that only matches the new expression.
34
+ */
35
+ scheduledFrom?: string;
30
36
  /** The newest due time already handed to the bot. */
31
37
  lastFiredAt?: string;
32
38
  /** The run in progress, if any. */
@@ -72,6 +78,29 @@ export declare function addLocalJob(dir: string, input: {
72
78
  timezone: string;
73
79
  context?: Record<string, unknown>;
74
80
  }, now?: Date): LocalJob;
81
+ /**
82
+ * Add a job unless one already matches, as one locked read-and-write, so two
83
+ * bot starts racing each other still leave a single job.
84
+ */
85
+ export declare function ensureLocalJob(dir: string, matches: (job: LocalJob) => boolean, input: {
86
+ prompt: string;
87
+ cron: string;
88
+ timezone: string;
89
+ context?: Record<string, unknown>;
90
+ }, now?: Date): {
91
+ job: LocalJob;
92
+ created: boolean;
93
+ };
94
+ /**
95
+ * Change a job's schedule in place, keeping its id, instruction, context and
96
+ * run history. Returns null when the bot has no job with that id.
97
+ */
98
+ export declare function rescheduleLocalJob(dir: string, jobId: string, schedule: {
99
+ cron: string;
100
+ timezone: string;
101
+ }, now?: Date): LocalJob | null;
102
+ /** Remove every job `matches` picks, as one locked read-and-write. Returns the removed jobs. */
103
+ export declare function removeLocalJobsWhere(dir: string, matches: (job: LocalJob) => boolean): LocalJob[];
75
104
  /** Forget the once-per-minute check (tests, and a bot restarted in-process). */
76
105
  export declare function resetLocalJobChecks(): void;
77
106
  export declare function takeDueLocalJobRuns(dir: string, now?: Date): InboxItem[];
@@ -81,9 +110,16 @@ export declare function takeDueLocalJobRuns(dir: string, now?: Date): InboxItem[
81
110
  * locally instead of being sent to the cloud.
82
111
  */
83
112
  export declare function withLocalJobs(api: BotApi, dir: string, clock?: () => Date): BotApi;
113
+ /** What `hq bot jobs` uses: list, add, remove, and (local bots) reschedule. */
114
+ export type LocalJobsApi = Pick<BotApi, "listJobs" | "createJob" | "cancelJob"> & {
115
+ rescheduleJob?(agentUid: string, jobId: string, schedule: {
116
+ cron: string;
117
+ timezone: string;
118
+ }): Promise<BotJobRecord>;
119
+ };
84
120
  /**
85
- * `hq bot jobs` against the bot's own folder: the same three calls the cloud
86
- * client offers, answered from `jobs.json`.
121
+ * `hq bot jobs` against the bot's own folder: the same calls the cloud client
122
+ * offers, plus reschedule, answered from `jobs.json`.
87
123
  */
88
- export declare function localJobsClient(dir: string, clock?: () => Date): Pick<BotApi, "listJobs" | "createJob" | "cancelJob">;
124
+ export declare function localJobsClient(dir: string, clock?: () => Date): Required<LocalJobsApi>;
89
125
  //# sourceMappingURL=local-jobs.d.ts.map
@@ -237,10 +237,14 @@ function rateIntervalMs(expr) {
237
237
  function isRunnableSchedule(expr) {
238
238
  return rateIntervalMs(expr) !== null || parseCron(expr) !== null;
239
239
  }
240
+ /** Where the current schedule starts counting: its last change, else creation. */
241
+ function scheduleOrigin(job) {
242
+ return Date.parse(job.scheduledFrom ?? job.createdAt);
243
+ }
240
244
  export function latestDueFire(job, now) {
241
245
  const interval = rateIntervalMs(job.cron);
242
246
  if (interval) {
243
- const origin = Date.parse(job.createdAt);
247
+ const origin = scheduleOrigin(job);
244
248
  const steps = Math.floor((now.getTime() - origin) / interval);
245
249
  if (steps < 1)
246
250
  return null;
@@ -252,7 +256,7 @@ export function latestDueFire(job, now) {
252
256
  return null;
253
257
  const start = new Date(now);
254
258
  start.setUTCSeconds(0, 0);
255
- const floor = Math.max(job.lastFiredAt ? Date.parse(job.lastFiredAt) : Date.parse(job.createdAt), start.getTime() - LOOKBACK_MINUTES * 60_000);
259
+ const floor = Math.max(job.lastFiredAt ? Date.parse(job.lastFiredAt) : Date.parse(job.createdAt), job.scheduledFrom ? Date.parse(job.scheduledFrom) : 0, start.getTime() - LOOKBACK_MINUTES * 60_000);
256
260
  for (let t = start.getTime(); t > floor; t -= 60_000) {
257
261
  const at = new Date(t);
258
262
  if (cronMatches(cron, at, job.timezone))
@@ -264,7 +268,7 @@ export function latestDueFire(job, now) {
264
268
  export function nextDueFire(job, now) {
265
269
  const interval = rateIntervalMs(job.cron);
266
270
  if (interval) {
267
- const origin = Date.parse(job.createdAt);
271
+ const origin = scheduleOrigin(job);
268
272
  const steps = Math.max(0, Math.floor((now.getTime() - origin) / interval)) + 1;
269
273
  return new Date(origin + steps * interval);
270
274
  }
@@ -310,6 +314,53 @@ export function addLocalJob(dir, input, now = new Date()) {
310
314
  withJobsLock(dir, () => writeLocalJobs(dir, [...readLocalJobs(dir), job]));
311
315
  return job;
312
316
  }
317
+ /**
318
+ * Add a job unless one already matches, as one locked read-and-write, so two
319
+ * bot starts racing each other still leave a single job.
320
+ */
321
+ export function ensureLocalJob(dir, matches, input, now = new Date()) {
322
+ if (!isRunnableSchedule(input.cron))
323
+ throw new Error(`"${input.cron}" is not a schedule this bot can run.`);
324
+ return withJobsLock(dir, () => {
325
+ const jobs = readLocalJobs(dir);
326
+ const existing = jobs.find(matches);
327
+ if (existing)
328
+ return { job: existing, created: false };
329
+ const job = {
330
+ jobId: `job_${randomUUID().replace(/-/g, "").slice(0, 20)}`,
331
+ prompt: input.prompt,
332
+ cron: input.cron,
333
+ timezone: input.timezone,
334
+ ...(input.context ? { context: input.context } : {}),
335
+ createdAt: now.toISOString(),
336
+ };
337
+ writeLocalJobs(dir, [...jobs, job]);
338
+ return { job, created: true };
339
+ });
340
+ }
341
+ /**
342
+ * Change a job's schedule in place, keeping its id, instruction, context and
343
+ * run history. Returns null when the bot has no job with that id.
344
+ */
345
+ export function rescheduleLocalJob(dir, jobId, schedule, now = new Date()) {
346
+ if (!isRunnableSchedule(schedule.cron))
347
+ throw new Error(`"${schedule.cron}" is not a schedule this bot can run.`);
348
+ return withJobsLock(dir, () => {
349
+ const jobs = readLocalJobs(dir);
350
+ const index = jobs.findIndex((job) => job.jobId === jobId);
351
+ if (index < 0)
352
+ return null;
353
+ const updated = {
354
+ ...jobs[index],
355
+ cron: schedule.cron,
356
+ timezone: schedule.timezone,
357
+ scheduledFrom: now.toISOString(),
358
+ };
359
+ jobs[index] = updated;
360
+ writeLocalJobs(dir, jobs);
361
+ return updated;
362
+ });
363
+ }
313
364
  /** Remove a job. Returns false when the bot has no job with that id. */
314
365
  function removeLocalJob(dir, jobId) {
315
366
  return withJobsLock(dir, () => {
@@ -321,6 +372,16 @@ function removeLocalJob(dir, jobId) {
321
372
  return true;
322
373
  });
323
374
  }
375
+ /** Remove every job `matches` picks, as one locked read-and-write. Returns the removed jobs. */
376
+ export function removeLocalJobsWhere(dir, matches) {
377
+ return withJobsLock(dir, () => {
378
+ const jobs = readLocalJobs(dir);
379
+ const removed = jobs.filter(matches);
380
+ if (removed.length > 0)
381
+ writeLocalJobs(dir, jobs.filter((job) => !matches(job)));
382
+ return removed;
383
+ });
384
+ }
324
385
  // ─── Firing ──────────────────────────────────────────────────────────────────
325
386
  /**
326
387
  * Hand over every job that is due now. Each fire is recorded (lastFiredAt and
@@ -430,8 +491,8 @@ export function withLocalJobs(api, dir, clock = () => new Date()) {
430
491
  /** The most jobs one bot may hold (the same cap the cloud uses). */
431
492
  const MAX_LOCAL_JOBS = 25;
432
493
  /**
433
- * `hq bot jobs` against the bot's own folder: the same three calls the cloud
434
- * client offers, answered from `jobs.json`.
494
+ * `hq bot jobs` against the bot's own folder: the same calls the cloud client
495
+ * offers, plus reschedule, answered from `jobs.json`.
435
496
  */
436
497
  export function localJobsClient(dir, clock = () => new Date()) {
437
498
  return {
@@ -454,6 +515,12 @@ export function localJobsClient(dir, clock = () => new Date()) {
454
515
  throw new BotApiError(404, "local jobs", "JOB_NOT_FOUND", `Job '${jobId}' not found`);
455
516
  }
456
517
  },
518
+ async rescheduleJob(_agentUid, jobId, schedule) {
519
+ const job = rescheduleLocalJob(dir, jobId, schedule, clock());
520
+ if (!job)
521
+ throw new BotApiError(404, "local jobs", "JOB_NOT_FOUND", `Job '${jobId}' not found`);
522
+ return toJobRecord(job, clock());
523
+ },
457
524
  };
458
525
  }
459
526
  //# sourceMappingURL=local-jobs.js.map