@indigoai-us/hq-cli 5.345.182 → 5.345.187

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 (36) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/command-catalog.generated.d.ts +29 -0
  3. package/dist/command-catalog.generated.js +35 -0
  4. package/dist/commands/bot.d.ts +5 -5
  5. package/dist/commands/bot.js +30 -31
  6. package/dist/commands/sources.d.ts +37 -0
  7. package/dist/commands/sources.js +104 -1
  8. package/dist/lib/anywhere-person-setting.js +24 -10
  9. package/dist/lib/bot/fleet-local.d.ts +4 -2
  10. package/dist/lib/bot/fleet-local.js +23 -7
  11. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -7
  12. package/dist/lib/bot/jobs/hot-signal-sync.js +106 -9
  13. package/dist/lib/bot/local-jobs.d.ts +4 -2
  14. package/dist/lib/bot/local-jobs.js +33 -4
  15. package/dist/lib/bot/run.js +2 -2
  16. package/dist/lib/hot-channels/adapters/attio.js +3 -0
  17. package/dist/lib/hot-channels/adapters/linear.js +4 -0
  18. package/dist/lib/hot-channels/adapters/meetings.js +4 -0
  19. package/dist/lib/hot-channels/adapters/slack.js +3 -0
  20. package/dist/lib/hot-channels/contract.d.ts +7 -0
  21. package/dist/lib/hot-channels/ledger.d.ts +8 -0
  22. package/dist/lib/hot-channels/ledger.js +6 -2
  23. package/dist/lib/hot-channels/loop.d.ts +6 -0
  24. package/dist/lib/hot-channels/loop.js +16 -2
  25. package/dist/lib/hot-channels/person-vault-reader.d.ts +41 -0
  26. package/dist/lib/hot-channels/person-vault-reader.js +59 -0
  27. package/dist/lib/hot-channels/run.d.ts +8 -0
  28. package/dist/lib/hot-channels/run.js +19 -0
  29. package/dist/lib/hot-channels/schedule.d.ts +64 -0
  30. package/dist/lib/hot-channels/schedule.js +162 -0
  31. package/dist/lib/hot-channels/selection.d.ts +87 -0
  32. package/dist/lib/hot-channels/selection.js +175 -0
  33. package/dist/utils/shipped-codex-hooks.js +1 -1
  34. package/dist/utils/version-report.d.ts +1 -1
  35. package/dist/utils/version-report.js +20 -7
  36. package/package.json +7 -7
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * The bot's built-in hot-signal-sync job.
3
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
4
+ * A local bot registers one recurring job in its own `jobs.json` the first
5
+ * time it starts (`ensureHotSignalSyncJob`), for the bot's company, or, for a
6
+ * bot with no company (the personal bot first-run setup creates), for the
7
+ * HQ's active company (`.hq/config.json` `activeCompany`, the same company
8
+ * `hq sources sync` uses). Only one local bot registers it per company. Right
9
+ * after the job is created the bot runs the sync once, so a new install does
10
+ * not wait for the first scheduled time (`registerHotSignalSyncOnStart`). The job
6
11
  * fires from the bot's inbox loop like every other local job
7
12
  * (lib/bot/local-jobs.ts): no crontab, launchd or systemd entry is written.
8
13
  * The default schedule is 06:30 daily in the machine's time zone, ahead of a
@@ -34,14 +39,26 @@ export declare const HOT_SIGNAL_SYNC_DEFAULT_CRON = "cron(30 6 * * ? *)";
34
39
  export declare function hotSignalSyncJobContext(company: string): Record<string, unknown>;
35
40
  /** The company a job item syncs, when its context marks it as this built-in; else null. */
36
41
  export declare function hotSignalSyncJobCompany(context: unknown): string | null;
42
+ /**
43
+ * The HQ's active company from `{hqRoot}/.hq/config.json` (`activeCompany`),
44
+ * the same company `hq sources sync` falls back to. Undefined when the file is
45
+ * missing, unreadable, or the value is not a plain slug. Never throws.
46
+ */
47
+ export declare function readActiveCompanySlug(hqRoot: string): string | undefined;
37
48
  /**
38
49
  * 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.
50
+ * the first company it serves, else (for a bot with no company at all, such as
51
+ * the personal bot first-run setup creates) `activeCompany`, the HQ's active
52
+ * company. Null = none, and no job is registered. Checked again each time the
53
+ * job fires, so a job for a company the bot no longer resolves to is removed.
43
54
  */
44
- export declare function currentHotSignalSyncCompany(config: Pick<BotConfig, "companySlug" | "kind" | "companies" | "workerId">): string | null;
55
+ export declare function currentHotSignalSyncCompany(config: Pick<BotConfig, "companySlug" | "kind" | "companies" | "workerId">, activeCompany?: string | null): string | null;
56
+ /**
57
+ * The local bot folder, other than `dir`, that already has the hot-signal-sync
58
+ * job for `company` (or registered it once, even if its owner removed it
59
+ * since). Null = none. Unreadable folders are skipped.
60
+ */
61
+ export declare function otherBotWithHotSignalSync(dir: string, company: string, botDirs: readonly string[]): string | null;
45
62
  /**
46
63
  * Remove every hot-signal-sync job whose company is not `current` (all of them
47
64
  * when `current` is null), and forget their registration so the company can be
@@ -52,6 +69,8 @@ export interface EnsureHotSignalSyncJobResult {
52
69
  /** The job, or null when the owner removed it after it was first registered. */
53
70
  job: LocalJob | null;
54
71
  created: boolean;
72
+ /** Set when another local bot already has this company's job; nothing was registered here. */
73
+ heldBy?: string;
55
74
  }
56
75
  /**
57
76
  * Register the hot-signal-sync job for `company` once. Safe to call on every
@@ -61,7 +80,34 @@ export interface EnsureHotSignalSyncJobResult {
61
80
  export declare function ensureHotSignalSyncJob(dir: string, input: {
62
81
  company: string;
63
82
  timezone: string;
83
+ otherBotDirs?: readonly string[];
64
84
  }, now?: Date): EnsureHotSignalSyncJobResult;
85
+ export interface RegisterOnStartInput {
86
+ dir: string;
87
+ config: Pick<BotConfig, "companySlug" | "kind" | "companies" | "workerId">;
88
+ /** The HQ's active company, used only when the bot has no company. */
89
+ activeCompany?: string | null;
90
+ /** Every local bot folder (this one may be included); used for the one-bot-per-company check. */
91
+ otherBotDirs?: readonly string[];
92
+ timezone: string;
93
+ /** The first sync, run once right after the job is created. Errors are logged, never thrown. */
94
+ runFirst: (company: string) => Promise<HotSignalSyncRunOutcome>;
95
+ log?: (level: "info" | "warn", message: string) => void;
96
+ now?: Date;
97
+ }
98
+ export interface RegisterOnStartResult {
99
+ company: string | null;
100
+ ensured: EnsureHotSignalSyncJobResult | null;
101
+ /** The first run, when the job was created on this start; resolves (never rejects) when it ends. */
102
+ firstRun: Promise<void> | null;
103
+ }
104
+ /**
105
+ * What a bot does at start for the built-in job: remove jobs for a company it
106
+ * no longer syncs, register the job for its current company once, and, when
107
+ * the job was created just now, start one sync in the background. Never
108
+ * throws and never waits for the sync, so bot start cannot fail or stall here.
109
+ */
110
+ export declare function registerHotSignalSyncOnStart(input: RegisterOnStartInput): RegisterOnStartResult;
65
111
  /**
66
112
  * The signals pass over one adapter's landed sources: how many signals it
67
113
  * generated. The bot wires the local `claude -p` pass
@@ -75,6 +121,8 @@ export type HotSignalSyncFn = (input: {
75
121
  company: string;
76
122
  trigger: RunTrigger;
77
123
  catchUp: boolean;
124
+ /** The one run started right after the job was registered; marked in the ledger. */
125
+ firstRun?: boolean;
78
126
  signalsGenerated: LocalSignalsPass;
79
127
  now: () => Date;
80
128
  /** The owner DM for the signals-failure alert (US-012); unset = no alert check. */
@@ -87,6 +135,8 @@ export interface HotSignalSyncRunInput {
87
135
  company: string;
88
136
  /** The run fired late, after a missed window (job-run.ts `isCatchUpRun`). */
89
137
  catchUp: boolean;
138
+ /** The one run started right after the job was registered (`registerHotSignalSyncOnStart`). */
139
+ firstRun?: boolean;
90
140
  now: () => Date;
91
141
  sync: HotSignalSyncFn;
92
142
  signalsPass?: LocalSignalsPass;
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * The bot's built-in hot-signal-sync job.
3
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
4
+ * A local bot registers one recurring job in its own `jobs.json` the first
5
+ * time it starts (`ensureHotSignalSyncJob`), for the bot's company, or, for a
6
+ * bot with no company (the personal bot first-run setup creates), for the
7
+ * HQ's active company (`.hq/config.json` `activeCompany`, the same company
8
+ * `hq sources sync` uses). Only one local bot registers it per company. Right
9
+ * after the job is created the bot runs the sync once, so a new install does
10
+ * not wait for the first scheduled time (`registerHotSignalSyncOnStart`). The job
6
11
  * fires from the bot's inbox loop like every other local job
7
12
  * (lib/bot/local-jobs.ts): no crontab, launchd or systemd entry is written.
8
13
  * The default schedule is 06:30 daily in the machine's time zone, ahead of a
@@ -21,7 +26,7 @@
21
26
  import * as fs from "node:fs";
22
27
  import * as path from "node:path";
23
28
  import { JOB_RUN_OPTIONS_CONTEXT_KEY } from "../job-run.js";
24
- import { effectiveBotCompanies } from "../config.js";
29
+ import { effectiveBotCompanies, effectiveBotKind } from "../config.js";
25
30
  import { ensureLocalJob, readLocalJobs, removeLocalJobsWhere, withJobsLock } from "../local-jobs.js";
26
31
  export const HOT_SIGNAL_SYNC_JOB_KIND = "hot-signal-sync";
27
32
  /**
@@ -69,15 +74,55 @@ export function hotSignalSyncJobCompany(context) {
69
74
  return null;
70
75
  return typeof company === "string" && company.trim() ? company.trim() : null;
71
76
  }
77
+ const ACTIVE_COMPANY_RE = /^[a-z0-9][a-z0-9_-]*$/i;
78
+ /**
79
+ * The HQ's active company from `{hqRoot}/.hq/config.json` (`activeCompany`),
80
+ * the same company `hq sources sync` falls back to. Undefined when the file is
81
+ * missing, unreadable, or the value is not a plain slug. Never throws.
82
+ */
83
+ export function readActiveCompanySlug(hqRoot) {
84
+ try {
85
+ const cfg = JSON.parse(fs.readFileSync(path.join(hqRoot, ".hq", "config.json"), "utf-8"));
86
+ const slug = cfg?.activeCompany;
87
+ return typeof slug === "string" && slug.length > 0 ? slug : undefined;
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
72
93
  /**
73
94
  * 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.
95
+ * the first company it serves, else (for a bot with no company at all, such as
96
+ * the personal bot first-run setup creates) `activeCompany`, the HQ's active
97
+ * company. Null = none, and no job is registered. Checked again each time the
98
+ * job fires, so a job for a company the bot no longer resolves to is removed.
78
99
  */
79
- export function currentHotSignalSyncCompany(config) {
80
- return config.companySlug ?? effectiveBotCompanies(config)[0] ?? null;
100
+ export function currentHotSignalSyncCompany(config, activeCompany) {
101
+ const own = config.companySlug ?? effectiveBotCompanies(config)[0];
102
+ if (own)
103
+ return own;
104
+ // A company bot syncs only the companies it was given.
105
+ if (effectiveBotKind(config) === "company")
106
+ return null;
107
+ const active = activeCompany?.trim();
108
+ return active && ACTIVE_COMPANY_RE.test(active) ? active : null;
109
+ }
110
+ /**
111
+ * The local bot folder, other than `dir`, that already has the hot-signal-sync
112
+ * job for `company` (or registered it once, even if its owner removed it
113
+ * since). Null = none. Unreadable folders are skipped.
114
+ */
115
+ export function otherBotWithHotSignalSync(dir, company, botDirs) {
116
+ const self = path.resolve(dir);
117
+ for (const other of botDirs) {
118
+ if (path.resolve(other) === self)
119
+ continue;
120
+ if (readMarker(other).registered[markerKey(company)])
121
+ return other;
122
+ if (readLocalJobs(other).some((job) => hotSignalSyncJobCompany(job.context) === company))
123
+ return other;
124
+ }
125
+ return null;
81
126
  }
82
127
  /**
83
128
  * Remove every hot-signal-sync job whose company is not `current` (all of them
@@ -115,6 +160,10 @@ export function ensureHotSignalSyncJob(dir, input, now = new Date()) {
115
160
  if (readMarker(dir).registered[key]) {
116
161
  return { job: readLocalJobs(dir).find(matches) ?? null, created: false };
117
162
  }
163
+ // One bot per company: a second local bot never adds its own copy.
164
+ const heldBy = otherBotWithHotSignalSync(dir, input.company, input.otherBotDirs ?? []);
165
+ if (heldBy)
166
+ return { job: null, created: false, heldBy };
118
167
  const { job, created } = ensureLocalJob(dir, matches, {
119
168
  prompt: `Sync hot channels for ${input.company} and run the signals pass (runs in the bot, no model turn).`,
120
169
  cron: HOT_SIGNAL_SYNC_DEFAULT_CRON,
@@ -130,6 +179,53 @@ export function ensureHotSignalSyncJob(dir, input, now = new Date()) {
130
179
  });
131
180
  return { job, created };
132
181
  }
182
+ /**
183
+ * What a bot does at start for the built-in job: remove jobs for a company it
184
+ * no longer syncs, register the job for its current company once, and, when
185
+ * the job was created just now, start one sync in the background. Never
186
+ * throws and never waits for the sync, so bot start cannot fail or stall here.
187
+ */
188
+ export function registerHotSignalSyncOnStart(input) {
189
+ const log = input.log ?? (() => undefined);
190
+ const company = currentHotSignalSyncCompany(input.config, input.activeCompany);
191
+ try {
192
+ for (const stale of removeStaleHotSignalSyncJobs(input.dir, company)) {
193
+ log("info", `Removed the hot-channel sync job ${stale.jobId}: this bot no longer syncs that company.`);
194
+ }
195
+ }
196
+ catch (err) {
197
+ log("warn", `Could not check the hot-channel sync jobs: ${errorMessage(err)}`);
198
+ }
199
+ if (!company)
200
+ return { company: null, ensured: null, firstRun: null };
201
+ let ensured;
202
+ try {
203
+ ensured = ensureHotSignalSyncJob(input.dir, { company, timezone: input.timezone, otherBotDirs: input.otherBotDirs ?? [] }, input.now ?? new Date());
204
+ }
205
+ catch (err) {
206
+ log("warn", `Could not register the hot-channel sync job for ${company}: ${errorMessage(err)}`);
207
+ return { company, ensured: null, firstRun: null };
208
+ }
209
+ if (ensured.heldBy) {
210
+ log("info", `Not adding a hot-channel sync job for ${company}: the bot in ${ensured.heldBy} already runs it.`);
211
+ }
212
+ if (!ensured.created)
213
+ return { company, ensured, firstRun: null };
214
+ log("info", `Registered the daily hot-channel sync for ${company}; running the first sync now.`);
215
+ const firstRun = (async () => {
216
+ try {
217
+ const outcome = await input.runFirst(company);
218
+ log(outcome.ok ? "info" : "warn", outcome.text);
219
+ }
220
+ catch (err) {
221
+ log("warn", `The first hot-channel sync for ${company} failed: ${errorMessage(err)}`);
222
+ }
223
+ })();
224
+ return { company, ensured, firstRun };
225
+ }
226
+ function errorMessage(err) {
227
+ return err instanceof Error ? err.message : String(err);
228
+ }
133
229
  export const noLocalSignalsPass = async () => 0;
134
230
  /** Run the scheduled sync once and describe it in plain words. Never throws. */
135
231
  export async function runHotSignalSyncJob(input) {
@@ -140,6 +236,7 @@ export async function runHotSignalSyncJob(input) {
140
236
  company: input.company,
141
237
  trigger: "scheduled",
142
238
  catchUp: input.catchUp,
239
+ ...(input.firstRun ? { firstRun: true } : {}),
143
240
  signalsGenerated: signalsPass,
144
241
  now: input.now,
145
242
  ...(input.sendAlert
@@ -95,9 +95,11 @@ export declare function ensureLocalJob(dir: string, matches: (job: LocalJob) =>
95
95
  * Change a job's schedule in place, keeping its id, instruction, context and
96
96
  * run history. Returns null when the bot has no job with that id.
97
97
  */
98
- export declare function rescheduleLocalJob(dir: string, jobId: string, schedule: {
98
+ export declare function rescheduleLocalJob(dir: string, jobId: string,
99
+ /** `timezone` omitted keeps the job's own, read under the same lock. */
100
+ schedule: {
99
101
  cron: string;
100
- timezone: string;
102
+ timezone?: string;
101
103
  }, now?: Date): LocalJob | null;
102
104
  /** Remove every job `matches` picks, as one locked read-and-write. Returns the removed jobs. */
103
105
  export declare function removeLocalJobsWhere(dir: string, matches: (job: LocalJob) => boolean): LocalJob[];
@@ -42,6 +42,33 @@ export function readLocalJobs(dir) {
42
42
  return [];
43
43
  }
44
44
  }
45
+ /**
46
+ * `jobs.json` for a write that adds a job: a missing file is an empty list,
47
+ * but a file that exists and cannot be read or parsed throws, so adding a job
48
+ * never writes back a list that drops every other job.
49
+ */
50
+ function readLocalJobsForAdd(dir) {
51
+ let text;
52
+ try {
53
+ text = fs.readFileSync(localJobsPath(dir), "utf8");
54
+ }
55
+ catch (err) {
56
+ if (err.code === "ENOENT")
57
+ return [];
58
+ throw new Error(`The bot's job list (${localJobsPath(dir)}) could not be read, so it was not changed: ${err.message}`);
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(text);
63
+ }
64
+ catch (err) {
65
+ throw new Error(`The bot's job list (${localJobsPath(dir)}) is not valid JSON, so it was not changed: ${err.message}`);
66
+ }
67
+ if (!parsed || !Array.isArray(parsed.jobs)) {
68
+ throw new Error(`The bot's job list (${localJobsPath(dir)}) has no jobs array, so it was not changed.`);
69
+ }
70
+ return parsed.jobs.filter((job) => !!job && typeof job.jobId === "string");
71
+ }
45
72
  function writeLocalJobs(dir, jobs) {
46
73
  const file = localJobsPath(dir);
47
74
  const tmp = `${file}.${process.pid}.tmp`;
@@ -311,7 +338,7 @@ export function addLocalJob(dir, input, now = new Date()) {
311
338
  ...(input.context ? { context: input.context } : {}),
312
339
  createdAt: now.toISOString(),
313
340
  };
314
- withJobsLock(dir, () => writeLocalJobs(dir, [...readLocalJobs(dir), job]));
341
+ withJobsLock(dir, () => writeLocalJobs(dir, [...readLocalJobsForAdd(dir), job]));
315
342
  return job;
316
343
  }
317
344
  /**
@@ -322,7 +349,7 @@ export function ensureLocalJob(dir, matches, input, now = new Date()) {
322
349
  if (!isRunnableSchedule(input.cron))
323
350
  throw new Error(`"${input.cron}" is not a schedule this bot can run.`);
324
351
  return withJobsLock(dir, () => {
325
- const jobs = readLocalJobs(dir);
352
+ const jobs = readLocalJobsForAdd(dir);
326
353
  const existing = jobs.find(matches);
327
354
  if (existing)
328
355
  return { job: existing, created: false };
@@ -342,7 +369,9 @@ export function ensureLocalJob(dir, matches, input, now = new Date()) {
342
369
  * Change a job's schedule in place, keeping its id, instruction, context and
343
370
  * run history. Returns null when the bot has no job with that id.
344
371
  */
345
- export function rescheduleLocalJob(dir, jobId, schedule, now = new Date()) {
372
+ export function rescheduleLocalJob(dir, jobId,
373
+ /** `timezone` omitted keeps the job's own, read under the same lock. */
374
+ schedule, now = new Date()) {
346
375
  if (!isRunnableSchedule(schedule.cron))
347
376
  throw new Error(`"${schedule.cron}" is not a schedule this bot can run.`);
348
377
  return withJobsLock(dir, () => {
@@ -353,7 +382,7 @@ export function rescheduleLocalJob(dir, jobId, schedule, now = new Date()) {
353
382
  const updated = {
354
383
  ...jobs[index],
355
384
  cron: schedule.cron,
356
- timezone: schedule.timezone,
385
+ timezone: schedule.timezone ?? jobs[index].timezone,
357
386
  scheduledFrom: now.toISOString(),
358
387
  };
359
388
  jobs[index] = updated;
@@ -83,7 +83,7 @@ import { isAgentSender, isDmItem, isMetaNonReply, resolveReplyTarget } from "./r
83
83
  import { resolveRoomMentionsForBot, roomPostHasMentionTokens, } from "./room-mentions.js";
84
84
  import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
85
85
  import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
86
- import { currentHotSignalSyncCompany, hotSignalSyncJobCompany, removeStaleHotSignalSyncJobs } from "./jobs/hot-signal-sync.js";
86
+ import { currentHotSignalSyncCompany, hotSignalSyncJobCompany, readActiveCompanySlug, removeStaleHotSignalSyncJobs } from "./jobs/hot-signal-sync.js";
87
87
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
88
88
  import { SIGN_IN_CHECK_FRESH_MS, SIGN_IN_CHECK_MS, SIGN_IN_RETRY_MS, signInNeededText, signedOutExcerpt, } from "./runtime-sign-in.js";
89
89
  import { checkRuntimeSignIn } from "./runtime-sign-in-check.js";
@@ -1978,7 +1978,7 @@ export async function runBot(deps) {
1978
1978
  log("warn", `hot-signal-sync job ${jobId} run ${id} skipped: bot.json missing or unreadable`);
1979
1979
  return;
1980
1980
  }
1981
- const currentCompany = currentHotSignalSyncCompany(freshConfig);
1981
+ const currentCompany = currentHotSignalSyncCompany(freshConfig, readActiveCompanySlug(freshConfig.hqRoot) ?? null);
1982
1982
  if (currentCompany !== syncCompany) {
1983
1983
  let removedNote = "";
1984
1984
  try {
@@ -306,6 +306,9 @@ export function createAttioAdapter(options) {
306
306
  reasons.add("owner");
307
307
  if (reasons.size === 0)
308
308
  continue;
309
+ // A saved record pick: only deliveries about a chosen record land.
310
+ if (ctx.selection && !(ref && ctx.selection.has(ref.recordId)))
311
+ continue;
309
312
  events.push(toEvent(d, [...reasons], actor, ref, ctx.personUid, options.identities));
310
313
  }
311
314
  const last = page[page.length - 1];
@@ -290,6 +290,10 @@ export function createLinearAdapter(options = {}) {
290
290
  if (!issue || Date.parse(issue.updatedAt) <= watermarkMs)
291
291
  continue;
292
292
  high = high === null ? issue.updatedAt : laterOf(high, issue.updatedAt);
293
+ // A saved issue pick (by issue id or identifier such as ENG-123): other
294
+ // issues still move the watermark but do not land.
295
+ if (ctx.selection && !ctx.selection.has(issue.uuid) && !ctx.selection.has(issue.identifier))
296
+ continue;
293
297
  events.push(toEvent(issue, ctx.personUid));
294
298
  }
295
299
  if (page.hasNextPage) {
@@ -463,6 +463,10 @@ export function createMeetingsAdapter(options) {
463
463
  return { events, watermark: mark ?? new Date(fromMs).toISOString(), hasMore, deduped };
464
464
  }
465
465
  async function fetch(cursor, ctx) {
466
+ // The person saved an empty meetings pick: meetings are excluded. Nothing
467
+ // is read or written and the cursor stays where it was.
468
+ if (ctx.selection && ctx.selection.size === 0)
469
+ return { events: [], cursor, hasMore: false };
466
470
  const state = parseMeetingsCursor(cursor);
467
471
  const vault = await fetchVault(state.vault, ctx);
468
472
  const gateways = (ctx.gateways ?? []).filter((g) => MEETING_PROVIDERS.has(g.provider.toLowerCase()));
@@ -238,6 +238,9 @@ export function createSlackAdapter(options) {
238
238
  const owners = [nonEmptyString(fm.person_uid), nonEmptyString(fm.account_id)];
239
239
  if (!owners.some((id) => id !== undefined && me.has(id.toLowerCase())))
240
240
  continue;
241
+ // A saved channel pick: only threads in a chosen channel land.
242
+ if (ctx.selection && !ctx.selection.has(t.channelId))
243
+ continue;
241
244
  candidates.push(t);
242
245
  }
243
246
  if (!Number.isNaN(pausedMs) && pausedMs > floorMs) {
@@ -85,6 +85,13 @@ export interface PollContext {
85
85
  * was lost.
86
86
  */
87
87
  beforeWrite?: () => Promise<void>;
88
+ /**
89
+ * The ids the person chose to watch on this connection (hq-pro saved
90
+ * selection). When set, an adapter lands only events about one of these
91
+ * ids (Slack channel, Linear issue, Attio record); an empty set lands
92
+ * nothing. Unset means no choice was saved: land everything.
93
+ */
94
+ selection?: ReadonlySet<string>;
88
95
  }
89
96
  export interface PollPage {
90
97
  events: HotEvent[];
@@ -41,6 +41,12 @@ export interface RunLedgerEntry {
41
41
  * was asleep or the bot was stopped). Omitted for runs that were not marked.
42
42
  */
43
43
  catchUp?: boolean;
44
+ /**
45
+ * True on the one run the bot starts right after it registers its daily
46
+ * hot-signal-sync job, so a new install syncs without waiting for the first
47
+ * scheduled time. Omitted on every other run.
48
+ */
49
+ firstRun?: boolean;
44
50
  }
45
51
  /** The UTC calendar day of an instant, `YYYY-MM-DD`. Ledger files are per UTC day. */
46
52
  export declare function ledgerDay(at: Date): string;
@@ -77,6 +83,8 @@ export interface RunHotChannelSyncInput extends RunHotChannelLoopInput {
77
83
  runId?: string;
78
84
  /** Mark every ledger line of this run as a catch-up of a missed scheduled window. */
79
85
  catchUp?: boolean;
86
+ /** Mark every ledger line of this run as the job's first run (see RunLedgerEntry.firstRun). */
87
+ firstRun?: boolean;
80
88
  /**
81
89
  * The signals pass for this adapter's landed sources; returns the number of
82
90
  * signals it wrote. Any throw makes this adapter's line `failed` with a count
@@ -12,6 +12,7 @@ import { assertNotIntegrationSecretsPath, personDirName } from "./cursors.js";
12
12
  import { runHotChannelLoop } from "./loop.js";
13
13
  import { SyncLockLostError } from "./sync-lock.js";
14
14
  import { SignalsPassError } from "./signals-pass.js";
15
+ import { SELECTION_UNREADABLE_REASON } from "./selection.js";
15
16
  export const RUN_TRIGGERS = ["scheduled", "on-demand"];
16
17
  const DAY = /^\d{4}-\d{2}-\d{2}$/;
17
18
  /** The UTC calendar day of an instant, `YYYY-MM-DD`. Ledger files are per UTC day. */
@@ -52,7 +53,8 @@ function isLedgerEntry(value) {
52
53
  typeof v.finishedAt === "string" &&
53
54
  (v.status === "ok" || v.status === "failed" || v.status === "skipped") &&
54
55
  (v.error === null || typeof v.error === "string") &&
55
- (v.catchUp === undefined || typeof v.catchUp === "boolean"));
56
+ (v.catchUp === undefined || typeof v.catchUp === "boolean") &&
57
+ (v.firstRun === undefined || typeof v.firstRun === "boolean"));
56
58
  }
57
59
  /** Read one day's ledger. A missing file is an empty ledger. */
58
60
  export async function readRunLedger(vaultRoot, personKey, day) {
@@ -123,7 +125,7 @@ export async function runHotChannelSync(input) {
123
125
  }
124
126
  const now = input.now ?? (() => new Date());
125
127
  const runId = input.runId ?? `run_${randomUUID()}`;
126
- const { trigger: _trigger, runId: _runId, catchUp, signalsGenerated, ...loopInput } = input;
128
+ const { trigger: _trigger, runId: _runId, catchUp, firstRun, signalsGenerated, ...loopInput } = input;
127
129
  const results = [];
128
130
  const entries = [];
129
131
  for (const entry of input.catalog) {
@@ -175,6 +177,8 @@ export async function runHotChannelSync(input) {
175
177
  error: failure,
176
178
  personUid: input.personUid,
177
179
  catchUp: catchUp ?? false,
180
+ ...(result?.error?.code === SELECTION_UNREADABLE_REASON ? { reason: SELECTION_UNREADABLE_REASON } : {}),
181
+ ...(firstRun ? { firstRun: true } : {}),
178
182
  ...(result && result.rejected > 0
179
183
  ? { rejected: result.rejected, rejectedReasons: [...(result.rejectedReasons ?? [])] }
180
184
  : {}),
@@ -57,6 +57,12 @@ export interface RunHotChannelLoopInput {
57
57
  * `PollContext.gateway`. Called only for entries with a `connectionId`.
58
58
  */
59
59
  gatewayFor?: (entry: CatalogEntry) => HotChannelGateway | undefined;
60
+ /**
61
+ * The person's saved selection for a poll entry, handed to the adapter as
62
+ * `PollContext.selection`. Undefined means land everything; a throw skips
63
+ * this entry (fail closed) and records it as failed.
64
+ */
65
+ selectionFor?: (entry: CatalogEntry) => Promise<ReadonlySet<string> | undefined>;
60
66
  now?: () => Date;
61
67
  maxPagesPerAdapter?: number;
62
68
  maxPushEventsPerAdapter?: number;
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { createVaultCursorStore, cursorKey } from "./cursors.js";
5
5
  import { assertHotEvent, assertSafeSegment, eventKey, HotEventValidationError, landHotEvent } from "./landing.js";
6
6
  import { SyncLockLostError } from "./sync-lock.js";
7
+ import { SELECTION_UNREADABLE_REASON } from "./selection.js";
7
8
  /** Default page cap per poll adapter per run (policy: bound every wait/loop). */
8
9
  export const DEFAULT_MAX_PAGES_PER_ADAPTER = 20;
9
10
  /** Default cap on buffered push events drained per adapter per run. */
@@ -118,7 +119,7 @@ async function drainPush(ctx, adapter, result) {
118
119
  await fs.rm(file, { force: true });
119
120
  }
120
121
  }
121
- async function runPoll(ctx, adapter, result, key, gateway, gateways = []) {
122
+ async function runPoll(ctx, adapter, result, key, gateway, gateways = [], selection) {
122
123
  let cursor = result.cursorBefore;
123
124
  for (let page = 0; page < ctx.maxPages; page += 1) {
124
125
  const now = ctx.now();
@@ -129,6 +130,7 @@ async function runPoll(ctx, adapter, result, key, gateway, gateways = []) {
129
130
  beforeWrite: ctx.beforeWrite,
130
131
  ...(gateway ? { gateway } : {}),
131
132
  ...(gateways.length > 0 ? { gateways } : {}),
133
+ ...(selection ? { selection } : {}),
132
134
  });
133
135
  result.pages += 1;
134
136
  let lastEventKey;
@@ -206,7 +208,19 @@ export async function runHotChannelLoop(input) {
206
208
  });
207
209
  result.cursorBefore = await ctx.cursorStore.get(ctx.personKey, key);
208
210
  result.cursorAfter = result.cursorBefore;
209
- await runPoll(ctx, adapter, result, key, gateway, gateways);
211
+ let selection;
212
+ try {
213
+ selection = await input.selectionFor?.(entry);
214
+ }
215
+ catch (err) {
216
+ // Fail closed: a saved selection that may exist but cannot be read
217
+ // skips this connection (nothing lands, the cursor stays) and the
218
+ // run goes on to the next one.
219
+ ctx.warn(`hot-channel ${adapter.id}: saved selection unreadable (${describeError(err)?.message}); skipping this connection`);
220
+ result.error = { name: "SelectionUnreadableError", code: SELECTION_UNREADABLE_REASON, message: describeError(err)?.message ?? "unreadable" };
221
+ continue;
222
+ }
223
+ await runPoll(ctx, adapter, result, key, gateway, gateways, selection);
210
224
  }
211
225
  }
212
226
  catch (err) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Reads objects from the signed-in person's own vault bucket, the bucket
3
+ * hq-pro writes hot-channel selections to. Same path as
4
+ * `hq files cat --personal`: resolve the person entity, vend STS credentials
5
+ * through `/sts/vend-self`, then `GetObject`.
6
+ *
7
+ * The entity lookup and credential vend happen once per reader (one reader per
8
+ * run); a failed open is retried on the next read. A missing object resolves
9
+ * null; any other failure rejects, and the selection loader skips that
10
+ * connection (fail closed).
11
+ */
12
+ import { GetObjectCommand, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
13
+ import type { PersonVaultObjectReader } from "./selection.js";
14
+ export interface PersonVaultReaderDeps {
15
+ /** The signed-in person's canonical person-entity uid. */
16
+ resolvePersonUid(): Promise<string>;
17
+ getEntity(uid: string): Promise<{
18
+ uid: string;
19
+ bucketName?: string;
20
+ }>;
21
+ vendSelf(personUid: string): Promise<{
22
+ credentials: {
23
+ accessKeyId: string;
24
+ secretAccessKey: string;
25
+ sessionToken: string;
26
+ };
27
+ }>;
28
+ s3Factory(input: {
29
+ region: string;
30
+ credentials: {
31
+ accessKeyId: string;
32
+ secretAccessKey: string;
33
+ sessionToken: string;
34
+ };
35
+ }): {
36
+ send(cmd: GetObjectCommand): Promise<GetObjectCommandOutput>;
37
+ };
38
+ region: string;
39
+ }
40
+ export declare function createPersonVaultReader(deps: PersonVaultReaderDeps): PersonVaultObjectReader;
41
+ //# sourceMappingURL=person-vault-reader.d.ts.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Reads objects from the signed-in person's own vault bucket, the bucket
3
+ * hq-pro writes hot-channel selections to. Same path as
4
+ * `hq files cat --personal`: resolve the person entity, vend STS credentials
5
+ * through `/sts/vend-self`, then `GetObject`.
6
+ *
7
+ * The entity lookup and credential vend happen once per reader (one reader per
8
+ * run); a failed open is retried on the next read. A missing object resolves
9
+ * null; any other failure rejects, and the selection loader skips that
10
+ * connection (fail closed).
11
+ */
12
+ import { GetObjectCommand } from "@aws-sdk/client-s3";
13
+ function isMissingObject(err) {
14
+ const name = err?.name;
15
+ const status = err?.$metadata?.httpStatusCode;
16
+ return name === "NoSuchKey" || name === "NotFound" || status === 404;
17
+ }
18
+ export function createPersonVaultReader(deps) {
19
+ let session;
20
+ const open = async () => {
21
+ const uid = await deps.resolvePersonUid();
22
+ const entity = await deps.getEntity(uid);
23
+ // No personal bucket means hq-pro has nowhere to have saved a pick.
24
+ if (!entity.bucketName)
25
+ return null;
26
+ const vend = await deps.vendSelf(entity.uid);
27
+ return { bucket: entity.bucketName, s3: deps.s3Factory({ region: deps.region, credentials: vend.credentials }) };
28
+ };
29
+ return async (key) => {
30
+ // One open per reader (one reader per run). A failed open is not kept:
31
+ // the next read tries again, so one bad vend does not cover the run.
32
+ const opening = (session ??= open());
33
+ let opened;
34
+ try {
35
+ opened = await opening;
36
+ }
37
+ catch (err) {
38
+ if (session === opening)
39
+ session = undefined;
40
+ throw err;
41
+ }
42
+ if (opened === null)
43
+ return null;
44
+ const { bucket, s3 } = opened;
45
+ let resp;
46
+ try {
47
+ resp = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
48
+ }
49
+ catch (err) {
50
+ if (isMissingObject(err))
51
+ return null;
52
+ throw err;
53
+ }
54
+ if (!resp.Body)
55
+ return null;
56
+ return await resp.Body.transformToString();
57
+ };
58
+ }
59
+ //# sourceMappingURL=person-vault-reader.js.map