@indigoai-us/hq-cli 5.345.178 → 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 (80) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/command-catalog.generated.d.ts +71 -1
  3. package/dist/command-catalog.generated.js +86 -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 +8 -0
  7. package/dist/commands/bot.js +44 -0
  8. package/dist/commands/daemon.d.ts +2 -0
  9. package/dist/commands/daemon.js +15 -4
  10. package/dist/commands/integrations-core.d.ts +2 -0
  11. package/dist/commands/integrations-core.js +1 -0
  12. package/dist/commands/sources.d.ts +89 -0
  13. package/dist/commands/sources.js +363 -1
  14. package/dist/lib/bot/fleet-local.d.ts +1 -1
  15. package/dist/lib/bot/fleet-local.js +7 -7
  16. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +109 -0
  17. package/dist/lib/bot/jobs/hot-signal-sync.js +177 -0
  18. package/dist/lib/bot/local-jobs.d.ts +40 -4
  19. package/dist/lib/bot/local-jobs.js +72 -5
  20. package/dist/lib/bot/run.d.ts +15 -0
  21. package/dist/lib/bot/run.js +61 -0
  22. package/dist/lib/daemon/handoff.d.ts +4 -0
  23. package/dist/lib/daemon/handoff.js +6 -2
  24. package/dist/lib/daemon/install.d.ts +70 -2
  25. package/dist/lib/daemon/install.js +185 -27
  26. package/dist/lib/daemon/legacy.d.ts +9 -3
  27. package/dist/lib/daemon/legacy.js +11 -4
  28. package/dist/lib/daemon/migrate.js +1 -1
  29. package/dist/lib/daemon/run.d.ts +3 -2
  30. package/dist/lib/daemon/run.js +10 -6
  31. package/dist/lib/doctor/checks/anywhere.d.ts +6 -0
  32. package/dist/lib/doctor/checks/anywhere.js +35 -6
  33. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.d.ts +33 -0
  34. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.js +53 -0
  35. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.d.ts +38 -0
  36. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.js +64 -0
  37. package/dist/lib/hot-channels/adapters/attio.d.ts +67 -0
  38. package/dist/lib/hot-channels/adapters/attio.js +374 -0
  39. package/dist/lib/hot-channels/adapters/gmail.d.ts +111 -0
  40. package/dist/lib/hot-channels/adapters/gmail.js +413 -0
  41. package/dist/lib/hot-channels/adapters/index.d.ts +23 -0
  42. package/dist/lib/hot-channels/adapters/index.js +39 -0
  43. package/dist/lib/hot-channels/adapters/linear.d.ts +79 -0
  44. package/dist/lib/hot-channels/adapters/linear.js +321 -0
  45. package/dist/lib/hot-channels/adapters/meeting-providers.d.ts +86 -0
  46. package/dist/lib/hot-channels/adapters/meeting-providers.js +253 -0
  47. package/dist/lib/hot-channels/adapters/meetings.d.ts +81 -0
  48. package/dist/lib/hot-channels/adapters/meetings.js +523 -0
  49. package/dist/lib/hot-channels/adapters/slack.d.ts +70 -0
  50. package/dist/lib/hot-channels/adapters/slack.js +277 -0
  51. package/dist/lib/hot-channels/alert.d.ts +78 -0
  52. package/dist/lib/hot-channels/alert.js +179 -0
  53. package/dist/lib/hot-channels/audience.d.ts +15 -0
  54. package/dist/lib/hot-channels/audience.js +25 -0
  55. package/dist/lib/hot-channels/contract.d.ts +140 -0
  56. package/dist/lib/hot-channels/contract.js +20 -0
  57. package/dist/lib/hot-channels/cursors.d.ts +41 -0
  58. package/dist/lib/hot-channels/cursors.js +84 -0
  59. package/dist/lib/hot-channels/index.d.ts +10 -0
  60. package/dist/lib/hot-channels/index.js +10 -0
  61. package/dist/lib/hot-channels/landing.d.ts +83 -0
  62. package/dist/lib/hot-channels/landing.js +175 -0
  63. package/dist/lib/hot-channels/ledger.d.ts +101 -0
  64. package/dist/lib/hot-channels/ledger.js +186 -0
  65. package/dist/lib/hot-channels/loop.d.ts +74 -0
  66. package/dist/lib/hot-channels/loop.js +219 -0
  67. package/dist/lib/hot-channels/prompts/signals-pass.md +22 -0
  68. package/dist/lib/hot-channels/registry.d.ts +28 -0
  69. package/dist/lib/hot-channels/registry.js +121 -0
  70. package/dist/lib/hot-channels/run.d.ts +137 -0
  71. package/dist/lib/hot-channels/run.js +324 -0
  72. package/dist/lib/hot-channels/signals-pass.d.ts +150 -0
  73. package/dist/lib/hot-channels/signals-pass.js +581 -0
  74. package/dist/lib/hot-channels/sync-lock.d.ts +51 -0
  75. package/dist/lib/hot-channels/sync-lock.js +224 -0
  76. package/dist/lib/install/hook-target.d.ts +6 -2
  77. package/dist/lib/install/hook-target.js +9 -4
  78. package/dist/lib/lanes/loop.d.ts +10 -0
  79. package/dist/lib/lanes/loop.js +18 -1
  80. package/package.json +7 -7
@@ -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
@@ -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
@@ -134,6 +134,21 @@ export interface BotRunDeps {
134
134
  inboxPollBackoffEnabled?: () => Promise<boolean>;
135
135
  /** Default-off gate for the per-job quiet-success option. */
136
136
  jobQuietOptionEnabled?: () => boolean | Promise<boolean>;
137
+ /**
138
+ * The built-in hot-signal-sync job (jobs/hot-signal-sync.ts), run in-process
139
+ * with no model turn. Unset = a fired run of that job fails with a plain
140
+ * report instead of handing the instruction to the model.
141
+ */
142
+ hotSignalSync?: (input: {
143
+ company: string;
144
+ catchUp: boolean;
145
+ now: () => Date;
146
+ /** The signals-failure alert to the owner, sent even when the job is quiet (US-012). */
147
+ sendAlert: (text: string) => Promise<void>;
148
+ }) => Promise<{
149
+ ok: boolean;
150
+ text: string;
151
+ }>;
137
152
  heartbeatIntervalMs?: number;
138
153
  /** Override the rendered system prompt (tests). */
139
154
  systemPromptOverride?: string;
@@ -82,6 +82,7 @@ import { isAgentSender, isDmItem, isMetaNonReply, resolveReplyTarget } from "./r
82
82
  import { resolveRoomMentionsForBot, roomPostHasMentionTokens, } from "./room-mentions.js";
83
83
  import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
84
84
  import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
85
+ import { currentHotSignalSyncCompany, hotSignalSyncJobCompany, removeStaleHotSignalSyncJobs } from "./jobs/hot-signal-sync.js";
85
86
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
86
87
  import { SIGN_IN_CHECK_FRESH_MS, SIGN_IN_CHECK_MS, SIGN_IN_RETRY_MS, signInNeededText, signedOutExcerpt, } from "./runtime-sign-in.js";
87
88
  import { checkRuntimeSignIn } from "./runtime-sign-in-check.js";
@@ -1932,6 +1933,66 @@ export async function runBot(deps) {
1932
1933
  const markReported = (status) => {
1933
1934
  patchInflight(dir, id, { jobCompleted: status });
1934
1935
  };
1936
+ // The built-in hot-signal-sync job runs here, in the bot, with no model
1937
+ // turn. It is marked processed as soon as the sync finishes, so a report
1938
+ // that fails to post (or a redelivered item) never syncs a second time.
1939
+ const syncCompany = hotSignalSyncJobCompany(job.context);
1940
+ if (syncCompany) {
1941
+ // The job's stored company is checked against the bot's current config
1942
+ // (read fresh from disk) every time it fires. A company the bot no longer
1943
+ // serves is never synced: the run is skipped and the job removed.
1944
+ const currentCompany = currentHotSignalSyncCompany(readBotConfig(dir) ?? config);
1945
+ if (currentCompany !== syncCompany) {
1946
+ let removedNote = "";
1947
+ try {
1948
+ removeStaleHotSignalSyncJobs(dir, currentCompany);
1949
+ removedNote = ` Removed job ${jobId}.`;
1950
+ }
1951
+ catch (err) {
1952
+ removedNote = ` Could not remove job ${jobId}: ${err instanceof Error ? err.message : String(err)}`;
1953
+ }
1954
+ const text = `Skipped the hot-channel sync for ${syncCompany}: this bot no longer syncs that company (current: ${currentCompany ?? "none"}). Nothing was written.${removedNote}`;
1955
+ markProcessed(dir, id, now().toISOString());
1956
+ try {
1957
+ await sendToPerson(text);
1958
+ }
1959
+ catch (err) {
1960
+ log("warn", `report for run ${id} of job ${jobId} was not delivered: ${err instanceof Error ? err.message : String(err)}`);
1961
+ }
1962
+ await completeJobRun(jobId, id, { status: "skipped-precondition", output: jobRunOutput(text) });
1963
+ markReported("skipped-precondition");
1964
+ await api.ackInbox(config.agentUid, id);
1965
+ clearInflight(dir, id);
1966
+ attempts.delete(id);
1967
+ setStatus({ lastActivityAt: now().toISOString() });
1968
+ log("info", `hot-signal-sync job ${jobId} run ${id} skipped: company ${syncCompany} no longer served (current: ${currentCompany ?? "none"})`);
1969
+ return;
1970
+ }
1971
+ const result = deps.hotSignalSync
1972
+ ? await deps.hotSignalSync({ company: syncCompany, catchUp, now, sendAlert: (text) => sendToPerson(text) })
1973
+ : { ok: false, text: `This bot cannot run the hot-channel sync for ${syncCompany}.` };
1974
+ markProcessed(dir, id, now().toISOString());
1975
+ const status = result.ok ? "succeeded" : "failed";
1976
+ if (!(quietJob && result.ok)) {
1977
+ try {
1978
+ await sendToPerson(result.text);
1979
+ }
1980
+ catch (err) {
1981
+ log("warn", `report for run ${id} of job ${jobId} was not delivered: ${err instanceof Error ? err.message : String(err)}`);
1982
+ }
1983
+ }
1984
+ await completeJobRun(jobId, id, { status, output: jobRunOutput(result.text) });
1985
+ markReported(status);
1986
+ await api.ackInbox(config.agentUid, id);
1987
+ clearInflight(dir, id);
1988
+ attempts.delete(id);
1989
+ setStatus({
1990
+ ...(!(quietJob && result.ok) ? { repliesSent: (readStatus()?.repliesSent ?? 0) + 1 } : {}),
1991
+ lastActivityAt: now().toISOString(),
1992
+ });
1993
+ log("info", `hot-signal-sync job ${jobId} run ${id} ${status} for ${syncCompany}${catchUp ? " (catch-up)" : ""}`);
1994
+ return;
1995
+ }
1935
1996
  const { outcome, poster } = await runTurnWithProgress({
1936
1997
  marker: { messageId: id, target: markerTarget, jobId, jobHeader: header, agentUid: config.agentUid },
1937
1998
  prompt: buildJobTurnPrompt(job, { now: now(), timeZone }),
@@ -32,7 +32,11 @@ export interface DaemonUnitHost {
32
32
  platform: NodeJS.Platform;
33
33
  home: string;
34
34
  systemUnitDir?: string;
35
+ /** This user's own Scheduled Task name (daemonWindowsTaskName); another user's task on the PC does not count. */
36
+ windowsTaskName: string;
35
37
  windowsTaskExists: (task: string) => boolean;
38
+ /** The shared task an older install registered runs as this user (it still counts until install moves it). */
39
+ windowsLegacyTaskIsMine?: () => boolean;
36
40
  }
37
41
  /** The `hq daemon` OS unit is installed on this machine (for this user, or as the box's system unit). */
38
42
  export declare function daemonUnitInstalled(host: DaemonUnitHost): boolean;
@@ -10,7 +10,8 @@ import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
  import { DEFAULT_SERVICES, enabledServices, isServiceName } from "./catalog.js";
12
12
  import { readDaemonConfig, writeDaemonConfig } from "./config.js";
13
- import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK, SYSTEM_UNIT_DIR } from "./install.js";
13
+ import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, SYSTEM_UNIT_DIR, currentWindowsIdentity, legacyWindowsTask, windowsTaskName } from "./install.js";
14
+ import { defaultRunCommand } from "../service-manager/launchd.js";
14
15
  import { isDaemonProcess, runningDaemonPid } from "./lock.js";
15
16
  import { detectMachineType } from "./machine-type.js";
16
17
  import { daemonPaths } from "./paths.js";
@@ -96,7 +97,7 @@ export function daemonUnitInstalled(host) {
96
97
  case "darwin":
97
98
  return fs.existsSync(path.join(host.home, "Library", "LaunchAgents", `${DAEMON_LAUNCHD_LABEL}.plist`));
98
99
  case "win32":
99
- return host.windowsTaskExists(DAEMON_WINDOWS_TASK);
100
+ return host.windowsTaskExists(host.windowsTaskName) || (host.windowsLegacyTaskIsMine?.() ?? false);
100
101
  default:
101
102
  return (fs.existsSync(path.join(host.home, ".config", "systemd", "user", DAEMON_SYSTEMD_UNIT)) ||
102
103
  fs.existsSync(path.join(host.systemUnitDir ?? SYSTEM_UNIT_DIR, DAEMON_SYSTEMD_UNIT)));
@@ -142,9 +143,12 @@ export function handOffInstallHere(services, env = process.env, opts = {}) {
142
143
  if (opts.ifStale && services.every((service) => config.disable.includes(service))) {
143
144
  return `hq daemon has ${services.join(", ")} disabled, so the older service will not be installed.`;
144
145
  }
146
+ const identity = process.platform === "win32" ? currentWindowsIdentity() : undefined;
145
147
  const installed = daemonUnitInstalled({
146
148
  platform: process.platform,
147
149
  home,
150
+ windowsTaskName: identity ? windowsTaskName(identity) : "",
151
+ windowsLegacyTaskIsMine: () => identity !== undefined && legacyWindowsTask(defaultRunCommand, identity).state === "mine",
148
152
  windowsTaskExists: (task) => spawnSync("schtasks.exe", ["/Query", "/TN", task], { encoding: "utf8", windowsHide: true }).status === 0,
149
153
  }) || hostedDaemonRunning(paths);
150
154
  if (!installed) {
@@ -7,7 +7,7 @@
7
7
  * - Linux, per-user: systemd user unit hq-daemon.service
8
8
  * - Linux, system (outposts and agent boxes): /etc/systemd/system/hq-daemon.service
9
9
  * running as the box's login user; needs root
10
- * - Windows: per-user logon Scheduled Task ai.getindigo.hq-daemon
10
+ * - Windows: per-user logon Scheduled Task ai.getindigo.hq-daemon-<user SID>
11
11
  * - anything else: no unit; the caller prints the command to run
12
12
  *
13
13
  * The unit never names services. `hq daemon run` reads the machine type and
@@ -20,8 +20,34 @@ import { type PidLockRecord } from "../mesh/live/daemon/pid-lock.js";
20
20
  import type { MachineType } from "./machine-type.js";
21
21
  export declare const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
22
22
  export declare const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
23
- export declare const DAEMON_WINDOWS_TASK = "ai.getindigo.hq-daemon";
23
+ /**
24
+ * The one machine-wide task name older installs registered on Windows. Two
25
+ * users on one PC overwrote each other's daemon under it, so each user now
26
+ * gets their own name (daemonWindowsTaskName). Install moves the current
27
+ * user's task off this name; another user's task under it is left alone.
28
+ */
29
+ export declare const LEGACY_DAEMON_WINDOWS_TASK = "ai.getindigo.hq-daemon";
24
30
  export declare const SYSTEM_UNIT_DIR = "/etc/systemd/system";
31
+ /**
32
+ * The Windows task name for one user: the shared prefix plus the user's SID,
33
+ * or the `DOMAIN\user` name with characters a task name cannot hold replaced
34
+ * when the SID could not be read. Never the bare legacy name.
35
+ */
36
+ export declare function daemonWindowsTaskName(who: {
37
+ sid?: string;
38
+ user: string;
39
+ }): string;
40
+ /** The current Windows user's SID from `whoami /user`, or undefined when it cannot be read. */
41
+ export declare function readWindowsSid(run: RunCommand): string | undefined;
42
+ /** The per-user task name for the user running this process. */
43
+ export declare function currentDaemonWindowsTaskName(run: RunCommand, user: string): string;
44
+ /**
45
+ * `DOMAIN\user` for the current account. Same answer as the mesh installer's
46
+ * resolveWindowsUser, kept here so lookups do not load the mesh installer.
47
+ */
48
+ export declare function currentWindowsUser(env?: NodeJS.ProcessEnv): string;
49
+ /** The current user as a DaemonHost names them on Windows. */
50
+ export declare function currentWindowsIdentity(run?: RunCommand, env?: NodeJS.ProcessEnv): Pick<DaemonHost, "windowsSid" | "windowsUser">;
25
51
  export type InstallScope = "user" | "system";
26
52
  export interface DaemonHost {
27
53
  platform: NodeJS.Platform;
@@ -34,6 +60,8 @@ export interface DaemonHost {
34
60
  isRoot: boolean;
35
61
  /** `DOMAIN\user` the Windows task runs as. */
36
62
  windowsUser: string;
63
+ /** That user's SID, when `whoami /user` could read it. Names the per-user task. */
64
+ windowsSid?: string;
37
65
  /** conhost.exe path when `--headless` is supported, else undefined. */
38
66
  windowsHeadlessLauncher: string | undefined;
39
67
  /** Where system units are written (tests point this at a temp dir). */
@@ -70,6 +98,8 @@ export interface DaemonUnitResult {
70
98
  startPending?: true;
71
99
  /** Windows: an older daemon may still be running; HQ could not verify it, so did not stop it. */
72
100
  warning?: string;
101
+ /** Windows: what happened to the shared task from an older install, when there is one. */
102
+ notice?: string;
73
103
  }
74
104
  export interface InstallDeps {
75
105
  run: RunCommand;
@@ -93,6 +123,43 @@ export declare function daemonRootRefusal(isRoot: boolean, machineType: MachineT
93
123
  /** Boxes run the daemon as a system unit; everything else per user. */
94
124
  export declare function installScopeFor(machineType: MachineType): InstallScope;
95
125
  export declare function planDaemonUnit(host: DaemonHost, options: InstallOptions): DaemonUnitPlan;
126
+ /**
127
+ * The registered per-user task names for `host`: the current name, and the
128
+ * user-name form an install used when it could not read the SID. The fallback
129
+ * name is only returned when its principal is this user.
130
+ */
131
+ export declare function windowsUserTaskNames(run: RunCommand, host: Pick<DaemonHost, "windowsSid" | "windowsUser">): string[];
132
+ /** The per-user task name for `host`. */
133
+ export declare function windowsTaskName(host: Pick<DaemonHost, "windowsSid" | "windowsUser">): string;
134
+ /**
135
+ * Whether two Windows account names are the same user, ignoring case. Both
136
+ * names must have the same form: `DOMAIN\user` matches only the same
137
+ * `DOMAIN\user`, and a bare `user` only the same bare `user`. A bare name is
138
+ * never matched against a domain name, because `bob` could be `PC\bob` or
139
+ * `CORP\bob`, and a wrong match would end and delete another user's task.
140
+ */
141
+ export declare function sameWindowsUser(a: string, b: string): boolean;
142
+ /**
143
+ * The shared task an older install registered, and whose it is. "mine" means
144
+ * its principal is the current user, so HQ may move it. "other" means another
145
+ * user's, or a principal HQ cannot match to the current user; "unreadable"
146
+ * means schtasks refused to show it. Both are left alone.
147
+ */
148
+ export type LegacyWindowsTask = {
149
+ state: "none";
150
+ } | {
151
+ state: "mine";
152
+ owner: string;
153
+ } | {
154
+ state: "other";
155
+ owner: string;
156
+ } | {
157
+ state: "unreadable";
158
+ reason: string;
159
+ };
160
+ export declare function legacyWindowsTask(run: RunCommand, host: Pick<DaemonHost, "windowsSid" | "windowsUser">): LegacyWindowsTask;
161
+ /** Whose task `task` is, read from `schtasks /Query /XML`. */
162
+ export declare function classifyWindowsTask(run: RunCommand, task: string, host: Pick<DaemonHost, "windowsSid" | "windowsUser">): LegacyWindowsTask;
96
163
  /** What to tell the user when HQ did not stop pid `pid` because it could not verify it. */
97
164
  export declare function oldDaemonWarning(pid: number, reason: string): string;
98
165
  export declare function installDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): DaemonUnitResult;
@@ -127,5 +194,6 @@ export declare function uninstallDaemonUnit(host: DaemonHost, options: InstallOp
127
194
  unitPath: string;
128
195
  removed: boolean;
129
196
  warning?: string;
197
+ notice?: string;
130
198
  };
131
199
  //# sourceMappingURL=install.d.ts.map