@indigoai-us/hq-cli 5.345.170 → 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 (111) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
  3. package/dist/command-catalog.generated.d.ts +132 -5
  4. package/dist/command-catalog.generated.js +164 -6
  5. package/dist/commands/access.d.ts +32 -1
  6. package/dist/commands/access.js +112 -3
  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 +137 -14
  12. package/dist/commands/cloud.d.ts +58 -1
  13. package/dist/commands/cloud.js +219 -5
  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/integrations-api.d.ts +4 -0
  23. package/dist/commands/integrations-api.js +18 -2
  24. package/dist/commands/integrations-connect.d.ts +10 -3
  25. package/dist/commands/integrations-connect.js +70 -6
  26. package/dist/commands/integrations-core.d.ts +23 -0
  27. package/dist/commands/integrations-core.js +100 -0
  28. package/dist/commands/integrations-manage.js +8 -1
  29. package/dist/commands/integrations.js +49 -5
  30. package/dist/commands/lanes-loop.d.ts +20 -0
  31. package/dist/commands/lanes-loop.js +69 -11
  32. package/dist/commands/lanes.js +9 -1
  33. package/dist/commands/rescue.d.ts +9 -0
  34. package/dist/commands/rescue.js +31 -0
  35. package/dist/commands/run.js +115 -40
  36. package/dist/commands/scan.d.ts +5 -1
  37. package/dist/commands/scan.js +16 -4
  38. package/dist/commands/secrets.js +50 -9
  39. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2367 -2109
  40. package/dist/hq-cloud-sync-runner/package.json +1 -1
  41. package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
  42. package/dist/lib/agent-kit/inbox-state.js +41 -20
  43. package/dist/lib/agent-kit/kit-config.d.ts +2 -0
  44. package/dist/lib/agent-kit/kit-config.js +6 -0
  45. package/dist/lib/agent-kit/mcp/tools.js +12 -5
  46. package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
  47. package/dist/lib/agent-kit/run/heartbeat.js +6 -3
  48. package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
  49. package/dist/lib/agent-kit/run/inbox.js +42 -10
  50. package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
  51. package/dist/lib/agent-kit/run/request-timeout.js +51 -0
  52. package/dist/lib/bot/api.d.ts +1 -1
  53. package/dist/lib/bot/config.d.ts +10 -0
  54. package/dist/lib/bot/config.js +17 -1
  55. package/dist/lib/bot/fleet-local.d.ts +127 -0
  56. package/dist/lib/bot/fleet-local.js +638 -0
  57. package/dist/lib/bot/index.d.ts +1 -0
  58. package/dist/lib/bot/index.js +1 -0
  59. package/dist/lib/bot/local-config.d.ts +3 -2
  60. package/dist/lib/bot/local-config.js +5 -2
  61. package/dist/lib/bot/promote.d.ts +10 -0
  62. package/dist/lib/bot/promote.js +3 -2
  63. package/dist/lib/bot/runtime/index.js +8 -4
  64. package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
  65. package/dist/lib/bot/runtime/scratch-dir.js +41 -0
  66. package/dist/lib/caller-principal.d.ts +4 -0
  67. package/dist/lib/caller-principal.js +40 -0
  68. package/dist/lib/capacity/collectors/grok.js +8 -2
  69. package/dist/lib/cli-flag-keys.d.ts +2 -0
  70. package/dist/lib/cli-flag-keys.js +2 -0
  71. package/dist/lib/daemon/catalog.js +4 -1
  72. package/dist/lib/daemon/sync-core-canary.d.ts +3 -0
  73. package/dist/lib/daemon/sync-core-canary.js +8 -3
  74. package/dist/lib/doctor/checks/release-links.d.ts +62 -1
  75. package/dist/lib/doctor/checks/release-links.js +188 -2
  76. package/dist/lib/doctor/fix/apply.js +54 -0
  77. package/dist/lib/doctor/fix/remediation.d.ts +8 -1
  78. package/dist/lib/doctor/fix/remediation.js +22 -0
  79. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  80. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  81. package/dist/lib/index-render/index.d.ts +9 -0
  82. package/dist/lib/index-render/index.js +65 -5
  83. package/dist/lib/index-render/session-scope.d.ts +69 -0
  84. package/dist/lib/index-render/session-scope.js +279 -0
  85. package/dist/lib/lanes/admission.js +7 -20
  86. package/dist/lib/lanes/reconcile.js +13 -15
  87. package/dist/lib/lanes/stale-lock.d.ts +50 -0
  88. package/dist/lib/lanes/stale-lock.js +105 -0
  89. package/dist/lib/lanes/store.js +11 -76
  90. package/dist/lib/notify-recipient.d.ts +10 -4
  91. package/dist/lib/notify-recipient.js +16 -4
  92. package/dist/lib/secret-input-link.d.ts +42 -0
  93. package/dist/lib/secret-input-link.js +93 -0
  94. package/dist/lib/secret-scan/range.d.ts +57 -0
  95. package/dist/lib/secret-scan/range.js +266 -0
  96. package/dist/lib/secret-scan/repo-config.d.ts +18 -3
  97. package/dist/lib/secret-scan/repo-config.js +135 -2
  98. package/dist/run/script-bound-schema.d.ts +27 -0
  99. package/dist/run/script-bound-schema.js +97 -0
  100. package/dist/secrets-env-action.d.ts +2 -0
  101. package/dist/secrets-env-action.js +7 -2
  102. package/dist/utils/cognito-session.d.ts +3 -30
  103. package/dist/utils/cognito-session.js +2 -100
  104. package/dist/utils/machine-principal-state.d.ts +38 -0
  105. package/dist/utils/machine-principal-state.js +110 -0
  106. package/dist/utils/safe-link.js +9 -0
  107. package/dist/utils/script-bound-exec.d.ts +158 -0
  108. package/dist/utils/script-bound-exec.js +568 -0
  109. package/dist/utils/sync-watchdog.d.ts +92 -0
  110. package/dist/utils/sync-watchdog.js +250 -0
  111. package/package.json +8 -8
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.101","bundleSha256":"353f47dca127c7f6c1e00de5c9cdce809c5c32d08b2deb9755020b8e8b822871","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.102","bundleSha256":"54baedb34f8744f3dbbfa970785c6de703a33145081969bed5b266a2fc4d4d9e","formatVersion":1}
@@ -84,6 +84,23 @@ export interface MergedInbox {
84
84
  */
85
85
  source: "server" | "server-partial" | "local-mirror";
86
86
  }
87
+ /** Notes for a server inbox body that cannot certify a complete read. */
88
+ export declare const AGENT_INBOX_INVALID_RESPONSE_NOTE = "server inbox returned an invalid response; showing the local mirror only, which may be stale";
89
+ export declare const AGENT_INBOX_INVALID_ITEMS_NOTE = "server inbox contained invalid messages; showing valid server messages and the local mirror, which may be incomplete";
90
+ /**
91
+ * Classify a decoded 200 body from GET /v1/agents/{uid}/inbox. Pass
92
+ * `undefined` for a body that failed to decode. `invalid` means the body had
93
+ * no messages array, so nothing from it can be trusted; `partial` means some
94
+ * items were dropped (non-objects or missing id/messageId). Valid items keep
95
+ * their raw fields with `id` set; callers redact before output. Never puts
96
+ * response bytes in a diagnostic. Pure.
97
+ */
98
+ export declare function classifyAgentInboxBody(body: unknown): {
99
+ status: "ok" | "partial" | "invalid";
100
+ items: Array<Record<string, unknown> & {
101
+ id: string;
102
+ }>;
103
+ };
87
104
  export declare function mergeAgentInbox(paths: Pick<AgentKitPaths, "inboxDir">, fetchServerInbox: () => Promise<{
88
105
  status: number;
89
106
  body: unknown;
@@ -138,6 +138,38 @@ export function summarizeInboxEntry(e, done) {
138
138
  done,
139
139
  };
140
140
  }
141
+ /** Notes for a server inbox body that cannot certify a complete read. */
142
+ export const AGENT_INBOX_INVALID_RESPONSE_NOTE = "server inbox returned an invalid response; showing the local mirror only, which may be stale";
143
+ export const AGENT_INBOX_INVALID_ITEMS_NOTE = "server inbox contained invalid messages; showing valid server messages and the local mirror, which may be incomplete";
144
+ /**
145
+ * Classify a decoded 200 body from GET /v1/agents/{uid}/inbox. Pass
146
+ * `undefined` for a body that failed to decode. `invalid` means the body had
147
+ * no messages array, so nothing from it can be trusted; `partial` means some
148
+ * items were dropped (non-objects or missing id/messageId). Valid items keep
149
+ * their raw fields with `id` set; callers redact before output. Never puts
150
+ * response bytes in a diagnostic. Pure.
151
+ */
152
+ export function classifyAgentInboxBody(body) {
153
+ const messages = body?.messages;
154
+ if (!Array.isArray(messages))
155
+ return { status: "invalid", items: [] };
156
+ const items = [];
157
+ let invalidItems = 0;
158
+ for (const m of messages) {
159
+ if (!m || typeof m !== "object" || Array.isArray(m)) {
160
+ invalidItems += 1;
161
+ continue;
162
+ }
163
+ const raw = m;
164
+ const id = typeof raw.id === "string" && raw.id ? raw.id : typeof raw.messageId === "string" && raw.messageId ? raw.messageId : null;
165
+ if (!id) {
166
+ invalidItems += 1;
167
+ continue;
168
+ }
169
+ items.push({ ...raw, id });
170
+ }
171
+ return { status: invalidItems > 0 ? "partial" : "ok", items };
172
+ }
141
173
  export async function mergeAgentInbox(paths, fetchServerInbox) {
142
174
  const byId = new Map();
143
175
  for (const e of readMirroredInbox(paths))
@@ -147,32 +179,21 @@ export async function mergeAgentInbox(paths, fetchServerInbox) {
147
179
  try {
148
180
  const res = await fetchServerInbox();
149
181
  if (res.status === 200) {
150
- const messages = res.body?.messages;
151
182
  // A successful HTTP status alone cannot certify an inbox read. An error
152
183
  // envelope or an undecodable body must remain visibly unverified; never
153
184
  // include response bytes or parser errors in this diagnostic.
154
- if (!Array.isArray(messages)) {
155
- source = "local-mirror";
156
- note = "server inbox returned an invalid response; showing the local mirror only, which may be stale";
185
+ const classified = classifyAgentInboxBody(res.body);
186
+ for (const item of classified.items) {
187
+ if (!byId.has(item.id))
188
+ byId.set(item.id, redactAgentInboxItem(item));
157
189
  }
158
- let invalidItems = 0;
159
- for (const m of Array.isArray(messages) ? messages : []) {
160
- if (!m || typeof m !== "object") {
161
- invalidItems += 1;
162
- continue;
163
- }
164
- const raw = m;
165
- const id = typeof raw.id === "string" ? raw.id : typeof raw.messageId === "string" ? raw.messageId : null;
166
- if (!id) {
167
- invalidItems += 1;
168
- continue;
169
- }
170
- if (!byId.has(id))
171
- byId.set(id, redactAgentInboxItem({ ...raw, id }));
190
+ if (classified.status === "invalid") {
191
+ source = "local-mirror";
192
+ note = AGENT_INBOX_INVALID_RESPONSE_NOTE;
172
193
  }
173
- if (invalidItems > 0) {
194
+ else if (classified.status === "partial") {
174
195
  source = "server-partial";
175
- note = "server inbox contained invalid messages; showing valid server messages and the local mirror, which may be incomplete";
196
+ note = AGENT_INBOX_INVALID_ITEMS_NOTE;
176
197
  }
177
198
  }
178
199
  else {
@@ -12,6 +12,8 @@ export interface KitConfig {
12
12
  inboxPollMs: number;
13
13
  heartbeatIntervalMs: number;
14
14
  meshRefreshMs: number;
15
+ /** Per-request timeout for the inbox and heartbeat network calls. */
16
+ requestTimeoutMs: number;
15
17
  /** Ack mirrored inbox items on the server after writing them locally. */
16
18
  inboxAck: boolean;
17
19
  /**
@@ -8,10 +8,12 @@ import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { KIT_COMPONENTS } from "./paths.js";
10
10
  import { writePrivateFile } from "./private-file.js";
11
+ import { KIT_REQUEST_TIMEOUT_MIN_MS, KIT_REQUEST_TIMEOUT_MS } from "./run/request-timeout.js";
11
12
  const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
12
13
  const DEFAULT_INBOX_POLL_MS = 15_000;
13
14
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
14
15
  const DEFAULT_MESH_REFRESH_MS = 5 * 60_000;
16
+ const DEFAULT_REQUEST_TIMEOUT_MS = KIT_REQUEST_TIMEOUT_MS;
15
17
  /**
16
18
  * Kit components whose health this install actually reports. `sync` drops out
17
19
  * when the vault is not mirrored to disk — there is no loop to be healthy or
@@ -30,6 +32,7 @@ export function defaultKitConfig(paths, cliVersion, now = () => new Date()) {
30
32
  inboxPollMs: DEFAULT_INBOX_POLL_MS,
31
33
  heartbeatIntervalMs: DEFAULT_HEARTBEAT_INTERVAL_MS,
32
34
  meshRefreshMs: DEFAULT_MESH_REFRESH_MS,
35
+ requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
33
36
  inboxAck: false,
34
37
  syncFiles: false,
35
38
  installedAt: now().toISOString(),
@@ -56,6 +59,9 @@ export function readKitConfig(paths, cliVersion) {
56
59
  inboxPollMs: num("inboxPollMs"),
57
60
  heartbeatIntervalMs: num("heartbeatIntervalMs"),
58
61
  meshRefreshMs: num("meshRefreshMs"),
62
+ // 0, negative or missing keeps the default; a tiny positive value would
63
+ // time out every poll, so it is raised to the minimum.
64
+ requestTimeoutMs: Math.max(KIT_REQUEST_TIMEOUT_MIN_MS, num("requestTimeoutMs")),
59
65
  inboxAck: raw.inboxAck === true,
60
66
  syncFiles: raw.syncFiles === true,
61
67
  installedAt: typeof raw.installedAt === "string" ? raw.installedAt : base.installedAt,
@@ -17,7 +17,8 @@
17
17
  */
18
18
  import { CLI_VERSION } from "../../../cli-version.js";
19
19
  import { peekIdToken } from "../../../utils/id-token.js";
20
- import { defaultAudienceFor, isAgentUid, isRecipientUid } from "../../../lib/notify-recipient.js";
20
+ import { PERSON_AGENT_AUDIENCE_MESSAGE, callerMayUseAudience, defaultAudienceFor, isAgentUid, isRecipientUid } from "../../../lib/notify-recipient.js";
21
+ import { tokenIsMachinePrincipal } from "../../../lib/caller-principal.js";
21
22
  import { redactAgentInboxItem } from "../inbox-redact.js";
22
23
  import { markInboxDone, mergeAgentInbox, readDoneIds, summarizeInboxEntry } from "../inbox-state.js";
23
24
  import { AttachmentCompanyUnresolvedError, fetchChatAttachment } from "./attachments.js";
@@ -232,13 +233,20 @@ export function buildAgentMcpTools(clients) {
232
233
  if (audience !== undefined && !["human", "agent", "both"].includes(audience)) {
233
234
  throw new McpToolInputError('"audience" must be human, agent, or both');
234
235
  }
236
+ // The token decides who is sending. Normally the agent's machine
237
+ // identity; if it ever resolves to a person login, people only send
238
+ // human messages, so refuse agent/both before any send.
239
+ const token = await clients.getToken();
240
+ const machineCaller = tokenIsMachinePrincipal(token);
241
+ if (!callerMayUseAudience(audience, machineCaller)) {
242
+ return errorResult(PERSON_AGENT_AUDIENCE_MESSAGE);
243
+ }
235
244
  if (/^chn_[A-Za-z0-9]+$/.test(to)) {
236
245
  // A channel or group DM: post into it, so a reply lands where the
237
246
  // message arrived instead of in a private DM to the sender.
238
- const token = await clients.getToken();
239
247
  const res = await clients.apiJson(token, `/v1/notify/channels/${encodeURIComponent(to)}/messages`, {
240
248
  method: "POST",
241
- body: { body: message, audience: audience ?? defaultAudienceFor("channel") },
249
+ body: { body: message, audience: audience ?? defaultAudienceFor("channel", machineCaller) },
242
250
  });
243
251
  if (res.status < 200 || res.status >= 300) {
244
252
  return errorResult(`Channel send failed (${res.status}): ${describeError(res.body)}`);
@@ -252,8 +260,7 @@ export function buildAgentMcpTools(clients) {
252
260
  : (() => {
253
261
  throw new McpToolInputError('"to" must be an email, prs_… or agt_… uid, or a chn_… channel id');
254
262
  })();
255
- body.audience = audience ?? defaultAudienceFor(isAgentUid(to) ? "agent" : "person");
256
- const token = await clients.getToken();
263
+ body.audience = audience ?? defaultAudienceFor(isAgentUid(to) ? "agent" : "person", machineCaller);
257
264
  const res = await clients.apiJson(token, "/v1/notify/dm", { method: "POST", body });
258
265
  if (res.status < 200 || res.status >= 300) {
259
266
  return errorResult(`DM send failed (${res.status}): ${describeError(res.body)}`);
@@ -54,11 +54,14 @@ export interface HeartbeatDeps {
54
54
  agentUid: string;
55
55
  apiBaseUrl: string;
56
56
  intervalMs: number;
57
- getToken: () => Promise<string>;
57
+ /** Mints or refreshes the machine token; the signal cancels a stuck refresh. */
58
+ getToken: (signal?: AbortSignal) => Promise<string>;
58
59
  log: KitLogger;
59
60
  /** Components this kit runs; defaults to all of them. */
60
61
  components?: readonly KitComponent[];
61
- post?: (token: string, body: HeartbeatBody, identity?: HeartbeatIdentity) => Promise<number | HeartbeatPostResult>;
62
+ post?: (token: string, body: HeartbeatBody, identity?: HeartbeatIdentity, signal?: AbortSignal) => Promise<number | HeartbeatPostResult>;
63
+ /** Per-request timeout for the token mint and the heartbeat POST (kit.json `requestTimeoutMs`). */
64
+ requestTimeoutMs?: number;
62
65
  /** Re-read the configured identity and endpoint once when a freshly minted token differs. */
63
66
  getConfiguredAgentIdentity?: () => HeartbeatIdentity;
64
67
  now?: () => Date;
@@ -72,7 +75,7 @@ export interface HeartbeatPostResult {
72
75
  status: number;
73
76
  bodyText?: string;
74
77
  }
75
- export declare function defaultHeartbeatPost(identity: HeartbeatIdentity): (token: string, body: HeartbeatBody, requestIdentity?: HeartbeatIdentity) => Promise<HeartbeatPostResult>;
78
+ export declare function defaultHeartbeatPost(identity: HeartbeatIdentity): (token: string, body: HeartbeatBody, requestIdentity?: HeartbeatIdentity, signal?: AbortSignal) => Promise<HeartbeatPostResult>;
76
79
  export declare function beatOnce(deps: HeartbeatDeps): Promise<boolean>;
77
80
  export declare function runHeartbeatLoop(deps: HeartbeatDeps): Promise<void>;
78
81
  //# sourceMappingURL=heartbeat.d.ts.map
@@ -17,6 +17,7 @@ import { vaultApiFetch } from "../../../utils/vault-api.js";
17
17
  import { readComponentStatus } from "../creds.js";
18
18
  import { KIT_COMPONENTS } from "../paths.js";
19
19
  import { writePrivateFile } from "./../private-file.js";
20
+ import { KIT_REQUEST_TIMEOUT_MS, withRequestTimeout } from "./request-timeout.js";
20
21
  /** A component stamp older than this reads as error. */
21
22
  export const COMPONENT_STALE_AFTER_MS = {
22
23
  sync: 3 * 60 * 60_000, // sync passes every 5 min; three hours of silence is real trouble
@@ -51,13 +52,14 @@ export function readLastHeartbeat(paths) {
51
52
  }
52
53
  }
53
54
  export function defaultHeartbeatPost(identity) {
54
- return async (token, body, requestIdentity = identity) => {
55
+ return async (token, body, requestIdentity = identity, signal) => {
55
56
  const res = await vaultApiFetch({
56
57
  token,
57
58
  baseUrl: requestIdentity.apiBaseUrl,
58
59
  path: `/v1/agents/${encodeURIComponent(requestIdentity.agentUid)}/heartbeat`,
59
60
  method: "POST",
60
61
  body: body,
62
+ ...(signal ? { signal } : {}),
61
63
  });
62
64
  return { status: res.status, bodyText: await res.text().catch(() => "") };
63
65
  };
@@ -85,7 +87,8 @@ async function beatOnceResult(deps) {
85
87
  const now = deps.now ?? (() => new Date());
86
88
  const body = buildHeartbeatBody(deps.paths, now, undefined, deps.components ?? KIT_COMPONENTS);
87
89
  try {
88
- const token = await deps.getToken();
90
+ const timeoutMs = deps.requestTimeoutMs ?? KIT_REQUEST_TIMEOUT_MS;
91
+ const token = await withRequestTimeout(timeoutMs, undefined, (signal) => deps.getToken(signal));
89
92
  const tokenUid = tokenAgentUid(token);
90
93
  let identity = { agentUid: deps.agentUid, apiBaseUrl: deps.apiBaseUrl };
91
94
  if (tokenUid !== undefined && tokenUid !== identity.agentUid) {
@@ -103,7 +106,7 @@ async function beatOnceResult(deps) {
103
106
  }
104
107
  }
105
108
  const post = deps.post ?? defaultHeartbeatPost(identity);
106
- const response = await post(token, body, identity);
109
+ const response = await withRequestTimeout(timeoutMs, undefined, (signal) => post(token, body, identity, signal));
107
110
  const result = typeof response === "number" ? { status: response } : response;
108
111
  const status = result.status;
109
112
  const ok = status >= 200 && status < 300;
@@ -22,6 +22,8 @@ export declare const SEEN_IDS_NAME = "seen-ids.json";
22
22
  export declare const INBOX_JSONL_NAME = "inbox.jsonl";
23
23
  export declare const MAX_SEEN_IDS = 5000;
24
24
  export declare const INBOX_POLL_BACKOFF_MAX_MS = 30000;
25
+ /** A poll slower than this logs one line with its duration and outcome. */
26
+ export declare const INBOX_SLOW_POLL_MS = 10000;
25
27
  export interface InboxItem {
26
28
  id: string;
27
29
  raw: Record<string, unknown>;
@@ -37,14 +39,17 @@ export interface InboxDeps {
37
39
  apiBaseUrl: string;
38
40
  pollMs: number;
39
41
  ack: boolean;
40
- getToken: () => Promise<string>;
42
+ /** Mints or refreshes the machine token; the signal cancels a stuck refresh. */
43
+ getToken: (signal?: AbortSignal) => Promise<string>;
41
44
  log: KitLogger;
42
45
  fetchInbox?: (token: string, signal?: AbortSignal) => Promise<InboxFetchResult>;
43
- ackItem?: (token: string, id: string) => Promise<number>;
46
+ ackItem?: (token: string, id: string, signal?: AbortSignal) => Promise<number>;
44
47
  warmCache?: (token: string, agentUid: string) => Promise<unknown>;
45
48
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
46
49
  readInboxBackoffEnabled?: () => Promise<boolean>;
47
50
  signal?: AbortSignal;
51
+ /** Per-request timeout for the token mint, inbox GET and acks (kit.json `requestTimeoutMs`). */
52
+ requestTimeoutMs?: number;
48
53
  companySlug?: string;
49
54
  maxPolls?: number;
50
55
  /** Override wake config lookup (tests). `null` disables the wake. */
@@ -71,7 +76,7 @@ export declare function agentApiUrl(apiBaseUrl: string, route: string): string;
71
76
  export declare function agentInboxUrl(apiBaseUrl: string, agentUid: string): string;
72
77
  export declare function truncateBody(text: string, max?: number): string;
73
78
  export declare function defaultFetchInbox(agentUid: string, apiBaseUrl: string): (token: string, signal?: AbortSignal) => Promise<InboxFetchResult>;
74
- export declare function defaultAckItem(agentUid: string, apiBaseUrl: string): (token: string, id: string) => Promise<number>;
79
+ export declare function defaultAckItem(agentUid: string, apiBaseUrl: string): (token: string, id: string, signal?: AbortSignal) => Promise<number>;
75
80
  export declare function pollInboxOnce(deps: InboxDeps): Promise<{
76
81
  ok: boolean;
77
82
  mirrored: number;
@@ -25,10 +25,13 @@ import { redactAgentInboxItem, scrubInboxMirror, withInboxMirrorLock } from "../
25
25
  import { mkdirPrivate, writePrivateFile } from "./../private-file.js";
26
26
  import { createAgentKitInboxBackoffFlagReader } from "../inbox-poll-backoff-flag.js";
27
27
  import { buildWakeNotice, currentWakeState, fireWake, readWakeConfig, shouldWake, } from "../wake.js";
28
+ import { KIT_REQUEST_TIMEOUT_MS, KitRequestTimeoutError, withRequestTimeout } from "./request-timeout.js";
28
29
  export const SEEN_IDS_NAME = "seen-ids.json";
29
30
  export const INBOX_JSONL_NAME = "inbox.jsonl";
30
31
  export const MAX_SEEN_IDS = 5000;
31
32
  export const INBOX_POLL_BACKOFF_MAX_MS = 30_000;
33
+ /** A poll slower than this logs one line with its duration and outcome. */
34
+ export const INBOX_SLOW_POLL_MS = 10_000;
32
35
  /** Pull `{id}` out of each server item; items without a string id are dropped. */
33
36
  export function extractInboxItems(body) {
34
37
  const rec = body && typeof body === "object" ? body : {};
@@ -141,12 +144,13 @@ export function defaultFetchInbox(agentUid, apiBaseUrl) {
141
144
  };
142
145
  }
143
146
  export function defaultAckItem(agentUid, apiBaseUrl) {
144
- return async (token, id) => {
147
+ return async (token, id, signal) => {
145
148
  const res = await vaultApiFetch({
146
149
  token,
147
150
  baseUrl: apiBaseUrl,
148
151
  path: `/v1/agents/${encodeURIComponent(agentUid)}/inbox/${encodeURIComponent(id)}/ack`,
149
152
  method: "POST",
153
+ ...(signal ? { signal } : {}),
150
154
  });
151
155
  return res.status;
152
156
  };
@@ -154,6 +158,27 @@ export function defaultAckItem(agentUid, apiBaseUrl) {
154
158
  export async function pollInboxOnce(deps) {
155
159
  if (deps.signal?.aborted)
156
160
  return { ok: false, mirrored: 0 };
161
+ const startedAt = Date.now();
162
+ const result = await pollInboxOnceTimed(deps);
163
+ const elapsed = Date.now() - startedAt;
164
+ // One line per slow or timed-out poll: duration and outcome only, never a
165
+ // token or message content. A timeout already logged its own line.
166
+ if (result.outcome !== "timeout" && result.outcome !== "aborted" && elapsed >= INBOX_SLOW_POLL_MS) {
167
+ deps.log("warn", `inbox poll slow: ${elapsed}ms outcome=${result.outcome}`);
168
+ }
169
+ return { ok: result.ok, mirrored: result.mirrored };
170
+ }
171
+ async function pollInboxOnceTimed(deps) {
172
+ const startedAt = Date.now();
173
+ const timeoutMs = deps.requestTimeoutMs ?? KIT_REQUEST_TIMEOUT_MS;
174
+ const bounded = (fn) => withRequestTimeout(timeoutMs, deps.signal, fn);
175
+ // A timed-out step is a failed poll: stamp error and let the loop retry on
176
+ // the base interval. It never falls through to the empty-inbox path.
177
+ const timedOut = (step) => {
178
+ writeComponentStatus(deps.paths, "inbox", "error");
179
+ deps.log("error", `inbox poll timed out: ${step} after ${Date.now() - startedAt}ms (limit ${timeoutMs}ms) outcome=timeout`);
180
+ return { ok: false, mirrored: 0, outcome: "timeout" };
181
+ };
157
182
  // Mirrors written before credential redaction are scrubbed on the next poll,
158
183
  // before a wake can send the bot at the files. A write or rename error is
159
184
  // counted and retried on the next poll. The log is a count and errno codes,
@@ -178,23 +203,29 @@ export async function pollInboxOnce(deps) {
178
203
  const warmCache = deps.warmCache ?? (async () => { });
179
204
  let token;
180
205
  try {
181
- token = await deps.getToken();
206
+ token = await bounded((signal) => deps.getToken(signal));
182
207
  }
183
208
  catch (err) {
209
+ if (deps.signal?.aborted)
210
+ return { ok: false, mirrored: 0, outcome: "aborted" };
211
+ if (err instanceof KitRequestTimeoutError)
212
+ return timedOut("token mint");
184
213
  writeComponentStatus(deps.paths, "inbox", "error");
185
214
  deps.log("error", `inbox token mint failed: ${err instanceof Error ? err.message : String(err)}`);
186
- return { ok: false, mirrored: 0 };
215
+ return { ok: false, mirrored: 0, outcome: "error" };
187
216
  }
188
217
  let res;
189
218
  try {
190
- res = await fetchInbox(token, deps.signal);
219
+ res = await bounded((signal) => fetchInbox(token, signal));
191
220
  }
192
221
  catch (err) {
193
222
  if (deps.signal?.aborted)
194
- return { ok: false, mirrored: 0 };
223
+ return { ok: false, mirrored: 0, outcome: "aborted" };
224
+ if (err instanceof KitRequestTimeoutError)
225
+ return timedOut("inbox GET");
195
226
  writeComponentStatus(deps.paths, "inbox", "error");
196
227
  deps.log("error", `inbox fetch failed: GET ${agentInboxUrl(deps.apiBaseUrl, deps.agentUid)}: ${err instanceof Error ? err.message : String(err)}`);
197
- return { ok: false, mirrored: 0 };
228
+ return { ok: false, mirrored: 0, outcome: "error" };
198
229
  }
199
230
  if (res.status !== 200) {
200
231
  writeComponentStatus(deps.paths, "inbox", "error");
@@ -202,7 +233,7 @@ export async function pollInboxOnce(deps) {
202
233
  // Non-OK bodies are not constrained to an error envelope and could carry
203
234
  // inbox items. Keep the status and route, but do not copy the body to logs.
204
235
  deps.log("warn", `inbox poll GET ${url} → ${res.status}`);
205
- return { ok: false, mirrored: 0 };
236
+ return { ok: false, mirrored: 0, outcome: "error" };
206
237
  }
207
238
  writeComponentStatus(deps.paths, "inbox", "ok");
208
239
  const seen = readSeenIds(deps.paths);
@@ -211,12 +242,13 @@ export async function pollInboxOnce(deps) {
211
242
  mirrorInboxItem(deps.paths, item);
212
243
  if (deps.ack) {
213
244
  try {
214
- const status = await ackItem(token, item.id);
245
+ const status = await bounded((signal) => ackItem(token, item.id, signal));
215
246
  if (status < 200 || status >= 300)
216
247
  deps.log("warn", `ack ${item.id} → ${status}`);
217
248
  }
218
249
  catch (err) {
219
- deps.log("warn", `ack ${item.id} failed: ${err instanceof Error ? err.message : String(err)}`);
250
+ const reason = err instanceof KitRequestTimeoutError ? err.message : err instanceof Error ? err.message : String(err);
251
+ deps.log("warn", `ack ${item.id} failed: ${reason}`);
220
252
  }
221
253
  }
222
254
  // Persist each completed item before starting another one. If shutdown
@@ -234,7 +266,7 @@ export async function pollInboxOnce(deps) {
234
266
  }
235
267
  }
236
268
  await maybeWake(deps, fresh.length);
237
- return { ok: true, mirrored: fresh.length };
269
+ return { ok: true, mirrored: fresh.length, outcome: "ok" };
238
270
  }
239
271
  /** Poke the bot's framework when mail is new, or still pending past the retry window. */
240
272
  async function maybeWake(deps, freshCount) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Per-request bound for the kit services' network calls.
3
+ *
4
+ * A poll on a stale or half-open connection otherwise waits until the OS
5
+ * drops the socket, which can take hours (row 66ce5718). Each call gets its
6
+ * own deadline combined with the service's shutdown signal. The deadline
7
+ * uses setTimeout, not AbortSignal.timeout, so tests can drive it with fake
8
+ * timers; `anySignal` keeps the CLI working on Node 20.0.
9
+ */
10
+ /** Default per-request timeout for kit network calls (kit.json `requestTimeoutMs`). */
11
+ export declare const KIT_REQUEST_TIMEOUT_MS = 30000;
12
+ /** Lowest accepted kit.json `requestTimeoutMs`; smaller values are raised to this. */
13
+ export declare const KIT_REQUEST_TIMEOUT_MIN_MS = 1000;
14
+ export declare class KitRequestTimeoutError extends Error {
15
+ readonly timeoutMs: number;
16
+ constructor(timeoutMs: number);
17
+ }
18
+ /**
19
+ * Run `fn` with a signal that aborts on shutdown or after `timeoutMs`.
20
+ * The result is also raced against that signal, so a call that ignores the
21
+ * signal (such as a token refresh waiting on a lock) still returns on time.
22
+ * Rejects with KitRequestTimeoutError on the deadline, or with the shutdown
23
+ * signal's reason on shutdown.
24
+ */
25
+ export declare function withRequestTimeout<T>(timeoutMs: number, shutdown: AbortSignal | undefined, fn: (signal: AbortSignal) => Promise<T>): Promise<T>;
26
+ //# sourceMappingURL=request-timeout.d.ts.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Per-request bound for the kit services' network calls.
3
+ *
4
+ * A poll on a stale or half-open connection otherwise waits until the OS
5
+ * drops the socket, which can take hours (row 66ce5718). Each call gets its
6
+ * own deadline combined with the service's shutdown signal. The deadline
7
+ * uses setTimeout, not AbortSignal.timeout, so tests can drive it with fake
8
+ * timers; `anySignal` keeps the CLI working on Node 20.0.
9
+ */
10
+ import { anySignal } from "../../../utils/vault-api.js";
11
+ /** Default per-request timeout for kit network calls (kit.json `requestTimeoutMs`). */
12
+ export const KIT_REQUEST_TIMEOUT_MS = 30_000;
13
+ /** Lowest accepted kit.json `requestTimeoutMs`; smaller values are raised to this. */
14
+ export const KIT_REQUEST_TIMEOUT_MIN_MS = 1_000;
15
+ export class KitRequestTimeoutError extends Error {
16
+ timeoutMs;
17
+ constructor(timeoutMs) {
18
+ super(`request timed out after ${timeoutMs}ms`);
19
+ this.timeoutMs = timeoutMs;
20
+ this.name = "KitRequestTimeoutError";
21
+ }
22
+ }
23
+ /**
24
+ * Run `fn` with a signal that aborts on shutdown or after `timeoutMs`.
25
+ * The result is also raced against that signal, so a call that ignores the
26
+ * signal (such as a token refresh waiting on a lock) still returns on time.
27
+ * Rejects with KitRequestTimeoutError on the deadline, or with the shutdown
28
+ * signal's reason on shutdown.
29
+ */
30
+ export async function withRequestTimeout(timeoutMs, shutdown, fn) {
31
+ const deadline = new AbortController();
32
+ const timer = setTimeout(() => deadline.abort(new KitRequestTimeoutError(timeoutMs)), timeoutMs);
33
+ const combined = anySignal(shutdown ? [shutdown, deadline.signal] : [deadline.signal]);
34
+ let onAbort;
35
+ try {
36
+ const aborted = new Promise((_, reject) => {
37
+ if (combined.signal.aborted)
38
+ return reject(combined.signal.reason);
39
+ onAbort = () => reject(combined.signal.reason);
40
+ combined.signal.addEventListener("abort", onAbort, { once: true });
41
+ });
42
+ return await Promise.race([fn(combined.signal), aborted]);
43
+ }
44
+ finally {
45
+ clearTimeout(timer);
46
+ if (onAbort)
47
+ combined.signal.removeEventListener("abort", onAbort);
48
+ combined.dispose();
49
+ }
50
+ }
51
+ //# sourceMappingURL=request-timeout.js.map
@@ -131,7 +131,7 @@ export interface MyLocalBot {
131
131
  name?: string;
132
132
  botKind?: "personal" | "company";
133
133
  computeMode?: string;
134
- ownerUid?: string;
134
+ ownerUid?: string | null;
135
135
  online?: boolean;
136
136
  lastHeartbeatAt?: string | null;
137
137
  localConfig?: unknown;
@@ -3,6 +3,15 @@
3
3
  */
4
4
  export declare const BOT_RUNTIMES: readonly ["claude", "codex", "grok"];
5
5
  export type BotRuntimeId = (typeof BOT_RUNTIMES)[number];
6
+ export declare const HQ_FLEET_RUNTIME_ENGINE_ID: "hq-fleet-runtime";
7
+ export interface BotEngineMetadata {
8
+ id: typeof HQ_FLEET_RUNTIME_ENGINE_ID;
9
+ distribution: "hq-fleet-local";
10
+ version: string;
11
+ artifactSha256: string;
12
+ runtimeCommit: string;
13
+ }
14
+ export declare function isBotEngineMetadata(value: unknown): value is BotEngineMetadata;
6
15
  export declare function isBotRuntimeId(value: unknown): value is BotRuntimeId;
7
16
  /**
8
17
  * What a bot is (owner decisions, 2026-09-15):
@@ -80,6 +89,7 @@ export interface BotConfig {
80
89
  /** The person (prs_) who created and owns the bot. */
81
90
  ownerUid: string;
82
91
  runtime: BotRuntimeId;
92
+ engine?: BotEngineMetadata;
83
93
  /**
84
94
  * "personal" (acts as its owner everywhere) or "company" (acts as itself,
85
95
  * member of `companies`). Missing on bots created before kinds existed:
@@ -5,6 +5,20 @@ import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import { botConfigPath } from "./paths.js";
7
7
  export const BOT_RUNTIMES = ["claude", "codex", "grok"];
8
+ export const HQ_FLEET_RUNTIME_ENGINE_ID = "hq-fleet-runtime";
9
+ export function isBotEngineMetadata(value) {
10
+ if (!value || typeof value !== "object" || Array.isArray(value))
11
+ return false;
12
+ const engine = value;
13
+ return (engine.id === HQ_FLEET_RUNTIME_ENGINE_ID &&
14
+ engine.distribution === "hq-fleet-local" &&
15
+ typeof engine.version === "string" &&
16
+ /^\d+\.\d+\.\d+$/.test(engine.version) &&
17
+ typeof engine.artifactSha256 === "string" &&
18
+ /^[a-f0-9]{64}$/.test(engine.artifactSha256) &&
19
+ typeof engine.runtimeCommit === "string" &&
20
+ /^[a-f0-9]{40}$/.test(engine.runtimeCommit));
21
+ }
8
22
  export function isBotRuntimeId(value) {
9
23
  return typeof value === "string" && BOT_RUNTIMES.includes(value);
10
24
  }
@@ -188,9 +202,10 @@ export function readBotConfig(dir) {
188
202
  }
189
203
  if (!isBotRuntimeId(raw.runtime))
190
204
  return null;
191
- const { intro: rawIntro, kickoff: rawKickoff, kind: _kind, companies: _companies, ...rest } = raw;
205
+ const { intro: rawIntro, kickoff: rawKickoff, kind: _kind, companies: _companies, engine: rawEngine, ...rest } = raw;
192
206
  const intro = typeof rawIntro === "string" && rawIntro.trim() ? rawIntro : undefined;
193
207
  const kickoff = typeof rawKickoff === "string" && rawKickoff.trim() ? rawKickoff : undefined;
208
+ const engine = isBotEngineMetadata(rawEngine) ? rawEngine : undefined;
194
209
  // Older bot.json files carry no kind: they read (and, on the first patch,
195
210
  // persist) as personal, so every reader sees a kind.
196
211
  const kind = effectiveBotKind(raw);
@@ -201,6 +216,7 @@ export function readBotConfig(dir) {
201
216
  ...(kind === "company" ? { companies } : {}),
202
217
  ...(intro ? { intro } : {}),
203
218
  ...(kickoff ? { kickoff } : {}),
219
+ ...(engine ? { engine } : {}),
204
220
  enabled: raw.enabled !== false,
205
221
  };
206
222
  }