@indigoai-us/hq-cli 5.345.183 → 5.345.188

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 (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/command-catalog.generated.d.ts +29 -0
  3. package/dist/command-catalog.generated.js +35 -0
  4. package/dist/commands/agent-inbox.js +43 -11
  5. package/dist/commands/agent-mcp.js +5 -2
  6. package/dist/commands/bot.d.ts +5 -5
  7. package/dist/commands/bot.js +30 -31
  8. package/dist/commands/sources.d.ts +37 -0
  9. package/dist/commands/sources.js +104 -1
  10. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +34414 -49801
  11. package/dist/hq-cloud-sync-runner/package.json +1 -1
  12. package/dist/lib/agent-kit/inbox-fetch.d.ts +92 -0
  13. package/dist/lib/agent-kit/inbox-fetch.js +135 -0
  14. package/dist/lib/agent-kit/mcp/tools.d.ts +11 -2
  15. package/dist/lib/agent-kit/mcp/tools.js +17 -3
  16. package/dist/lib/anywhere-person-setting.js +24 -10
  17. package/dist/lib/bot/fleet-local.d.ts +4 -2
  18. package/dist/lib/bot/fleet-local.js +23 -7
  19. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -7
  20. package/dist/lib/bot/jobs/hot-signal-sync.js +106 -9
  21. package/dist/lib/bot/local-jobs.d.ts +4 -2
  22. package/dist/lib/bot/local-jobs.js +33 -4
  23. package/dist/lib/bot/run.js +2 -2
  24. package/dist/lib/hot-channels/adapters/attio.js +3 -0
  25. package/dist/lib/hot-channels/adapters/linear.js +4 -0
  26. package/dist/lib/hot-channels/adapters/meetings.js +4 -0
  27. package/dist/lib/hot-channels/adapters/slack.js +3 -0
  28. package/dist/lib/hot-channels/contract.d.ts +7 -0
  29. package/dist/lib/hot-channels/ledger.d.ts +8 -0
  30. package/dist/lib/hot-channels/ledger.js +6 -2
  31. package/dist/lib/hot-channels/loop.d.ts +6 -0
  32. package/dist/lib/hot-channels/loop.js +16 -2
  33. package/dist/lib/hot-channels/person-vault-reader.d.ts +41 -0
  34. package/dist/lib/hot-channels/person-vault-reader.js +59 -0
  35. package/dist/lib/hot-channels/run.d.ts +8 -0
  36. package/dist/lib/hot-channels/run.js +19 -0
  37. package/dist/lib/hot-channels/schedule.d.ts +64 -0
  38. package/dist/lib/hot-channels/schedule.js +162 -0
  39. package/dist/lib/hot-channels/selection.d.ts +87 -0
  40. package/dist/lib/hot-channels/selection.js +175 -0
  41. package/dist/utils/cognito-session.d.ts +1 -1
  42. package/dist/utils/cognito-session.js +16 -4
  43. package/dist/utils/shipped-codex-hooks.js +1 -1
  44. package/dist/utils/version-report.d.ts +1 -1
  45. package/dist/utils/version-report.js +20 -7
  46. package/package.json +8 -8
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.106","bundleSha256":"ae4c7a7b3040977faff6e9ea7f302bc9e46cf77675347788642e362c3337e3fe","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.107","bundleSha256":"0df48ddf2618cac4801b8abefa1d2a4b4a915f368bf7d1b2823bcdd104044325","formatVersion":1}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Bounded, once-retried read of the agent's server inbox.
3
+ *
4
+ * `hq agent inbox` and the `hq_inbox_read` MCP tool used to do one token mint
5
+ * and one GET with no deadline and no retry, so three transient conditions
6
+ * each showed up as a local-mirror fallback that cleared minutes later
7
+ * (feedback 3e376c49, DEV-4065):
8
+ *
9
+ * - the machine-token cache is shared with the kit poller and heartbeat,
10
+ * which re-mint in the same minute each hour; hq-cloud serialises mints
11
+ * behind a 20 s lock and throws "... held by another process ..." when it
12
+ * gives up waiting;
13
+ * - the server bounds its inbox pull at 18 s and answers 503 with
14
+ * `Retry-After: 2` when the listing stalls;
15
+ * - a half-open socket can hang the GET until the OS drops it.
16
+ *
17
+ * Each attempt (mint plus GET) runs under one deadline. A first attempt that
18
+ * fails for one of those reasons is retried exactly once after a short delay
19
+ * (the server's `Retry-After`, capped, when it sent one). The second attempt
20
+ * mints a fresh token, because the first failure may have been the mint. Auth
21
+ * and not-found answers, and a 200 whose body is invalid, are never retried.
22
+ * Pure apart from the injected `sleep`/`random`, so tests drive it with fake
23
+ * timers.
24
+ */
25
+ import type { MergedInbox } from "./inbox-state.js";
26
+ /** Env override for the per-attempt deadline; values under the floor are raised to it. */
27
+ export declare const AGENT_INBOX_TIMEOUT_ENV = "HQ_AGENT_INBOX_TIMEOUT_MS";
28
+ /** Delay before the one retry when the server sent no usable `Retry-After`. */
29
+ export declare const AGENT_INBOX_RETRY_BASE_MS = 1500;
30
+ /** Jitter added to the base delay, so clients that fail together do not retry together. */
31
+ export declare const AGENT_INBOX_RETRY_JITTER_MS = 500;
32
+ /** Longest wait honoured from a `Retry-After` header. */
33
+ export declare const AGENT_INBOX_RETRY_AFTER_CAP_MS = 5000;
34
+ /** Total attempts: the first read plus one retry. */
35
+ export declare const AGENT_INBOX_MAX_ATTEMPTS = 2;
36
+ export interface InboxFetchResponse {
37
+ status: number;
38
+ /** Decoded JSON body, or `undefined` when it did not decode. Never a raw string of response bytes. */
39
+ body: unknown;
40
+ /** Parsed numeric `Retry-After` header in seconds, when the server sent one. */
41
+ retryAfterSeconds?: number;
42
+ }
43
+ export interface FetchServerInboxDeps {
44
+ /** Mint (or fetch from cache) the machine token. Called again for the retry. */
45
+ mint: (signal: AbortSignal) => Promise<string>;
46
+ /** GET the inbox with `token`. Must not throw on a non-2xx status. */
47
+ fetch: (token: string, signal: AbortSignal) => Promise<InboxFetchResponse>;
48
+ sleep?: (ms: number) => Promise<void>;
49
+ /** Uniform [0, 1) source for the jitter. */
50
+ random?: () => number;
51
+ /** Per-attempt deadline covering mint plus GET. */
52
+ timeoutMs?: number;
53
+ }
54
+ export type FetchServerInboxOutcome = {
55
+ ok: true;
56
+ response: {
57
+ status: number;
58
+ body: unknown;
59
+ };
60
+ attempts: number;
61
+ } | {
62
+ ok: false;
63
+ error: unknown;
64
+ attempts: number;
65
+ };
66
+ /** Per-attempt deadline from the env, defaulting to the kit's request timeout. */
67
+ export declare function resolveAgentInboxTimeoutMs(env?: NodeJS.ProcessEnv): number;
68
+ /** Whether a thrown first attempt is worth one more try. */
69
+ export declare function isRetryableInboxError(err: unknown): boolean;
70
+ export declare function isRetryableInboxStatus(status: number): boolean;
71
+ /** `Retry-After` as seconds, or undefined for a missing, non-numeric or negative value. */
72
+ export declare function parseRetryAfterSeconds(header: string | null | undefined): number | undefined;
73
+ /**
74
+ * Read the server inbox with at most AGENT_INBOX_MAX_ATTEMPTS attempts, each
75
+ * bounded by `timeoutMs`. Never throws: the outcome carries either the last
76
+ * response or the last error, plus how many attempts were made.
77
+ */
78
+ export declare function fetchServerInboxWithRetry(deps: FetchServerInboxDeps): Promise<FetchServerInboxOutcome>;
79
+ /** Suffix added to the degradation note when the retry also failed. */
80
+ export declare const AGENT_INBOX_AFTER_ATTEMPTS_SUFFIX = " (after 2 attempts)";
81
+ export interface AnnotatedInbox extends MergedInbox {
82
+ /** True whenever the answer is not a complete server read. */
83
+ stale: boolean;
84
+ /** Present when more than one attempt was made, so a hard failure is distinct from a single blip. */
85
+ attempts?: number;
86
+ }
87
+ /**
88
+ * Add `stale` and, when a retry happened, `attempts` to a merged inbox. A
89
+ * degraded note after a retry also says so in words.
90
+ */
91
+ export declare function annotateInboxAttempts(merged: MergedInbox, attempts: number): AnnotatedInbox;
92
+ //# sourceMappingURL=inbox-fetch.d.ts.map
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Bounded, once-retried read of the agent's server inbox.
3
+ *
4
+ * `hq agent inbox` and the `hq_inbox_read` MCP tool used to do one token mint
5
+ * and one GET with no deadline and no retry, so three transient conditions
6
+ * each showed up as a local-mirror fallback that cleared minutes later
7
+ * (feedback 3e376c49, DEV-4065):
8
+ *
9
+ * - the machine-token cache is shared with the kit poller and heartbeat,
10
+ * which re-mint in the same minute each hour; hq-cloud serialises mints
11
+ * behind a 20 s lock and throws "... held by another process ..." when it
12
+ * gives up waiting;
13
+ * - the server bounds its inbox pull at 18 s and answers 503 with
14
+ * `Retry-After: 2` when the listing stalls;
15
+ * - a half-open socket can hang the GET until the OS drops it.
16
+ *
17
+ * Each attempt (mint plus GET) runs under one deadline. A first attempt that
18
+ * fails for one of those reasons is retried exactly once after a short delay
19
+ * (the server's `Retry-After`, capped, when it sent one). The second attempt
20
+ * mints a fresh token, because the first failure may have been the mint. Auth
21
+ * and not-found answers, and a 200 whose body is invalid, are never retried.
22
+ * Pure apart from the injected `sleep`/`random`, so tests drive it with fake
23
+ * timers.
24
+ */
25
+ import { networkTransportErrorCode } from "../../utils/network-transport-error.js";
26
+ import { KIT_REQUEST_TIMEOUT_MIN_MS, KIT_REQUEST_TIMEOUT_MS, KitRequestTimeoutError, withRequestTimeout } from "./run/request-timeout.js";
27
+ /** Env override for the per-attempt deadline; values under the floor are raised to it. */
28
+ export const AGENT_INBOX_TIMEOUT_ENV = "HQ_AGENT_INBOX_TIMEOUT_MS";
29
+ /** Delay before the one retry when the server sent no usable `Retry-After`. */
30
+ export const AGENT_INBOX_RETRY_BASE_MS = 1_500;
31
+ /** Jitter added to the base delay, so clients that fail together do not retry together. */
32
+ export const AGENT_INBOX_RETRY_JITTER_MS = 500;
33
+ /** Longest wait honoured from a `Retry-After` header. */
34
+ export const AGENT_INBOX_RETRY_AFTER_CAP_MS = 5_000;
35
+ /** Total attempts: the first read plus one retry. */
36
+ export const AGENT_INBOX_MAX_ATTEMPTS = 2;
37
+ // 504 is absent on purpose: vaultApiFetch already retries a 504 once
38
+ // (Lambda invoke errors), and a second outer retry would make three GETs.
39
+ const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503]);
40
+ const LOCK_HELD_RE = /held by another process/i;
41
+ const TRANSPORT_MESSAGE_RE = /\b(fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN)\b/;
42
+ /** Per-attempt deadline from the env, defaulting to the kit's request timeout. */
43
+ export function resolveAgentInboxTimeoutMs(env = process.env) {
44
+ const raw = env[AGENT_INBOX_TIMEOUT_ENV]?.trim();
45
+ if (!raw)
46
+ return KIT_REQUEST_TIMEOUT_MS;
47
+ const n = Number(raw);
48
+ if (!Number.isFinite(n))
49
+ return KIT_REQUEST_TIMEOUT_MS;
50
+ return Math.max(KIT_REQUEST_TIMEOUT_MIN_MS, Math.floor(n));
51
+ }
52
+ /** Whether a thrown first attempt is worth one more try. */
53
+ export function isRetryableInboxError(err) {
54
+ if (err instanceof KitRequestTimeoutError)
55
+ return true;
56
+ if (!(err instanceof Error))
57
+ return false;
58
+ if (LOCK_HELD_RE.test(err.message))
59
+ return true;
60
+ if (networkTransportErrorCode(err) !== null)
61
+ return true;
62
+ if (err instanceof TypeError && err.message === "fetch failed")
63
+ return true;
64
+ return TRANSPORT_MESSAGE_RE.test(err.message);
65
+ }
66
+ export function isRetryableInboxStatus(status) {
67
+ return RETRYABLE_STATUSES.has(status);
68
+ }
69
+ /** `Retry-After` as seconds, or undefined for a missing, non-numeric or negative value. */
70
+ export function parseRetryAfterSeconds(header) {
71
+ if (header === null || header === undefined)
72
+ return undefined;
73
+ const trimmed = header.trim();
74
+ if (!/^\d+(\.\d+)?$/.test(trimmed))
75
+ return undefined;
76
+ const n = Number(trimmed);
77
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
78
+ }
79
+ function retryDelayMs(retryAfterSeconds, random) {
80
+ if (retryAfterSeconds !== undefined) {
81
+ return Math.min(AGENT_INBOX_RETRY_AFTER_CAP_MS, Math.round(retryAfterSeconds * 1000));
82
+ }
83
+ return AGENT_INBOX_RETRY_BASE_MS + Math.floor(random() * AGENT_INBOX_RETRY_JITTER_MS);
84
+ }
85
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
86
+ /**
87
+ * Read the server inbox with at most AGENT_INBOX_MAX_ATTEMPTS attempts, each
88
+ * bounded by `timeoutMs`. Never throws: the outcome carries either the last
89
+ * response or the last error, plus how many attempts were made.
90
+ */
91
+ export async function fetchServerInboxWithRetry(deps) {
92
+ const sleep = deps.sleep ?? defaultSleep;
93
+ const random = deps.random ?? Math.random;
94
+ const timeoutMs = deps.timeoutMs ?? KIT_REQUEST_TIMEOUT_MS;
95
+ let attempts = 0;
96
+ for (;;) {
97
+ attempts += 1;
98
+ const last = attempts >= AGENT_INBOX_MAX_ATTEMPTS;
99
+ let retryAfterSeconds;
100
+ try {
101
+ const res = await withRequestTimeout(timeoutMs, undefined, async (signal) => {
102
+ const token = await deps.mint(signal);
103
+ return deps.fetch(token, signal);
104
+ });
105
+ if (last || !isRetryableInboxStatus(res.status)) {
106
+ return { ok: true, response: { status: res.status, body: res.body }, attempts };
107
+ }
108
+ retryAfterSeconds = res.retryAfterSeconds;
109
+ }
110
+ catch (err) {
111
+ if (last || !isRetryableInboxError(err))
112
+ return { ok: false, error: err, attempts };
113
+ }
114
+ await sleep(retryDelayMs(retryAfterSeconds, random));
115
+ }
116
+ }
117
+ /** Suffix added to the degradation note when the retry also failed. */
118
+ export const AGENT_INBOX_AFTER_ATTEMPTS_SUFFIX = ` (after ${AGENT_INBOX_MAX_ATTEMPTS} attempts)`;
119
+ /**
120
+ * Add `stale` and, when a retry happened, `attempts` to a merged inbox. A
121
+ * degraded note after a retry also says so in words.
122
+ */
123
+ export function annotateInboxAttempts(merged, attempts) {
124
+ const stale = merged.source !== "server";
125
+ const retried = attempts > 1;
126
+ const note = merged.note && retried && stale ? `${merged.note}${AGENT_INBOX_AFTER_ATTEMPTS_SUFFIX}` : merged.note;
127
+ return {
128
+ entries: merged.entries,
129
+ source: merged.source,
130
+ stale,
131
+ ...(note ? { note } : {}),
132
+ ...(retried ? { attempts } : {}),
133
+ };
134
+ }
135
+ //# sourceMappingURL=inbox-fetch.js.map
@@ -16,6 +16,7 @@
16
16
  * tool result.
17
17
  */
18
18
  import type { ExternalMachineCreds } from "../creds.js";
19
+ import { type FetchServerInboxDeps } from "../inbox-fetch.js";
19
20
  import type { AgentKitPaths } from "../paths.js";
20
21
  import { type McpTool } from "./jsonrpc.js";
21
22
  export interface HqRunResult {
@@ -32,7 +33,8 @@ export interface McpToolClients {
32
33
  paths: Pick<AgentKitPaths, "inboxDir" | "attachmentsDir">;
33
34
  /** Run `hq <args…>` as the machine identity; never throws. */
34
35
  runHq: (args: string[]) => Promise<HqRunResult>;
35
- getToken: () => Promise<string>;
36
+ /** Machine token; `signal` cancels this caller's wait on a shared mint. */
37
+ getToken: (signal?: AbortSignal) => Promise<string>;
36
38
  /**
37
39
  * Whether this kit mirrors the vault to disk. Off by default, and it decides
38
40
  * what `hq_search` can honestly do: the local qmd index only exists when
@@ -47,15 +49,22 @@ export interface McpToolClients {
47
49
  * key through the presign API, as the machine identity.
48
50
  */
49
51
  downloadVaultObject: (scopeUid: string, key: string) => Promise<Buffer>;
50
- /** Authenticated JSON call against the control plane. */
52
+ /**
53
+ * Authenticated JSON call against the control plane. `retryAfterSeconds` is
54
+ * the server's numeric `Retry-After`, when it sent one.
55
+ */
51
56
  apiJson: (token: string, path: string, init?: {
52
57
  method?: string;
53
58
  body?: Record<string, unknown>;
54
59
  query?: Record<string, string>;
60
+ signal?: AbortSignal;
55
61
  }) => Promise<{
56
62
  status: number;
57
63
  body: unknown;
64
+ retryAfterSeconds?: number;
58
65
  }>;
66
+ /** Test seam for hq_inbox_read's bounded retry (sleep, jitter source, per-attempt deadline). */
67
+ inboxRetry?: Pick<FetchServerInboxDeps, "sleep" | "random" | "timeoutMs">;
59
68
  }
60
69
  export declare function clampOutput(text: string, max?: number): string;
61
70
  /** Vault paths are company-anchored and must not escape. */
@@ -19,6 +19,7 @@ import { CLI_VERSION } from "../../../cli-version.js";
19
19
  import { peekIdToken } from "../../../utils/id-token.js";
20
20
  import { PERSON_AGENT_AUDIENCE_MESSAGE, callerMayUseAudience, defaultAudienceFor, isAgentUid, isRecipientUid } from "../../../lib/notify-recipient.js";
21
21
  import { tokenIsMachinePrincipal } from "../../../lib/caller-principal.js";
22
+ import { annotateInboxAttempts, fetchServerInboxWithRetry, resolveAgentInboxTimeoutMs } from "../inbox-fetch.js";
22
23
  import { redactAgentInboxItem } from "../inbox-redact.js";
23
24
  import { markInboxDone, mergeAgentInbox, readDoneIds, summarizeInboxEntry } from "../inbox-state.js";
24
25
  import { AttachmentCompanyUnresolvedError, fetchChatAttachment } from "./attachments.js";
@@ -286,10 +287,21 @@ export function buildAgentMcpTools(clients) {
286
287
  const limit = int(args, "limit", { min: 1, max: 100, fallback: 20 });
287
288
  const includeDone = args.include_done === true;
288
289
  const done = readDoneIds(clients.paths);
289
- const { entries, note: serverNote, source } = await mergeAgentInbox(clients.paths, async () => {
290
- const token = await clients.getToken();
291
- return clients.apiJson(token, `/v1/agents/${encodeURIComponent(creds.entityUid)}/inbox`);
290
+ // Same bounded, once-retried server read as `hq agent inbox`.
291
+ let attempts = 1;
292
+ const merged = await mergeAgentInbox(clients.paths, async () => {
293
+ const outcome = await fetchServerInboxWithRetry({
294
+ timeoutMs: resolveAgentInboxTimeoutMs(process.env),
295
+ ...clients.inboxRetry,
296
+ mint: (signal) => clients.getToken(signal),
297
+ fetch: (token, signal) => clients.apiJson(token, `/v1/agents/${encodeURIComponent(creds.entityUid)}/inbox`, { signal }),
298
+ });
299
+ attempts = outcome.attempts;
300
+ if (!outcome.ok)
301
+ throw outcome.error;
302
+ return outcome.response;
292
303
  });
304
+ const { entries, note: serverNote, source, stale, attempts: attemptsMade } = annotateInboxAttempts(merged, attempts);
293
305
  const byId = new Map(entries.map((e) => [e.id, e]));
294
306
  const rows = [...byId.values()]
295
307
  .filter((e) => includeDone || !done.has(e.id))
@@ -299,6 +311,8 @@ export function buildAgentMcpTools(clients) {
299
311
  return textResult(clampOutput(JSON.stringify({
300
312
  count: rows.length,
301
313
  source,
314
+ stale,
315
+ ...(attemptsMade !== undefined ? { attempts: attemptsMade } : {}),
302
316
  messages: rows,
303
317
  ...(serverNote ? { note: serverNote } : {}),
304
318
  ...(hasFiles ? { attachmentsHint: ATTACHMENTS_HINT } : {}),
@@ -7,6 +7,20 @@ function hasPersonTokenCache(env, home) {
7
7
  const stateDir = env.HQ_STATE_DIR?.trim() || path.join(home, ".hq");
8
8
  return fs.existsSync(path.join(stateDir, "cognito-tokens.json"));
9
9
  }
10
+ const HQ_ANYWHERE_OPT_OUT_KEY = "hq-anywhere-opt-out";
11
+ const HQ_ANYWHERE_OPT_IN_KEY = "hq-anywhere";
12
+ async function readPersonSettingEnvelope(fetchImpl, apiBaseUrl, token, key) {
13
+ const response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/v1/bot/auto-schedule?setting=${key}`, { method: "GET", headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(5_000) });
14
+ if (!response.ok)
15
+ return null;
16
+ const body = await response.json();
17
+ if (typeof body !== "object" || body === null || Array.isArray(body))
18
+ return null;
19
+ const envelope = body;
20
+ if (envelope.key !== key || typeof envelope.value !== "boolean")
21
+ return null;
22
+ return { key: envelope.key, value: envelope.value };
23
+ }
10
24
  /** Returns null when the person preference cannot be read; callers must fail closed. */
11
25
  export async function readHqAnywherePersonSetting(options = {}) {
12
26
  const env = options.env ?? process.env;
@@ -21,16 +35,16 @@ export async function readHqAnywherePersonSetting(options = {}) {
21
35
  });
22
36
  const token = await getPersonToken();
23
37
  const apiBaseUrl = options.apiBaseUrl ?? env.HQ_PRO_API_URL ?? env.HQ_API_URL ?? env.HQ_VAULT_API_URL ?? "https://hqapi.hq.computer";
24
- const response = await (options.fetchImpl ?? fetch)(`${apiBaseUrl.replace(/\/$/, "")}/v1/bot/auto-schedule?setting=hq-anywhere`, { method: "GET", headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(5_000) });
25
- if (!response.ok)
26
- throw new Error(`Http${response.status}`);
27
- const body = await response.json();
28
- if (typeof body !== "object" || body === null || Array.isArray(body))
29
- throw new Error("InvalidEnvelope");
30
- const envelope = body;
31
- if (envelope.key !== "hq-anywhere" || typeof envelope.value !== "boolean")
32
- throw new Error("InvalidEnvelope");
33
- return envelope.value;
38
+ const fetchImpl = options.fetchImpl ?? fetch;
39
+ // Desktop Settings writes hq-anywhere-opt-out (hq-pro #5241). true = opted
40
+ // out. A person who never chose reads false, which means Anywhere is on.
41
+ const optOut = await readPersonSettingEnvelope(fetchImpl, apiBaseUrl, token, HQ_ANYWHERE_OPT_OUT_KEY);
42
+ if (optOut)
43
+ return !optOut.value;
44
+ const optIn = await readPersonSettingEnvelope(fetchImpl, apiBaseUrl, token, HQ_ANYWHERE_OPT_IN_KEY);
45
+ if (optIn)
46
+ return optIn.value;
47
+ throw new Error("InvalidEnvelope");
34
48
  }
35
49
  catch (error) {
36
50
  const errorName = error instanceof Error && /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(error.name)
@@ -136,7 +136,7 @@ export interface FleetLocalRemovalResult {
136
136
  credentialsRemoved: number;
137
137
  }
138
138
  export declare const HQ_FLEET_LOCAL_RELEASE: FleetLocalReleasePin;
139
- export declare const HQ_FLEET_LOCAL_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAAlGqxr0zWOUVIX7OnS1+7YQH2sJJAlulwL/VPSYlnUA=\n-----END PUBLIC KEY-----\n";
139
+ export declare const HQ_FLEET_LOCAL_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEASJoapvo0WMTg+Wu3Jvt0nzlXLOBEr+G1aJRi0JkEb4A=\n-----END PUBLIC KEY-----\n";
140
140
  export declare function fleetHome(home?: string, env?: NodeJS.ProcessEnv): string;
141
141
  export declare function verifyFleetLocalRelease(files: FleetLocalReleaseFiles, release?: FleetLocalReleasePin, publicKey?: string): void;
142
142
  export declare function resolveFleetReleaseToken(env?: NodeJS.ProcessEnv): string;
@@ -147,7 +147,9 @@ export declare const FLEET_LOCAL_RUNTIME_DOWNLOAD_TIMEOUT_MS: number;
147
147
  export declare function installPinnedFleetLocalDistribution(deps?: FleetLocalInstallDeps): Promise<FleetLocalInstallation>;
148
148
  /** The process id the bot's gateway recorded, when its file reads; the caller checks whether it is alive. */
149
149
  export declare function fleetLocalGatewayPid(agentUid: string, deps?: Pick<FleetLocalControlDeps, "fleetHome">): number | null;
150
- export declare function fleetLocalProfileNeedsAdoption(agentUid: string, deps?: Pick<FleetLocalControlDeps, "fleetHome" | "platform">): boolean;
150
+ export declare function fleetLocalProfileNeedsAdoption(agentUid: string, deps?: Pick<FleetLocalControlDeps, "fleetHome" | "platform"> & {
151
+ release?: FleetLocalReleasePin;
152
+ }): boolean;
151
153
  export declare function controlFleetLocalBot(agentUid: string, action: FleetLocalServiceAction, deps?: FleetLocalControlDeps): Record<string, unknown>;
152
154
  export declare function exportFleetLocalMachineCredential(agentUid: string, deps?: FleetLocalControlDeps): BotMachineCreds;
153
155
  export declare function fleetLocalPromotionRuntime(agentUid: string, deps?: FleetLocalControlDeps): LocalBotPromotionRuntime;
@@ -36,18 +36,18 @@ export function fleetProviderNeedsSignIn(provider) {
36
36
  }
37
37
  export const HQ_FLEET_LOCAL_RELEASE = {
38
38
  repository: "indigoai-us/hq-fleet-local",
39
- tag: "v0.4.16",
40
- version: "0.4.16",
41
- artifact: "hq-fleet-local-0.4.16.zip",
42
- artifactSha256: "36ff9ba72c333152dc19af18b298800d9f0aee24e11757d6e4c9f3e378e11c1a",
39
+ tag: "v0.4.17",
40
+ version: "0.4.17",
41
+ artifact: "hq-fleet-local-0.4.17.zip",
42
+ artifactSha256: "d2c5c49d4ef47da6003f4c2f3be7dcd2d8a287523c8577dfe0239e6e0705e4b4",
43
43
  runtimeArtifact: "hq-fleet-runtime-abccac13bd9e.tar",
44
44
  runtimeArtifactSha256: "29bcfe718ad7fbd3636afd00623772f2a25bc84c4f6f10d8d57f38e549d528d5",
45
- manifestSha256: "baf11852057cc3c4e622576fcf159bea7daf7047f9642046d13f26d6d3f83710",
46
- signatureSha256: "14df153280d7da496daac10d5d5fc1b8ad7b3bad500632b0deab6accc2606aba",
45
+ manifestSha256: "63663ffe8c6bef4f2aefa587d20431156cab4170a0f23533cdab7aab444a7d35",
46
+ signatureSha256: "bb4af0b453ae03fdbc18e6335ca23e2f0de42bd9e322dba7c4f31d0c8dc08aa7",
47
47
  runtimeCommit: "abccac13bd9ea593b24c8cd9c0ba28d5c6050cc9",
48
48
  };
49
49
  export const HQ_FLEET_LOCAL_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
50
- MCowBQYDK2VwAyEAAlGqxr0zWOUVIX7OnS1+7YQH2sJJAlulwL/VPSYlnUA=
50
+ MCowBQYDK2VwAyEASJoapvo0WMTg+Wu3Jvt0nzlXLOBEr+G1aJRi0JkEb4A=
51
51
  -----END PUBLIC KEY-----
52
52
  `;
53
53
  function digest(value) {
@@ -388,6 +388,20 @@ function sharedRuntimeFromMarker(profileDir, root) {
388
388
  return null;
389
389
  }
390
390
  }
391
+ function profileEngineMatchesRelease(profileDir, release) {
392
+ try {
393
+ const marker = JSON.parse(fs.readFileSync(path.join(profileDir, "bootstrap.json"), "utf8"));
394
+ const expected = engineMetadata(release);
395
+ return marker.engine?.id === expected.id &&
396
+ marker.engine.distribution === expected.distribution &&
397
+ marker.engine.version === expected.version &&
398
+ marker.engine.artifactSha256 === expected.artifactSha256 &&
399
+ marker.engine.runtimeCommit === expected.runtimeCommit;
400
+ }
401
+ catch {
402
+ return false;
403
+ }
404
+ }
391
405
  function hasEmbeddedRuntime(profileDir, platform) {
392
406
  const runtimeDir = path.join(profileDir, "runtime");
393
407
  try {
@@ -467,6 +481,8 @@ export function fleetLocalProfileNeedsAdoption(agentUid, deps = {}) {
467
481
  const shared = sharedRuntimeFromMarker(profileDir, root);
468
482
  if (!shared)
469
483
  return true;
484
+ if (!profileEngineMatchesRelease(profileDir, deps.release ?? HQ_FLEET_LOCAL_RELEASE))
485
+ return true;
470
486
  const platform = deps.platform ?? process.platform;
471
487
  if (!["hq-fleet", "hq-fleet-local", "python"].every((name) => fs.existsSync(venvExecutable(shared.runtimeDir, name, platform))))
472
488
  return true;
@@ -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;