@indigoai-us/hq-cli 5.345.169 → 5.345.171

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 (140) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
  3. package/dist/command-catalog.generated.d.ts +181 -9
  4. package/dist/command-catalog.generated.js +224 -10
  5. package/dist/commands/access.d.ts +32 -1
  6. package/dist/commands/access.js +119 -5
  7. package/dist/commands/agent-kit.d.ts +1 -1
  8. package/dist/commands/agent-kit.js +3 -1
  9. package/dist/commands/agents.js +2 -1
  10. package/dist/commands/bot.d.ts +3 -0
  11. package/dist/commands/bot.js +141 -14
  12. package/dist/commands/cloud.d.ts +78 -1
  13. package/dist/commands/cloud.js +274 -9
  14. package/dist/commands/core-company-index-scope-flag.d.ts +8 -1
  15. package/dist/commands/core-company-index-scope-flag.js +9 -2
  16. package/dist/commands/core-delegate-w1b.js +5 -2
  17. package/dist/commands/core-dm-bind-native.js +19 -4
  18. package/dist/commands/core-rebuild-index-fast.js +24 -6
  19. package/dist/commands/core.js +18 -6
  20. package/dist/commands/dm.d.ts +17 -3
  21. package/dist/commands/dm.js +121 -26
  22. package/dist/commands/feedback-triage.d.ts +43 -1
  23. package/dist/commands/feedback-triage.js +123 -23
  24. package/dist/commands/feedback.d.ts +40 -1
  25. package/dist/commands/feedback.js +184 -4
  26. package/dist/commands/files.d.ts +42 -0
  27. package/dist/commands/files.js +223 -30
  28. package/dist/commands/integrations-api.d.ts +4 -0
  29. package/dist/commands/integrations-api.js +18 -2
  30. package/dist/commands/integrations-connect.d.ts +10 -3
  31. package/dist/commands/integrations-connect.js +70 -6
  32. package/dist/commands/integrations-core.d.ts +23 -0
  33. package/dist/commands/integrations-core.js +100 -0
  34. package/dist/commands/integrations-manage.js +8 -1
  35. package/dist/commands/integrations.js +49 -5
  36. package/dist/commands/lanes-loop.d.ts +20 -0
  37. package/dist/commands/lanes-loop.js +69 -11
  38. package/dist/commands/lanes.js +9 -1
  39. package/dist/commands/mesh.js +6 -0
  40. package/dist/commands/rescue.d.ts +9 -0
  41. package/dist/commands/rescue.js +31 -0
  42. package/dist/commands/run.js +115 -40
  43. package/dist/commands/scan.d.ts +5 -1
  44. package/dist/commands/scan.js +16 -4
  45. package/dist/commands/secrets.js +50 -9
  46. package/dist/commands/sync-manifest.d.ts +16 -0
  47. package/dist/commands/sync-manifest.js +17 -1
  48. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2367 -2109
  49. package/dist/hq-cloud-sync-runner/package.json +1 -1
  50. package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
  51. package/dist/lib/agent-kit/inbox-state.js +41 -20
  52. package/dist/lib/agent-kit/kit-config.d.ts +2 -0
  53. package/dist/lib/agent-kit/kit-config.js +6 -0
  54. package/dist/lib/agent-kit/mcp/tools.js +12 -5
  55. package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
  56. package/dist/lib/agent-kit/run/heartbeat.js +6 -3
  57. package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
  58. package/dist/lib/agent-kit/run/inbox.js +42 -10
  59. package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
  60. package/dist/lib/agent-kit/run/request-timeout.js +51 -0
  61. package/dist/lib/bot/api.d.ts +1 -1
  62. package/dist/lib/bot/config.d.ts +10 -0
  63. package/dist/lib/bot/config.js +17 -1
  64. package/dist/lib/bot/decisions.d.ts +62 -0
  65. package/dist/lib/bot/decisions.js +228 -0
  66. package/dist/lib/bot/fleet-local.d.ts +127 -0
  67. package/dist/lib/bot/fleet-local.js +638 -0
  68. package/dist/lib/bot/index.d.ts +2 -0
  69. package/dist/lib/bot/index.js +2 -0
  70. package/dist/lib/bot/local-config.d.ts +3 -2
  71. package/dist/lib/bot/local-config.js +5 -2
  72. package/dist/lib/bot/promote.d.ts +10 -0
  73. package/dist/lib/bot/promote.js +3 -2
  74. package/dist/lib/bot/run.d.ts +7 -0
  75. package/dist/lib/bot/run.js +104 -1
  76. package/dist/lib/bot/runtime/index.js +8 -4
  77. package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
  78. package/dist/lib/bot/runtime/scratch-dir.js +41 -0
  79. package/dist/lib/caller-principal.d.ts +4 -0
  80. package/dist/lib/caller-principal.js +40 -0
  81. package/dist/lib/capacity/collectors/grok.js +8 -2
  82. package/dist/lib/cli-flag-keys.d.ts +2 -0
  83. package/dist/lib/cli-flag-keys.js +2 -0
  84. package/dist/lib/daemon/catalog.d.ts +4 -0
  85. package/dist/lib/daemon/catalog.js +63 -29
  86. package/dist/lib/daemon/config.d.ts +7 -0
  87. package/dist/lib/daemon/config.js +32 -0
  88. package/dist/lib/daemon/run.d.ts +3 -2
  89. package/dist/lib/daemon/run.js +23 -3
  90. package/dist/lib/daemon/supervisor.d.ts +1 -0
  91. package/dist/lib/daemon/supervisor.js +4 -0
  92. package/dist/lib/daemon/sync-core-canary.d.ts +12 -0
  93. package/dist/lib/daemon/sync-core-canary.js +61 -0
  94. package/dist/lib/doctor/checks/release-links.d.ts +62 -1
  95. package/dist/lib/doctor/checks/release-links.js +188 -2
  96. package/dist/lib/doctor/checks/sync-health.d.ts +32 -0
  97. package/dist/lib/doctor/checks/sync-health.js +54 -1
  98. package/dist/lib/doctor/fix/apply.d.ts +26 -4
  99. package/dist/lib/doctor/fix/apply.js +149 -11
  100. package/dist/lib/doctor/fix/remediation.d.ts +8 -1
  101. package/dist/lib/doctor/fix/remediation.js +22 -0
  102. package/dist/lib/doctor/types.d.ts +1 -1
  103. package/dist/lib/doctor/types.js +1 -0
  104. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  105. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  106. package/dist/lib/index-render/index.d.ts +9 -0
  107. package/dist/lib/index-render/index.js +65 -5
  108. package/dist/lib/index-render/session-scope.d.ts +69 -0
  109. package/dist/lib/index-render/session-scope.js +279 -0
  110. package/dist/lib/lanes/admission.js +7 -20
  111. package/dist/lib/lanes/reconcile.js +13 -15
  112. package/dist/lib/lanes/stale-lock.d.ts +50 -0
  113. package/dist/lib/lanes/stale-lock.js +105 -0
  114. package/dist/lib/lanes/store.js +11 -76
  115. package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
  116. package/dist/lib/notify-recipient.d.ts +10 -4
  117. package/dist/lib/notify-recipient.js +16 -4
  118. package/dist/lib/secret-input-link.d.ts +42 -0
  119. package/dist/lib/secret-input-link.js +93 -0
  120. package/dist/lib/secret-scan/range.d.ts +57 -0
  121. package/dist/lib/secret-scan/range.js +266 -0
  122. package/dist/lib/secret-scan/repo-config.d.ts +18 -3
  123. package/dist/lib/secret-scan/repo-config.js +135 -2
  124. package/dist/lib/work-context/organize.d.ts +7 -0
  125. package/dist/lib/work-context/organize.js +21 -1
  126. package/dist/run/script-bound-schema.d.ts +27 -0
  127. package/dist/run/script-bound-schema.js +97 -0
  128. package/dist/secrets-env-action.d.ts +2 -0
  129. package/dist/secrets-env-action.js +7 -2
  130. package/dist/sentry-before-send.js +1 -0
  131. package/dist/utils/cognito-session.d.ts +3 -30
  132. package/dist/utils/cognito-session.js +2 -100
  133. package/dist/utils/machine-principal-state.d.ts +38 -0
  134. package/dist/utils/machine-principal-state.js +110 -0
  135. package/dist/utils/safe-link.js +9 -0
  136. package/dist/utils/script-bound-exec.d.ts +158 -0
  137. package/dist/utils/script-bound-exec.js +568 -0
  138. package/dist/utils/sync-watchdog.d.ts +92 -0
  139. package/dist/utils/sync-watchdog.js +250 -0
  140. package/package.json +8 -8
@@ -9,7 +9,7 @@
9
9
  * checks the result before it leaves this machine: a bot's machine credentials
10
10
  * live only in ~/.hq/bots/<name>/machine-creds.json and never travel here.
11
11
  */
12
- import { botMemoryMode, effectiveBotCompanies, effectiveBotKind, isBotKind, isBotMemoryMode, isBotRuntimeId, } from "./config.js";
12
+ import { botMemoryMode, effectiveBotCompanies, effectiveBotKind, isBotKind, isBotEngineMetadata, isBotMemoryMode, isBotRuntimeId, } from "./config.js";
13
13
  /** Same limit the server enforces on the stored value. */
14
14
  const BOT_LOCAL_CONFIG_MAX_BYTES = 8 * 1024;
15
15
  /** Key names the server rejects outright (case-insensitive, substring). */
@@ -41,7 +41,8 @@ export function buildBotLocalConfig(config) {
41
41
  const companies = effectiveBotCompanies(config);
42
42
  const local = {
43
43
  v: 1,
44
- runtime: config.runtime,
44
+ ...(config.engine ? {} : { runtime: config.runtime }),
45
+ ...(config.engine ? { engine: config.engine } : {}),
45
46
  workerSource: config.workerSource ?? "scaffold",
46
47
  ...(config.workerId ? { workerId: config.workerId } : {}),
47
48
  ...(config.companySlug ? { companySlug: config.companySlug } : {}),
@@ -80,6 +81,8 @@ export function parseBotLocalConfig(value) {
80
81
  const runtime = str("runtime");
81
82
  if (isBotRuntimeId(runtime))
82
83
  out.runtime = runtime;
84
+ if (isBotEngineMetadata(raw.engine))
85
+ out.engine = raw.engine;
83
86
  const workerSource = str("workerSource");
84
87
  if (workerSource === "worker" || workerSource === "scaffold")
85
88
  out.workerSource = workerSource;
@@ -1,5 +1,14 @@
1
1
  import type { BotApi, BotPromotionState } from "./api.js";
2
2
  import type { BotConfig } from "./config.js";
3
+ import type { BotMachineCreds } from "./creds.js";
4
+ /** A bot whose process and HQ machine credential live outside its state folder,
5
+ * such as an HQ Fleet Runtime bot: promotion stops its service through the
6
+ * runtime and reads the credential from the runtime's secure store.
7
+ */
8
+ export interface LocalBotPromotionRuntime {
9
+ stop(): void;
10
+ machineCredential(): BotMachineCreds;
11
+ }
3
12
  /** One recoverable local-to-cloud step, used by CLI and desktop. Never kills the
4
13
  * local runtime, recopies provider login files, or changes another bot.
5
14
  */
@@ -9,6 +18,7 @@ export declare function promoteLocalBot(input: {
9
18
  companyUid: string;
10
19
  api: BotApi;
11
20
  drainTimeoutMs?: number;
21
+ runtime?: LocalBotPromotionRuntime;
12
22
  }): Promise<{
13
23
  promotion: BotPromotionState;
14
24
  pending: boolean;
@@ -41,7 +41,7 @@ function readPrivateJson(file, limit) {
41
41
  * local runtime, recopies provider login files, or changes another bot.
42
42
  */
43
43
  export async function promoteLocalBot(input) {
44
- const { dir, config, companyUid, api } = input;
44
+ const { dir, config, companyUid, api, runtime } = input;
45
45
  const existing = await api.promotionStatus(config.agentUid, companyUid);
46
46
  const holdFile = path.join(dir, "promotion-hold.json");
47
47
  const localHold = fs.existsSync(holdFile) ? readPrivateJson(holdFile, 2048) : null;
@@ -58,6 +58,7 @@ export async function promoteLocalBot(input) {
58
58
  recordActivePromotion(dir, hold, existing);
59
59
  return { promotion: existing, pending: false };
60
60
  }
61
+ runtime?.stop();
61
62
  await waitForPromotionDrain(dir, input.drainTimeoutMs ?? 60_000);
62
63
  const file = path.join(dir, `promotion-snapshot-${operationId}.json`);
63
64
  if (!fs.existsSync(file)) {
@@ -83,7 +84,7 @@ export async function promoteLocalBot(input) {
83
84
  throw new Error("Frozen snapshot does not match the cloud operation");
84
85
  let state = existing;
85
86
  if (!state || state.phase === "preparing") {
86
- const machine = readPrivateJson(botCredsPath(dir), 8192);
87
+ const machine = runtime ? runtime.machineCredential() : readPrivateJson(botCredsPath(dir), 8192);
87
88
  if (!machine || typeof machine !== "object" || typeof machine.secret !== "string")
88
89
  throw new Error("Invalid bot machine credential");
89
90
  try {
@@ -54,6 +54,7 @@ import { type BotLogger } from "./log.js";
54
54
  import { type CodingToolActivity, type DetectCodingToolActivityInput } from "./coding-tool-activity.js";
55
55
  import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "./runtime/index.js";
56
56
  import { type BotSessionScope } from "./paths.js";
57
+ import { type DecisionDmClient } from "./decisions.js";
57
58
  import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
58
59
  import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../heartbeat-identity.js";
59
60
  import { type BotState, type BotStatusFile } from "./status.js";
@@ -188,6 +189,12 @@ export interface BotRunDeps {
188
189
  * never read the real home folder.
189
190
  */
190
191
  detectCodingToolActivity?: (input: DetectCodingToolActivityInput) => CodingToolActivity;
192
+ /**
193
+ * US-005: the decisions client (`hq decide`), called with the bot's own
194
+ * machine token. Owner DMs that answer a decision talking in this owner-bot
195
+ * thread are recorded through it. Unset leaves every owner DM on the model path.
196
+ */
197
+ decisions?: DecisionDmClient;
191
198
  }
192
199
  export declare const KICKOFF_MESSAGE_ID = "kickoff";
193
200
  /**
@@ -74,6 +74,7 @@ import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
74
74
  import { botAttachmentsDir, dmSessionScope, isDmSessionScope } from "./paths.js";
75
75
  import { briefJobRunId, briefJobSessionScope, isBriefJobSessionScope } from "./brief-job.js";
76
76
  import { appendDmHistory, dmHistoryHasModelThread, markDmHistoryModelTurn, readDmHistory, withRecentDmHistory } from "./dm-history.js";
77
+ import { handleDecisionDm, looksLikePick, DECISIONS_NOT_LIVE_REPLY, readDecisionTalkState, writeDecisionTalkState } from "./decisions.js";
77
78
  import { attachmentTurnBlock, downloadTurnAttachments } from "./attachments.js";
78
79
  import { inboxEntryAttachments } from "../agent-kit/inbox-state.js";
79
80
  import { isAgentSender, isDmItem, isMetaNonReply, resolveReplyTarget } from "./room-policy.js";
@@ -1942,6 +1943,104 @@ export async function runBot(deps) {
1942
1943
  log("info", `answered ${id} with the setup explanation (no model turn)`);
1943
1944
  return true;
1944
1945
  };
1946
+ /**
1947
+ * US-005: an owner DM about a decision talking in this owner-bot thread.
1948
+ * Returns `done` when the message was answered here with no model turn
1949
+ * (a recorded pick, an ambiguity question, or a failure line), or a note for
1950
+ * the model turn. A send failure leaves the message unacked for the next poll.
1951
+ */
1952
+ let decisionsNotLiveUntil = 0;
1953
+ const DECISIONS_NOT_LIVE_RECHECK_MS = 10 * 60 * 1000;
1954
+ const runDecisionDm = async (item, text) => {
1955
+ const decisionClient = deps.decisions;
1956
+ if (!decisionClient || item.fromPersonUid !== config.ownerUid)
1957
+ return { done: false };
1958
+ let result;
1959
+ const pendingReply = readDecisionTalkState(dir).pending?.[item.messageId];
1960
+ if (pendingReply !== undefined) {
1961
+ // Decided on an earlier poll (the pick may already be saved): only resend.
1962
+ result = { kind: "reply", text: pendingReply, recorded: false };
1963
+ }
1964
+ else if (now().getTime() < decisionsNotLiveUntil) {
1965
+ result = looksLikePick(text) ? { kind: "reply", text: DECISIONS_NOT_LIVE_REPLY, recorded: false, notLive: true } : { kind: "none" };
1966
+ }
1967
+ else {
1968
+ let companyUids = null;
1969
+ if (deps.ownerContext) {
1970
+ try {
1971
+ const ctx = await deps.ownerContext();
1972
+ if (ctx.status === "ok")
1973
+ companyUids = ctx.companies.map((company) => company.companyUid);
1974
+ }
1975
+ catch (err) {
1976
+ log("warn", `decision company check failed (${err instanceof Error ? err.message : String(err)}); no pick is recorded`);
1977
+ }
1978
+ }
1979
+ const talk = readDecisionTalkState(dir);
1980
+ result = await handleDecisionDm({
1981
+ text,
1982
+ speakerUid: item.fromPersonUid,
1983
+ ...(item.rootEventId ? { rootEventId: item.rootEventId } : {}),
1984
+ ownerUid: config.ownerUid,
1985
+ agentUid: config.agentUid,
1986
+ companyUids,
1987
+ nowMs: now().getTime(),
1988
+ restated: new Set(Object.keys(talk.restated)),
1989
+ }, decisionClient);
1990
+ if (result.kind !== "model" && result.notLive) {
1991
+ decisionsNotLiveUntil = now().getTime() + DECISIONS_NOT_LIVE_RECHECK_MS;
1992
+ log("warn", "decisions API returned 404 (not deployed here); decision picks are off for 10 minutes");
1993
+ }
1994
+ if (result.kind === "model" && result.restate) {
1995
+ try {
1996
+ await sendToTarget({ kind: "dm", peerUid: config.ownerUid }, result.restate, item.rootEventId, "human");
1997
+ const latest = readDecisionTalkState(dir);
1998
+ writeDecisionTalkState(dir, { ...latest, restated: { ...latest.restated, [result.decisionId]: now().toISOString() } });
1999
+ }
2000
+ catch (err) {
2001
+ log("warn", `decision restate for ${result.decisionId} not sent (${err instanceof Error ? err.message : String(err)})`);
2002
+ }
2003
+ }
2004
+ }
2005
+ if (result.kind === "none")
2006
+ return { done: false };
2007
+ if (result.kind === "model")
2008
+ return { done: false, note: result.note };
2009
+ const id = item.messageId;
2010
+ const setPending = (text) => {
2011
+ const state = readDecisionTalkState(dir);
2012
+ const pending = { ...(state.pending ?? {}) };
2013
+ if (text === null)
2014
+ delete pending[id];
2015
+ else
2016
+ pending[id] = text;
2017
+ writeDecisionTalkState(dir, { v: 1, restated: state.restated, ...(Object.keys(pending).length ? { pending } : {}) });
2018
+ };
2019
+ try {
2020
+ setPending(result.text);
2021
+ }
2022
+ catch (err) {
2023
+ log("warn", `decision reply for ${id} not saved locally (${err instanceof Error ? err.message : String(err)})`);
2024
+ }
2025
+ try {
2026
+ await sendToTarget({ kind: "dm", peerUid: config.ownerUid }, result.text, item.rootEventId, "human");
2027
+ }
2028
+ catch (err) {
2029
+ log("warn", `decision reply for ${id} could not be sent (${err instanceof Error ? err.message : String(err)}); it is retried on the next poll`);
2030
+ return { done: true };
2031
+ }
2032
+ markProcessed(dir, id, now().toISOString());
2033
+ try {
2034
+ setPending(null);
2035
+ }
2036
+ catch {
2037
+ // A stale entry is harmless: the message is processed and never re-read.
2038
+ }
2039
+ await api.ackInbox(config.agentUid, id);
2040
+ setStatus({ repliesSent: (readStatus()?.repliesSent ?? 0) + 1, lastActivityAt: now().toISOString() });
2041
+ log("info", `answered ${id} for decision ${result.decisionId ?? "(none)"}${result.recorded ? " (pick recorded)" : ""}, no model turn`);
2042
+ return { done: true };
2043
+ };
1945
2044
  const handleItem = async (item) => {
1946
2045
  const id = item.messageId;
1947
2046
  const inboxState = readInboxState(dir);
@@ -2005,6 +2104,9 @@ export async function runBot(deps) {
2005
2104
  const ownerDm = target.kind === "dm" && target.peerUid === config.ownerUid;
2006
2105
  if (isSetupBot && ownerDm && (await sendSetupExplanation(item, messageText, attachmentRefs.length)))
2007
2106
  return;
2107
+ const decisionTurn = ownerDm && messageText ? await runDecisionDm(item, messageText) : { done: false };
2108
+ if (decisionTurn.done)
2109
+ return;
2008
2110
  const attempt = (attempts.get(id) ?? 0) + 1;
2009
2111
  attempts.set(id, attempt);
2010
2112
  const reactionId = item.eventId ?? id;
@@ -2100,7 +2202,8 @@ export async function runBot(deps) {
2100
2202
  // answering every note re-posted that question (three times in a row on
2101
2203
  // 2026-10-12). Such a turn may stay quiet.
2102
2204
  const appSetupNote = isSetupBot && ownerDm && isAppSetupNote(messageText);
2103
- const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? (appSetupNote ? `${APP_SETUP_NOTE_TURN_NOTE}\n\n${ownerText}` : ownerText) :
2205
+ const ownerTurnText = decisionTurn.note ? `${decisionTurn.note}\n\n${ownerText}` : ownerText;
2206
+ const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? (appSetupNote ? `${APP_SETUP_NOTE_TURN_NOTE}\n\n${ownerTurnText}` : ownerTurnText) :
2104
2207
  `[HQ DM sender: ${target.peerUid}; owner: ${config.ownerUid}; sender is not the owner.]\n\n${text}`;
2105
2208
  const markerTarget = target.kind === "dm" ? { kind: "dm", peerUid: target.peerUid } : { kind: "room", channelId: target.channelId };
2106
2209
  const { sendProgress, sendToPerson, saved } = trackingSend(markerTarget, item.rootEventId);
@@ -1,7 +1,6 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
- import * as os from "node:os";
5
4
  import * as path from "node:path";
6
5
  import { planBotRuntimeSpawn } from "./windows.js";
7
6
  import { GrokAcpClient } from "./grok-acp.js";
@@ -9,6 +8,7 @@ import { createClaudeRuntime } from "./claude.js";
9
8
  import { createCodexRuntime } from "./codex.js";
10
9
  import { createGrokRuntime, grokRetryArgsAfterInvalidOutputFormat, probeGrokOutputFormat } from "./grok.js";
11
10
  import { stripControlTokens } from "./sanitize.js";
11
+ import { createTurnScratchDir } from "./scratch-dir.js";
12
12
  import { isRuntimeVersionTooOld, versionTooOldModel } from "../runtime-failure.js";
13
13
  import { parseJsonLine, RuntimeError, turnTimeoutMs, stderrExcerpt, MAX_MODEL_FALLBACK_RETRIES, } from "./types.js";
14
14
  export * from "./types.js";
@@ -345,10 +345,13 @@ export function forgetModelFallbacks() {
345
345
  */
346
346
  export async function runRuntimeTurn(runtime, input, deps = {}) {
347
347
  const spawnImpl = deps.spawnImpl ?? defaultSpawn;
348
- const scratchDir = deps.scratchDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "hq-bot-turn-"));
348
+ const turnEnv = input.env ?? deps.env ?? process.env;
349
+ const turnPlatform = deps.platform ?? input.platform ?? process.platform;
350
+ const scratch = deps.scratchDir ? { dir: deps.scratchDir } : createTurnScratchDir("hq-bot-turn-", { platform: turnPlatform });
351
+ const scratchDir = scratch.dir;
349
352
  const outputFile = path.join(scratchDir, "last-message.txt");
350
353
  const newSessionId = input.newSessionId ?? (deps.newSessionId ?? randomUUID)();
351
- const turnInput = { ...input, platform: deps.platform ?? input.platform ?? process.platform };
354
+ const turnInput = { ...input, platform: turnPlatform };
352
355
  const nowMs = deps.now ?? Date.now;
353
356
  const memoKey = fallbackKey(runtime, input.model);
354
357
  const memo = rememberedModelFallbacks.get(memoKey);
@@ -358,7 +361,8 @@ export async function runRuntimeTurn(runtime, input, deps = {}) {
358
361
  const args = runtime.buildArgs(remembered ? { ...turnInput, model: remembered.to } : turnInput, { outputFile, newSessionId });
359
362
  // Enforce at the model boundary too: old plists and direct `bot run` callers
360
363
  // may lack the guard. A bot prompt must never quit the user's desktop app.
361
- const baseEnv = { ...(input.env ?? deps.env ?? process.env), HQ_NO_ENSURE_HQ_DESKTOP: "1" };
364
+ // When TMPDIR was unwritable, hand the runtime CLI the working temp root too.
365
+ const baseEnv = { ...turnEnv, ...(scratch.fallbackRoot ? { TMPDIR: scratch.fallbackRoot } : {}), HQ_NO_ENSURE_HQ_DESKTOP: "1" };
362
366
  const env = { ...(runtime.runtimeEnv?.(baseEnv) ?? baseEnv), HQ_NO_ENSURE_HQ_DESKTOP: "1" };
363
367
  const binary = runtime.resolveBinary?.(env, input.cwd, turnInput.platform) ?? runtime.binary;
364
368
  const stdin = runtime.stdinInput?.(turnInput);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Per-turn scratch folder for the local bot runtime (feedback_f0131909).
3
+ *
4
+ * A bot started by launchd/systemd at login can inherit a TMPDIR it cannot
5
+ * write yet (an external volume that is not mounted or not yet readable by the
6
+ * agent), so `mkdtemp` failed with EPERM on every turn until `hq bot restart`
7
+ * re-launched it with a working TMPDIR. Try the configured folder first, then
8
+ * /tmp, then ~/.hq/tmp. When TMPDIR works nothing changes.
9
+ */
10
+ export interface TurnScratchDir {
11
+ /** The created scratch folder. */
12
+ dir: string;
13
+ /** The temp root used, when it is not the configured one. */
14
+ fallbackRoot?: string;
15
+ }
16
+ export interface TurnScratchDeps {
17
+ /** The configured temp root. Defaults to os.tmpdir(), exactly as before. */
18
+ tmpdir?: string;
19
+ platform?: NodeJS.Platform;
20
+ home?: string;
21
+ warn?: (line: string) => void;
22
+ }
23
+ export declare function createTurnScratchDir(prefix: string, deps?: TurnScratchDeps): TurnScratchDir;
24
+ //# sourceMappingURL=scratch-dir.d.ts.map
@@ -0,0 +1,41 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ /** Warn once per unwritable folder per bot process, not on every turn. */
5
+ const warned = new Set();
6
+ /** Errors that mean "this folder cannot be used"; anything else is rethrown. */
7
+ const UNUSABLE = new Set(["EPERM", "EACCES", "ENOENT", "ENOTDIR", "EROFS"]);
8
+ export function createTurnScratchDir(prefix, deps = {}) {
9
+ const platform = deps.platform ?? process.platform;
10
+ const home = deps.home ?? os.homedir();
11
+ const warn = deps.warn ?? ((line) => process.stderr.write(`${line}\n`));
12
+ const configured = deps.tmpdir ?? os.tmpdir();
13
+ const fallbacks = [];
14
+ if (platform !== "win32")
15
+ fallbacks.push("/tmp");
16
+ fallbacks.push(path.join(home, ".hq", "tmp"));
17
+ let firstError;
18
+ for (const root of [configured, ...fallbacks.filter((f) => f !== configured)]) {
19
+ try {
20
+ if (root !== configured)
21
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
22
+ const dir = fs.mkdtempSync(path.join(root, prefix));
23
+ if (root === configured) {
24
+ warned.delete(configured);
25
+ return { dir };
26
+ }
27
+ if (!warned.has(configured))
28
+ warn(`hq bot: temp folder ${configured} is not writable (${firstError?.code ?? "error"}); using ${root} instead. Fix TMPDIR or run hq bot restart.`);
29
+ warned.add(configured);
30
+ return { dir, fallbackRoot: root };
31
+ }
32
+ catch (err) {
33
+ const e = err;
34
+ if (!e.code || !UNUSABLE.has(e.code))
35
+ throw err;
36
+ firstError ??= e;
37
+ }
38
+ }
39
+ throw new Error(`hq bot cannot create a temporary folder: ${configured} is not writable (${firstError?.code ?? "error"}), and ${fallbacks.join(" and ")} did not work either. Fix TMPDIR or run hq bot restart.`);
40
+ }
41
+ //# sourceMappingURL=scratch-dir.js.map
@@ -0,0 +1,4 @@
1
+ export declare function callerIsMachinePrincipal(env?: NodeJS.ProcessEnv): boolean;
2
+ /** True when the token names an agent (agt_) or outpost (out_) identity. */
3
+ export declare function tokenIsMachinePrincipal(token: string): boolean;
4
+ //# sourceMappingURL=caller-principal.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Whether the current caller is a machine principal (a local bot, an
3
+ * agent-kit agent, a hosted agent or an outpost) or a person login.
4
+ *
5
+ * The answer comes from the credentials the request will actually use, never
6
+ * from a user flag:
7
+ * - CLI sends go through `ensureCognitoToken()`, which picks machine tokens
8
+ * exactly when `wantsMachineCognitoTokens()` is true (HQ_MACHINE_CREDS_FILE
9
+ * set and valid, or the default machine creds file on a box with no person
10
+ * session). This check reads local state only and makes no network call.
11
+ * - Callers that already hold a token (the agent-kit MCP tools) use
12
+ * `tokenIsMachinePrincipal`, which reads the token's identity claims.
13
+ */
14
+ import { wantsMachineCognitoTokens } from "../utils/machine-principal-state.js";
15
+ import { peekIdToken } from "../utils/id-token.js";
16
+ export function callerIsMachinePrincipal(env = process.env) {
17
+ try {
18
+ return wantsMachineCognitoTokens({}, env);
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ const MACHINE_ENTITY_TYPES = new Set(["agent", "outpost", "company-outpost"]);
25
+ /** True when the token names an agent (agt_) or outpost (out_) identity. */
26
+ export function tokenIsMachinePrincipal(token) {
27
+ let claims;
28
+ try {
29
+ claims = peekIdToken(token);
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ const type = claims["custom:entityType"];
35
+ if (typeof type === "string" && MACHINE_ENTITY_TYPES.has(type))
36
+ return true;
37
+ const uid = claims["custom:entityUid"];
38
+ return typeof uid === "string" && /^(agt|out)_/.test(uid);
39
+ }
40
+ //# sourceMappingURL=caller-principal.js.map
@@ -192,7 +192,10 @@ export function mapGrokBillingBody(body, options) {
192
192
  export async function collectGrok(options = {}) {
193
193
  const ledger = ledgerOptions(options);
194
194
  const root = ledger.root ?? capacityRoot(ledger.env, ledger.home);
195
- const scoped = { ...ledger, root };
195
+ // Forward the injected clock so ledger retention and now.json use the same
196
+ // instant as observedAt; otherwise retention runs on the wall clock and can
197
+ // delete readings this call just wrote.
198
+ const scoped = { ...ledger, root, now: options.now ?? ledger.now };
196
199
  if (readCollectorMark(scoped)?.enabled === false && !options.allowDisabled) {
197
200
  return { readings: [], failures: [], view: null, skipped: "disabled", accountId: null };
198
201
  }
@@ -474,7 +477,10 @@ function stampAccount(row, options, planType) {
474
477
  async function collectEnumeratedGrok(options) {
475
478
  const ledger = ledgerOptions(options);
476
479
  const root = ledger.root ?? capacityRoot(ledger.env, ledger.home);
477
- const scoped = { ...ledger, root };
480
+ // Forward the injected clock so ledger retention and now.json use the same
481
+ // instant as observedAt; otherwise retention runs on the wall clock and can
482
+ // delete readings this call just wrote.
483
+ const scoped = { ...ledger, root, now: options.now ?? ledger.now };
478
484
  const clock = options.now ?? ledger.now ?? (() => new Date());
479
485
  const cliVersion = options.cliVersion ?? readInstalledGrokVersion(options.readVersion);
480
486
  const groups = new Map();
@@ -1,6 +1,8 @@
1
1
  /** hq-flags keys used by CLI-wide routes. */
2
2
  export declare const CLI_COMMAND_HELP_CATALOG_FLAG = "cli.command-help-catalog";
3
3
  export declare const MULTI_COMPANY_SESSION_LOCK_FLAG = "hooks.multi-company-session-lock";
4
+ /** Kill switch (default on) for company-scoped `hq core rebuild-index` root indexes. */
5
+ export declare const CORE_COMPANY_INDEX_SCOPE_FLAG = "cli.rebuild-index-company-scope";
4
6
  /**
5
7
  * hq-flags key for HQ Storage (`hq storage` and Settings > Storage in the
6
8
  * desktop app). Shared with hq-desktop-app, so it carries no `cli.` prefix,
@@ -1,6 +1,8 @@
1
1
  /** hq-flags keys used by CLI-wide routes. */
2
2
  export const CLI_COMMAND_HELP_CATALOG_FLAG = "cli.command-help-catalog";
3
3
  export const MULTI_COMPANY_SESSION_LOCK_FLAG = "hooks.multi-company-session-lock";
4
+ /** Kill switch (default on) for company-scoped `hq core rebuild-index` root indexes. */
5
+ export const CORE_COMPANY_INDEX_SCOPE_FLAG = "cli.rebuild-index-company-scope";
4
6
  /**
5
7
  * hq-flags key for HQ Storage (`hq storage` and Settings > Storage in the
6
8
  * desktop app). Shared with hq-desktop-app, so it carries no `cli.` prefix,
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import type { MachineType } from "./machine-type.js";
13
13
  import type { SyncOwner } from "./config.js";
14
+ import type { SyncCoreCanaryPlan } from "./sync-core-canary.js";
14
15
  import type { RemoteControlPoolsConfig } from "./remote-control-pools.js";
15
16
  import { type SyncRunnerProcess } from "./sync-runner.js";
16
17
  import { type VaultSyncSwitches } from "../vault-sync-switches.js";
@@ -112,6 +113,9 @@ export interface PlanContext {
112
113
  vaultSync?: VaultSyncSwitches;
113
114
  /** Persisted/effective owner selected for this machine. */
114
115
  syncOwner: SyncOwner;
116
+ syncCoreCanary?: SyncCoreCanaryPlan;
117
+ legacySyncReadyForCore?: boolean;
118
+ syncCoreCanaryRunning?: boolean;
115
119
  /** A legacy desktop app or sync unit is still managing sync. */
116
120
  desktopAppManagesSync: boolean;
117
121
  /** Full process list, or null when the daemon could not read it. */
@@ -123,33 +123,13 @@ function planService(service, ctx) {
123
123
  const agentMachine = ctx.machineType === "agent-kit" || ctx.machineType === "agent-box";
124
124
  switch (service) {
125
125
  case "sync": {
126
- if (!ctx.syncRunnerPath)
127
- return [unavailable(service, "the hq-cloud sync runner is missing from this CLI install")];
128
- const unit = base(service, {
129
- command: {
130
- type: "node",
131
- script: ctx.syncRunnerPath,
132
- args: [
133
- "--companies",
134
- "--direction",
135
- "both",
136
- "--on-conflict",
137
- "keep",
138
- "--hq-root",
139
- ctx.hqRoot ?? "",
140
- "--watch",
141
- ...(ctx.instantSyncEnabled === false ? [] : ["--event-push"]),
142
- "--owner",
143
- SYNC_RUNNER_OWNER,
144
- ],
145
- },
146
- env: {
147
- ...ctx.legacySyncEnv,
148
- ...(ctx.vaultSync ? vaultSkipEnv(ctx.vaultSync) : {}),
149
- HQ_SYNC_PASS_REQUEST_DIR: ctx.syncPassRequestsDir,
150
- },
151
- heartbeatTimeoutMs: ctx.syncHeartbeatTimeoutMs,
152
- });
126
+ const vaultSync = ctx.syncCoreCanaryRunning
127
+ ? {
128
+ allOff: ctx.vaultSync?.allOff === true,
129
+ personalOff: true,
130
+ offCompanies: ctx.vaultSync?.offCompanies ?? new Set(),
131
+ }
132
+ : ctx.vaultSync;
153
133
  let ownerBlock;
154
134
  if (ctx.machineType === "desktop" && ctx.syncOwner === "desktop-app" && ctx.desktopAppManagesSync) {
155
135
  ownerBlock = "the desktop app is still managing sync (run `hq daemon sync take-ownership` after it stops its runner)";
@@ -163,7 +143,7 @@ function planService(service, ctx) {
163
143
  ? "could not check for an existing sync runner; waiting to avoid starting a duplicate"
164
144
  : syncRunnerUnleasedBlockReason(ctx.hqRoot, ctx.syncRunnerProcesses, ctx.syncRunnerOwnerPid, ctx.platform, ctx.daemonSyncRunnerPids);
165
145
  }
166
- unit.blocked = firstBlock([
146
+ const commonBlock = firstBlock([
167
147
  [ctx.syncPaused, "sync is paused (run `hq daemon sync resume`)"],
168
148
  [
169
149
  ctx.vaultSync?.allOff === true,
@@ -173,7 +153,61 @@ function planService(service, ctx) {
173
153
  [!ctx.signedIn, NOT_SIGNED_IN],
174
154
  [!!ownerBlock, ownerBlock ?? ""],
175
155
  ]);
176
- return [unit];
156
+ const unit = ctx.syncRunnerPath
157
+ ? base(service, {
158
+ command: {
159
+ type: "node",
160
+ script: ctx.syncRunnerPath,
161
+ args: [
162
+ "--companies",
163
+ "--direction",
164
+ "both",
165
+ "--on-conflict",
166
+ "keep",
167
+ "--hq-root",
168
+ ctx.hqRoot ?? "",
169
+ "--watch",
170
+ ...(ctx.instantSyncEnabled === false ? [] : ["--event-push"]),
171
+ "--owner",
172
+ SYNC_RUNNER_OWNER,
173
+ ],
174
+ },
175
+ env: {
176
+ ...ctx.legacySyncEnv,
177
+ ...(vaultSync ? vaultSkipEnv(vaultSync) : {}),
178
+ HQ_SYNC_PASS_REQUEST_DIR: ctx.syncPassRequestsDir,
179
+ },
180
+ heartbeatTimeoutMs: ctx.syncHeartbeatTimeoutMs,
181
+ blocked: commonBlock,
182
+ })
183
+ : unavailable(service, "the hq-cloud sync runner is missing from this CLI install");
184
+ if (!ctx.syncCoreCanary)
185
+ return [unit];
186
+ const core = base(service, {
187
+ id: "sync-core-canary",
188
+ description: ctx.syncCoreCanary.fullVault
189
+ ? "keeps the personal vault in sync with sync-core"
190
+ : "keeps one personal-vault canary in sync with sync-core",
191
+ command: ctx.syncCoreCanary.binaryPath
192
+ ? { type: "exec", file: ctx.syncCoreCanary.binaryPath, args: ["start", "--loop"] }
193
+ : null,
194
+ env: {
195
+ HQ_SYNC_ENGINE: "rust-test",
196
+ HQ_SYNC_MODE: "full",
197
+ HQ_SYNC_PROTOCOL: "1",
198
+ ...(ctx.syncCoreCanary.vaultUid ? { HQ_SYNC_CANARY_VAULT: ctx.syncCoreCanary.vaultUid } : {}),
199
+ ...(ctx.syncCoreCanary.canaryPath ? { HQ_SYNC_CANARY_PATH: ctx.syncCoreCanary.canaryPath } : {}),
200
+ ...(ctx.syncCoreCanary.fullVault ? { HQ_SYNC_FULL_VAULT: "1" } : {}),
201
+ },
202
+ heartbeatTimeoutMs: 180_000,
203
+ blocked: firstBlock([
204
+ [!!commonBlock, commonBlock?.reason ?? ""],
205
+ [!!ctx.syncCoreCanary.blockedReason, ctx.syncCoreCanary.blockedReason ?? ""],
206
+ [ctx.vaultSync?.personalOff !== true, "turn the personal vault off in the current sync before starting the sync-core canary"],
207
+ [ctx.legacySyncReadyForCore !== true, "waiting for legacy sync to run its company-only plan"],
208
+ ]),
209
+ });
210
+ return [unit, core];
177
211
  }
178
212
  case "mesh": {
179
213
  const args = ["mesh", "daemon", "run", ...(ctx.hqRoot ? ["--hq-root", ctx.hqRoot] : [])];
@@ -18,12 +18,19 @@ export interface DaemonConfig {
18
18
  instantSyncEnabled?: boolean;
19
19
  /** Explicit desktop handoff; absent values preserve the legacy per-machine owner. */
20
20
  syncOwner?: SyncOwner;
21
+ syncCoreCanary?: SyncCoreCanaryConfig;
22
+ syncCoreCanaryError?: string;
23
+ syncCoreCanaryInvalid?: unknown;
21
24
  /** Services turned on here beyond the machine type's defaults. */
22
25
  enable: string[];
23
26
  /** Default services turned off here. */
24
27
  disable: string[];
25
28
  }
26
29
  export type SyncOwner = "daemon" | "desktop-app";
30
+ export interface SyncCoreCanaryConfig {
31
+ binaryPath: string;
32
+ vaultUid: string;
33
+ }
27
34
  export declare function effectiveSyncOwner(config: Pick<DaemonConfig, "syncOwner">, machineType: MachineType): SyncOwner;
28
35
  export declare function readDaemonConfig(file: string): DaemonConfig;
29
36
  export declare function writeDaemonConfig(file: string, config: DaemonConfig): void;
@@ -19,6 +19,27 @@ function emptyDaemonConfig() {
19
19
  function stringList(value) {
20
20
  return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
21
21
  }
22
+ function syncCoreCanary(value, file) {
23
+ if (value === undefined)
24
+ return {};
25
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
26
+ return { error: `hq daemon config at ${file} has invalid syncCoreCanary`, invalid: value };
27
+ }
28
+ const candidate = value;
29
+ if (typeof candidate.binaryPath !== "string" || !path.isAbsolute(candidate.binaryPath)) {
30
+ return {
31
+ error: `hq daemon config at ${file} has invalid syncCoreCanary: binaryPath must be absolute`,
32
+ invalid: value,
33
+ };
34
+ }
35
+ if (typeof candidate.vaultUid !== "string" || !/^prs_[A-Za-z0-9][A-Za-z0-9_-]*$/.test(candidate.vaultUid)) {
36
+ return {
37
+ error: `hq daemon config at ${file} has invalid syncCoreCanary: vaultUid must name one personal vault`,
38
+ invalid: value,
39
+ };
40
+ }
41
+ return { config: { binaryPath: candidate.binaryPath, vaultUid: candidate.vaultUid } };
42
+ }
22
43
  export function readDaemonConfig(file) {
23
44
  let text;
24
45
  try {
@@ -51,6 +72,13 @@ export function readDaemonConfig(file) {
51
72
  config.machineType = obj.machineType;
52
73
  if (obj.syncOwner === "daemon" || obj.syncOwner === "desktop-app")
53
74
  config.syncOwner = obj.syncOwner;
75
+ const parsedCanary = syncCoreCanary(obj.syncCoreCanary, file);
76
+ if (parsedCanary.config)
77
+ config.syncCoreCanary = parsedCanary.config;
78
+ if (parsedCanary.error)
79
+ config.syncCoreCanaryError = parsedCanary.error;
80
+ if (parsedCanary.error)
81
+ config.syncCoreCanaryInvalid = parsedCanary.invalid;
54
82
  if (typeof obj.hqRoot === "string" && obj.hqRoot.trim())
55
83
  config.hqRoot = obj.hqRoot;
56
84
  if (typeof obj.hqCloudVersion === "string" && validSemver(obj.hqCloudVersion) === obj.hqCloudVersion) {
@@ -73,6 +101,10 @@ export function writeDaemonConfig(file, config) {
73
101
  ordered.instantSyncEnabled = config.instantSyncEnabled;
74
102
  if (config.syncOwner)
75
103
  ordered.syncOwner = config.syncOwner;
104
+ if (config.syncCoreCanary)
105
+ ordered.syncCoreCanary = config.syncCoreCanary;
106
+ else if (config.syncCoreCanaryError)
107
+ ordered.syncCoreCanary = config.syncCoreCanaryInvalid;
76
108
  ordered.enable = config.enable;
77
109
  ordered.disable = config.disable;
78
110
  fs.writeFileSync(tmp, `${JSON.stringify(ordered, null, 2)}\n`, { mode: 0o600 });