@indigoai-us/hq-cli 5.156.0 → 5.157.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.157.0] — 2026-09-24
6
+
7
+ ### Fixed
8
+
9
+ - Local bots (`hq bot run`) now receive HQ chat attachments. The runner reads
10
+ `attachments` on each inbox item, downloads every file with the bot's own
11
+ machine identity through the same checks `hq_attachment_fetch` uses (chat
12
+ attachment path only, DM must include the bot, company must be one of the
13
+ bot's own, 26 MiB cap), and saves it under `~/.hq/bots/<name>/attachments/`
14
+ (dir 0700, files 0600). The turn text lists each file's name, type, size and
15
+ local path, with small text files inlined; a file that could not be fetched
16
+ gets a one-line reason, which is also logged. A message that is only an
17
+ attachment is answered instead of being acked and dropped. Claude turns get
18
+ `--add-dir` for the attachments folder so gated permission modes can read it.
19
+ The MIME type is also read from the server's `kind` field.
20
+
5
21
  ## [5.156.0] — 2026-09-24
6
22
 
7
23
  ### Fixed
@@ -9,18 +9,16 @@
9
9
  * hq binary with the machine-identity env pinned.
10
10
  */
11
11
  import { Command } from "commander";
12
- import type { Readable } from "node:stream";
13
12
  import { McpServer } from "../lib/agent-kit/mcp/jsonrpc.js";
14
13
  import { type HqRunResult, type McpToolClients } from "../lib/agent-kit/mcp/tools.js";
15
14
  import { type AgentKitPaths } from "../lib/agent-kit/paths.js";
16
15
  import type { ExternalMachineCreds } from "../lib/agent-kit/creds.js";
16
+ export { readBounded } from "../lib/agent-kit/attachment-download.js";
17
17
  export declare const AGENT_MCP_SERVER_NAME = "hq-agent";
18
18
  export declare const AGENT_MCP_INSTRUCTIONS: string;
19
19
  export declare const HQ_SUBPROCESS_TIMEOUT_MS = 120000;
20
20
  export declare function defaultRunHq(nodeBinary: string, hqBinary: string, env: NodeJS.ProcessEnv): (args: string[]) => Promise<HqRunResult>;
21
21
  export declare function defaultMcpClients(creds: ExternalMachineCreds, credsPath: string, paths?: Pick<AgentKitPaths, "inboxDir" | "attachmentsDir">): McpToolClients;
22
- /** Drain a stream into memory, refusing past `max` bytes. */
23
- export declare function readBounded(body: Readable, max: number): Promise<Buffer>;
24
22
  export declare function createAgentMcpServer(clients: McpToolClients): McpServer;
25
23
  export declare function registerAgentMcpCommand(agent: Command): void;
26
24
  //# sourceMappingURL=agent-mcp.d.ts.map
@@ -12,15 +12,15 @@ import { spawn } from "node:child_process";
12
12
  import * as os from "node:os";
13
13
  import { CLI_VERSION } from "../cli-version.js";
14
14
  import { ensureCognitoToken } from "../utils/cognito-session.js";
15
- import { GetObjectCommand } from "@aws-sdk/client-s3";
16
15
  import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
17
- import { createCompanyPresignClient } from "./files-browse.js";
18
- import { MAX_ATTACHMENT_BYTES } from "../lib/agent-kit/mcp/attachments.js";
19
16
  import { McpServer } from "../lib/agent-kit/mcp/jsonrpc.js";
17
+ import { createVaultObjectDownloader } from "../lib/agent-kit/attachment-download.js";
20
18
  import { buildAgentMcpTools } from "../lib/agent-kit/mcp/tools.js";
21
19
  import { readKitConfig } from "../lib/agent-kit/kit-config.js";
22
20
  import { agentKitPaths } from "../lib/agent-kit/paths.js";
23
21
  import { requireExternalCreds } from "./agent-kit.js";
22
+ // Re-exported for callers that imported it from here before it moved.
23
+ export { readBounded } from "../lib/agent-kit/attachment-download.js";
24
24
  export const AGENT_MCP_SERVER_NAME = "hq-agent";
25
25
  export const AGENT_MCP_INSTRUCTIONS = "You are acting inside HQ as an enrolled external agent. Tools run as that machine identity: " +
26
26
  "hq_whoami (identity), hq_search / hq_files_list / hq_files_read (company vault), " +
@@ -78,14 +78,8 @@ export function defaultMcpClients(creds, credsPath, paths = agentKitPaths(os.hom
78
78
  throw err;
79
79
  }
80
80
  },
81
- downloadVaultObject: async (scopeUid, key) => {
82
- // Same presign path `hq files cat` uses; the vault API's per-key ACL decides.
83
- const client = createCompanyPresignClient({ token: await getToken(), companyUid: scopeUid, baseUrl: creds.apiBaseUrl });
84
- const res = await client.send(new GetObjectCommand({ Bucket: scopeUid, Key: key }));
85
- if (!res.Body)
86
- throw new Error(`no body returned for '${key}'`);
87
- return readBounded(res.Body, MAX_ATTACHMENT_BYTES);
88
- },
81
+ // Same presign path `hq files cat` uses; the vault API's per-key ACL decides.
82
+ downloadVaultObject: createVaultObjectDownloader({ getToken, baseUrl: creds.apiBaseUrl }),
89
83
  apiJson: async (token, path, init) => {
90
84
  const res = await vaultApiFetch({
91
85
  token,
@@ -100,21 +94,6 @@ export function defaultMcpClients(creds, credsPath, paths = agentKitPaths(os.hom
100
94
  },
101
95
  };
102
96
  }
103
- /** Drain a stream into memory, refusing past `max` bytes. */
104
- export async function readBounded(body, max) {
105
- const chunks = [];
106
- let total = 0;
107
- for await (const chunk of body) {
108
- const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
109
- total += b.length;
110
- if (total > max) {
111
- body.destroy();
112
- throw new Error(`object exceeds the ${max}-byte attachment limit`);
113
- }
114
- chunks.push(b);
115
- }
116
- return Buffer.concat(chunks);
117
- }
118
97
  export function createAgentMcpServer(clients) {
119
98
  return new McpServer({
120
99
  name: AGENT_MCP_SERVER_NAME,
@@ -25,6 +25,7 @@ import { hasPromotionHold, promotionHoldSummary } from "../lib/bot/promotion-hol
25
25
  * list (online flag), and rm. Secrets are never printed.
26
26
  */
27
27
  import { promoteLocalBot } from "../lib/bot/promote.js";
28
+ import { createVaultObjectDownloader } from "../lib/agent-kit/attachment-download.js";
28
29
  import { addBotToCompanies, describeCompanyFailures, removeBotFromCompanies } from "./bot-companies.js";
29
30
  import { registerBotContinuityCommand } from "./bot-continuity.js";
30
31
  import { registerBotJobsCommand } from "./bot-jobs.js";
@@ -680,9 +681,19 @@ async function runBotResident(nameArg) {
680
681
  console.log(orphanBotRunMessage(name, outcome));
681
682
  return;
682
683
  }
683
- const api = new BotApi({ token: botTokenSupplier(dir) });
684
+ const token = botTokenSupplier(dir);
685
+ const api = new BotApi({ token });
684
686
  const owner = new OwnerContextCache(() => lookupOwnerContext(config.ownerUid), Date.now, undefined, config.ownerUid);
685
- const handle = await runBot({ dir, config, api, runtime: runtimeFor(config.runtime), ownerContext: () => owner.get() });
687
+ const handle = await runBot({
688
+ dir,
689
+ config,
690
+ api,
691
+ runtime: runtimeFor(config.runtime),
692
+ ownerContext: () => owner.get(),
693
+ // Chat attachments are fetched with the bot's own machine identity against
694
+ // the same API its inbox and posts use (BotApi has no baseUrl override either).
695
+ downloadAttachment: createVaultObjectDownloader({ getToken: token }),
696
+ });
686
697
  await handle.done;
687
698
  void name;
688
699
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Vault object download for chat attachments, shared by `hq agent mcp`
3
+ * (hq_attachment_fetch) and the local bot runner (`hq bot run`).
4
+ *
5
+ * Bytes come through the same vault presign path `hq files cat` uses
6
+ * (`createCompanyPresignClient` → POST /v1/files/presign) with the caller's own
7
+ * machine token, so the server's per-key ACL stays the authority. The body is
8
+ * read with a hard byte cap so a crafted key cannot pull a large object into
9
+ * memory.
10
+ */
11
+ import type { Readable } from "node:stream";
12
+ export type VaultObjectDownloader = (scopeUid: string, key: string) => Promise<Buffer>;
13
+ /** Drain a stream into memory, refusing past `max` bytes. */
14
+ export declare function readBounded(body: Readable, max: number): Promise<Buffer>;
15
+ /**
16
+ * A downloader bound to one identity. `baseUrl` pins the API the token is sent
17
+ * to (an enrolled agent passes its own `apiBaseUrl`; undefined = the default
18
+ * vault API, the same one the caller's other API calls use).
19
+ */
20
+ export declare function createVaultObjectDownloader(opts: {
21
+ getToken: () => Promise<string>;
22
+ baseUrl?: string;
23
+ }): VaultObjectDownloader;
24
+ //# sourceMappingURL=attachment-download.d.ts.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Vault object download for chat attachments, shared by `hq agent mcp`
3
+ * (hq_attachment_fetch) and the local bot runner (`hq bot run`).
4
+ *
5
+ * Bytes come through the same vault presign path `hq files cat` uses
6
+ * (`createCompanyPresignClient` → POST /v1/files/presign) with the caller's own
7
+ * machine token, so the server's per-key ACL stays the authority. The body is
8
+ * read with a hard byte cap so a crafted key cannot pull a large object into
9
+ * memory.
10
+ */
11
+ import { GetObjectCommand } from "@aws-sdk/client-s3";
12
+ import { createCompanyPresignClient } from "../../commands/files-browse.js";
13
+ import { MAX_ATTACHMENT_BYTES } from "./mcp/attachments.js";
14
+ /** Drain a stream into memory, refusing past `max` bytes. */
15
+ export async function readBounded(body, max) {
16
+ const chunks = [];
17
+ let total = 0;
18
+ for await (const chunk of body) {
19
+ const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
20
+ total += b.length;
21
+ if (total > max) {
22
+ body.destroy();
23
+ throw new Error(`object exceeds the ${max}-byte attachment limit`);
24
+ }
25
+ chunks.push(b);
26
+ }
27
+ return Buffer.concat(chunks);
28
+ }
29
+ /**
30
+ * A downloader bound to one identity. `baseUrl` pins the API the token is sent
31
+ * to (an enrolled agent passes its own `apiBaseUrl`; undefined = the default
32
+ * vault API, the same one the caller's other API calls use).
33
+ */
34
+ export function createVaultObjectDownloader(opts) {
35
+ return async (scopeUid, key) => {
36
+ const client = createCompanyPresignClient({
37
+ token: await opts.getToken(),
38
+ companyUid: scopeUid,
39
+ ...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
40
+ });
41
+ const res = await client.send(new GetObjectCommand({ Bucket: scopeUid, Key: key }));
42
+ if (!res.Body)
43
+ throw new Error(`no body returned for '${key}'`);
44
+ return readBounded(res.Body, MAX_ATTACHMENT_BYTES);
45
+ };
46
+ }
47
+ //# sourceMappingURL=attachment-download.js.map
@@ -30,11 +30,15 @@ export interface InboxAttachment {
30
30
  companyUid?: string;
31
31
  }
32
32
  /**
33
- * The file references on an inbox entry, plural `attachments[]` first and the
33
+ * The file references on an inbox entry (a mirrored kit entry or a bot
34
+ * runner's raw inbox item), plural `attachments[]` first and the
34
35
  * legacy singular `attachment` as a fallback. Entries without a `vaultPath` are
35
36
  * dropped: a reference the bot cannot fetch is worse than none. Pure.
36
37
  */
37
- export declare function inboxEntryAttachments(e: MirroredInboxEntry): InboxAttachment[];
38
+ export declare function inboxEntryAttachments(e: MirroredInboxEntry | {
39
+ attachments?: unknown;
40
+ attachment?: unknown;
41
+ }): InboxAttachment[];
38
42
  /**
39
43
  * Compact view for bots: who, when, which channel, the text, and any attached
40
44
  * files. The attachments belong here because this summary IS the message as far
@@ -66,7 +66,8 @@ export function pendingInbox(paths) {
66
66
  return readMirroredInbox(paths).filter((e) => !done.has(e.id));
67
67
  }
68
68
  /**
69
- * The file references on an inbox entry, plural `attachments[]` first and the
69
+ * The file references on an inbox entry (a mirrored kit entry or a bot
70
+ * runner's raw inbox item), plural `attachments[]` first and the
70
71
  * legacy singular `attachment` as a fallback. Entries without a `vaultPath` are
71
72
  * dropped: a reference the bot cannot fetch is worse than none. Pure.
72
73
  */
@@ -87,10 +88,13 @@ export function inboxEntryAttachments(e) {
87
88
  const str = (k) => typeof rec[k] === "string" && rec[k].trim()
88
89
  ? rec[k].trim()
89
90
  : undefined;
91
+ // The chat server records the MIME type as `kind` (hq-pro MessageAttachment);
92
+ // `contentType` wins when both are present. A coarse kind ("image") is not a MIME type.
93
+ const contentType = str("contentType") ?? (str("kind")?.includes("/") ? str("kind") : undefined);
90
94
  out.push({
91
95
  vaultPath,
92
96
  ...(str("name") ? { name: str("name") } : {}),
93
- ...(str("contentType") ? { contentType: str("contentType") } : {}),
97
+ ...(contentType ? { contentType } : {}),
94
98
  ...(typeof rec.sizeBytes === "number" ? { sizeBytes: rec.sizeBytes } : {}),
95
99
  ...(str("companyUid") ? { companyUid: str("companyUid") } : {}),
96
100
  });
@@ -68,7 +68,8 @@ export declare function assertAttachmentRef(input: {
68
68
  export declare function assertAttachmentScope(input: {
69
69
  scopeUid: string;
70
70
  key: string;
71
- botCompanyUid: string;
71
+ /** The bot's company, or every company it is a member of (a local company bot can have several). */
72
+ botCompanyUid: string | readonly string[];
72
73
  botEntityUid: string;
73
74
  }): void;
74
75
  /** Reduce an untrusted name to a safe single path component. */
@@ -96,4 +97,30 @@ export declare function saveAttachment(input: {
96
97
  contentType?: string;
97
98
  download: (scopeUid: string, key: string) => Promise<Buffer>;
98
99
  }): Promise<FetchAttachmentResult>;
100
+ /** The bot's own company could not be looked up, so a cmp_ attachment could not be gated. */
101
+ export declare class AttachmentCompanyUnresolvedError extends Error {
102
+ constructor(cause: unknown);
103
+ }
104
+ /**
105
+ * Validate one sender-supplied attachment ref and download it privately.
106
+ * The single path every caller uses (the `hq_attachment_fetch` MCP tool and
107
+ * the local bot runner), so the tenant checks cannot drift apart:
108
+ * 1. key shape (assertAttachmentKey) and the no-network ref checks
109
+ * (uid format, DM-pair membership, prs_ scope rule) — before any request;
110
+ * 2. for a cmp_ scope only, the bot's own company (resolved lazily, so a
111
+ * prs_ DM attachment never costs a lookup) must be the scope;
112
+ * 3. saveAttachment: size cap, sanitized 0600 file in a 0700 dir, temp cleanup.
113
+ * Refusals throw McpToolInputError; a failed company lookup throws
114
+ * AttachmentCompanyUnresolvedError; a download/write failure throws as-is.
115
+ */
116
+ export declare function fetchChatAttachment(input: {
117
+ vaultPath: string;
118
+ scopeUid: string;
119
+ name?: string;
120
+ contentType?: string;
121
+ botEntityUid: string;
122
+ resolveBotCompanyUids: () => Promise<string | readonly string[]>;
123
+ attachmentsDir: string;
124
+ download: (scopeUid: string, key: string) => Promise<Buffer>;
125
+ }): Promise<FetchAttachmentResult>;
99
126
  //# sourceMappingURL=attachments.d.ts.map
@@ -113,8 +113,9 @@ export function assertAttachmentRef(input) {
113
113
  export function assertAttachmentScope(input) {
114
114
  const { scopeUid, key, botCompanyUid, botEntityUid } = input;
115
115
  assertAttachmentRef({ scopeUid, key, botEntityUid });
116
- if (scopeUid.startsWith("cmp_") && scopeUid !== botCompanyUid) {
117
- throw new McpToolInputError(`attachment belongs to company ${scopeUid}; this agent is enrolled in ${botCompanyUid} and cannot read another company's files`);
116
+ const own = typeof botCompanyUid === "string" ? [botCompanyUid] : botCompanyUid;
117
+ if (scopeUid.startsWith("cmp_") && !own.includes(scopeUid)) {
118
+ throw new McpToolInputError(`attachment belongs to company ${scopeUid}; this agent is ${own.length ? `enrolled in ${own.join(", ")}` : "not a member of any company"} and cannot read another company's files`);
118
119
  }
119
120
  }
120
121
  /** Reduce an untrusted name to a safe single path component. */
@@ -185,4 +186,49 @@ export async function saveAttachment(input) {
185
186
  ...(note ? { note } : {}),
186
187
  };
187
188
  }
189
+ /** The bot's own company could not be looked up, so a cmp_ attachment could not be gated. */
190
+ export class AttachmentCompanyUnresolvedError extends Error {
191
+ constructor(cause) {
192
+ super(`could not resolve this agent's company: ${cause instanceof Error ? cause.message : String(cause)}`);
193
+ this.name = "AttachmentCompanyUnresolvedError";
194
+ }
195
+ }
196
+ /**
197
+ * Validate one sender-supplied attachment ref and download it privately.
198
+ * The single path every caller uses (the `hq_attachment_fetch` MCP tool and
199
+ * the local bot runner), so the tenant checks cannot drift apart:
200
+ * 1. key shape (assertAttachmentKey) and the no-network ref checks
201
+ * (uid format, DM-pair membership, prs_ scope rule) — before any request;
202
+ * 2. for a cmp_ scope only, the bot's own company (resolved lazily, so a
203
+ * prs_ DM attachment never costs a lookup) must be the scope;
204
+ * 3. saveAttachment: size cap, sanitized 0600 file in a 0700 dir, temp cleanup.
205
+ * Refusals throw McpToolInputError; a failed company lookup throws
206
+ * AttachmentCompanyUnresolvedError; a download/write failure throws as-is.
207
+ */
208
+ export async function fetchChatAttachment(input) {
209
+ const key = assertAttachmentKey(input.vaultPath);
210
+ const scopeUid = input.scopeUid.trim();
211
+ const { botEntityUid } = input;
212
+ // Format, DM-pair and prs_ checks need no network: refuse bad refs
213
+ // before resolving the company (a lookup that sends the bot's token).
214
+ assertAttachmentRef({ scopeUid, key, botEntityUid });
215
+ if (scopeUid.startsWith("cmp_")) {
216
+ let botCompanyUid;
217
+ try {
218
+ botCompanyUid = await input.resolveBotCompanyUids();
219
+ }
220
+ catch (err) {
221
+ throw new AttachmentCompanyUnresolvedError(err);
222
+ }
223
+ assertAttachmentScope({ scopeUid, key, botCompanyUid, botEntityUid });
224
+ }
225
+ return saveAttachment({
226
+ attachmentsDir: input.attachmentsDir,
227
+ scopeUid,
228
+ key,
229
+ name: input.name,
230
+ contentType: input.contentType,
231
+ download: input.download,
232
+ });
233
+ }
188
234
  //# sourceMappingURL=attachments.js.map
@@ -19,7 +19,7 @@ import { CLI_VERSION } from "../../../cli-version.js";
19
19
  import { peekIdToken } from "../../../utils/id-token.js";
20
20
  import { isRecipientUid } from "../../../lib/notify-recipient.js";
21
21
  import { markInboxDone, mergeAgentInbox, readDoneIds, summarizeInboxEntry } from "../inbox-state.js";
22
- import { assertAttachmentKey, assertAttachmentRef, assertAttachmentScope, saveAttachment } from "./attachments.js";
22
+ import { AttachmentCompanyUnresolvedError, fetchChatAttachment } from "./attachments.js";
23
23
  import { errorResult, McpToolInputError, textResult } from "./jsonrpc.js";
24
24
  export const MAX_TOOL_OUTPUT_CHARS = 40_000;
25
25
  export const ATTACHMENTS_HINT = "Some messages carry files. For each entry in a message's `attachments` whose vaultPath starts with chat/attachments/, " +
@@ -295,35 +295,29 @@ export function buildAgentMcpTools(clients) {
295
295
  additionalProperties: false,
296
296
  },
297
297
  handler: async (args) => {
298
- const key = assertAttachmentKey(str(args, "vault_path", { required: true, max: 1000 }));
299
- const scopeUid = str(args, "company_uid", { required: true, max: 220 }).trim();
298
+ const vaultPath = str(args, "vault_path", { required: true, max: 1000 });
299
+ const scopeUid = str(args, "company_uid", { required: true, max: 220 });
300
300
  const name = str(args, "name", { max: 500 });
301
301
  const contentType = str(args, "content_type", { max: 200 });
302
- // Format, DM-pair and prs_ checks need no network: refuse bad refs
303
- // before resolving the company (a lookup that sends the bot's token).
304
- assertAttachmentRef({ scopeUid, key, botEntityUid: creds.entityUid });
305
- if (scopeUid.startsWith("cmp_")) {
306
- let botCompanyUid;
307
- try {
308
- botCompanyUid = await clients.resolveCompanyUid();
309
- }
310
- catch (err) {
311
- return errorResult(`could not resolve this agent's company: ${err instanceof Error ? err.message : String(err)}`);
312
- }
313
- assertAttachmentScope({ scopeUid, key, botCompanyUid, botEntityUid: creds.entityUid });
314
- }
315
302
  try {
316
- const result = await saveAttachment({
317
- attachmentsDir: clients.paths.attachmentsDir,
303
+ const result = await fetchChatAttachment({
304
+ vaultPath,
318
305
  scopeUid,
319
- key,
320
306
  name,
321
307
  contentType,
308
+ botEntityUid: creds.entityUid,
309
+ resolveBotCompanyUids: clients.resolveCompanyUid,
310
+ attachmentsDir: clients.paths.attachmentsDir,
322
311
  download: clients.downloadVaultObject,
323
312
  });
324
313
  return textResult(clampOutput(JSON.stringify(result, null, 2)));
325
314
  }
326
315
  catch (err) {
316
+ // A refused ref is bad tool input (JSON-RPC invalid params).
317
+ if (err instanceof McpToolInputError)
318
+ throw err;
319
+ if (err instanceof AttachmentCompanyUnresolvedError)
320
+ return errorResult(err.message);
327
321
  return errorResult(`attachment fetch failed: ${err instanceof Error ? err.message : String(err)}`);
328
322
  }
329
323
  },
@@ -22,6 +22,15 @@ export interface InboxItem {
22
22
  text?: string;
23
23
  rootEventId?: string;
24
24
  receivedAt?: string;
25
+ /**
26
+ * Files attached to the message, as vault references (never content):
27
+ * `{ vaultPath, name, sizeBytes, contentType | kind, companyUid }`, where
28
+ * `companyUid` is the vault scope (the sender's prs_ for a DM). Server:
29
+ * hq-pro-agents channels/agent-dm-dispatch.ts. Parse with
30
+ * `inboxEntryAttachments`; the singular `attachment` is the legacy form.
31
+ */
32
+ attachments?: unknown[];
33
+ attachment?: unknown;
25
34
  /** Room items only (channel === "room"). */
26
35
  channelId?: string;
27
36
  channelName?: string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Chat attachments for local bot turns (`hq bot run`).
3
+ *
4
+ * A local bot's runtime (claude / codex / grok) runs without the `hq agent
5
+ * mcp` server, so it cannot call `hq_attachment_fetch`. The runner therefore
6
+ * downloads every file named on an inbox item BEFORE the turn, through the
7
+ * same validated path the MCP tool uses (`fetchChatAttachment`: key shape,
8
+ * DM-pair membership, company gate, size cap, private file), with the bot's
9
+ * own machine credentials, into the bot's own directory. The turn text gets a
10
+ * short block naming each file and where it is on disk; a file that could
11
+ * not be fetched gets a one-line reason instead of a path. Doing it in the
12
+ * runner makes it work the same for every runtime.
13
+ */
14
+ import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
15
+ import type { InboxAttachment } from "../agent-kit/inbox-state.js";
16
+ export interface TurnAttachment {
17
+ /** Display name (sanitized file name once saved). */
18
+ name: string;
19
+ contentType?: string;
20
+ sizeBytes?: number;
21
+ /** Set when the file was saved; absent when `error` is set. */
22
+ localPath?: string;
23
+ /** Bounded inline rendering for text-like files. */
24
+ text?: string;
25
+ /** How to open a non-text file. */
26
+ note?: string;
27
+ /** One-line reason the file was not downloaded. */
28
+ error?: string;
29
+ }
30
+ /**
31
+ * Download each attachment for one turn. Never throws: a per-file failure is
32
+ * returned as `error` (the caller logs it and shows it to the model), so one
33
+ * bad reference never costs the sender their reply.
34
+ */
35
+ export declare function downloadTurnAttachments(input: {
36
+ attachments: readonly InboxAttachment[];
37
+ botEntityUid: string;
38
+ attachmentsDir: string;
39
+ /** The bot's own company uids (its server memberships); consulted only for a cmp_ scope. */
40
+ resolveBotCompanyUids: () => Promise<readonly string[]>;
41
+ download?: VaultObjectDownloader;
42
+ }): Promise<TurnAttachment[]>;
43
+ /** The plain block appended to the turn text. Empty when there are no files. */
44
+ export declare function attachmentTurnBlock(files: readonly TurnAttachment[]): string;
45
+ //# sourceMappingURL=attachments.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Chat attachments for local bot turns (`hq bot run`).
3
+ *
4
+ * A local bot's runtime (claude / codex / grok) runs without the `hq agent
5
+ * mcp` server, so it cannot call `hq_attachment_fetch`. The runner therefore
6
+ * downloads every file named on an inbox item BEFORE the turn, through the
7
+ * same validated path the MCP tool uses (`fetchChatAttachment`: key shape,
8
+ * DM-pair membership, company gate, size cap, private file), with the bot's
9
+ * own machine credentials, into the bot's own directory. The turn text gets a
10
+ * short block naming each file and where it is on disk; a file that could
11
+ * not be fetched gets a one-line reason instead of a path. Doing it in the
12
+ * runner makes it work the same for every runtime.
13
+ */
14
+ import { fetchChatAttachment, sanitizeAttachmentFileName } from "../agent-kit/mcp/attachments.js";
15
+ function oneLine(s, max = 300) {
16
+ const flat = s.replace(/\s+/g, " ").trim();
17
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
18
+ }
19
+ /**
20
+ * Download each attachment for one turn. Never throws: a per-file failure is
21
+ * returned as `error` (the caller logs it and shows it to the model), so one
22
+ * bad reference never costs the sender their reply.
23
+ */
24
+ export async function downloadTurnAttachments(input) {
25
+ const out = [];
26
+ for (const ref of input.attachments) {
27
+ const name = sanitizeAttachmentFileName(ref.name || ref.vaultPath.split("/").pop() || "file");
28
+ const base = {
29
+ name,
30
+ ...(ref.contentType ? { contentType: ref.contentType } : {}),
31
+ ...(typeof ref.sizeBytes === "number" ? { sizeBytes: ref.sizeBytes } : {}),
32
+ };
33
+ if (!ref.companyUid) {
34
+ out.push({ ...base, error: "the message did not say which vault holds this file (no companyUid)" });
35
+ continue;
36
+ }
37
+ if (!input.download) {
38
+ out.push({ ...base, error: "this bot runner cannot download attachments" });
39
+ continue;
40
+ }
41
+ try {
42
+ const saved = await fetchChatAttachment({
43
+ vaultPath: ref.vaultPath,
44
+ scopeUid: ref.companyUid,
45
+ ...(ref.name ? { name: ref.name } : {}),
46
+ ...(ref.contentType ? { contentType: ref.contentType } : {}),
47
+ botEntityUid: input.botEntityUid,
48
+ resolveBotCompanyUids: input.resolveBotCompanyUids,
49
+ attachmentsDir: input.attachmentsDir,
50
+ download: input.download,
51
+ });
52
+ out.push({
53
+ name: saved.name,
54
+ ...(saved.contentType ? { contentType: saved.contentType } : {}),
55
+ sizeBytes: saved.sizeBytes,
56
+ localPath: saved.localPath,
57
+ ...(saved.text !== undefined ? { text: saved.text } : {}),
58
+ ...(saved.note ? { note: saved.note } : {}),
59
+ });
60
+ }
61
+ catch (err) {
62
+ out.push({ ...base, error: oneLine(err instanceof Error ? err.message : String(err)) });
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+ /** The plain block appended to the turn text. Empty when there are no files. */
68
+ export function attachmentTurnBlock(files) {
69
+ if (files.length === 0)
70
+ return "";
71
+ const lines = [`[Attached files: ${files.length}]`];
72
+ for (const f of files) {
73
+ const meta = [f.contentType, typeof f.sizeBytes === "number" ? `${f.sizeBytes} bytes` : undefined].filter(Boolean).join(", ");
74
+ const head = `- ${f.name}${meta ? ` (${meta})` : ""}`;
75
+ if (f.error || !f.localPath) {
76
+ lines.push(`${head}: not downloaded: ${f.error ?? "unknown error"}`);
77
+ continue;
78
+ }
79
+ lines.push(`${head}: saved at ${f.localPath}`);
80
+ if (f.text !== undefined) {
81
+ lines.push(` Contents of ${f.name}:`, `----- begin ${f.name} -----`, f.text, `----- end ${f.name} -----`);
82
+ }
83
+ else if (f.note) {
84
+ lines.push(` ${f.note.replace(/localPath/g, "the saved file")}`);
85
+ }
86
+ }
87
+ return lines.join("\n");
88
+ }
89
+ //# sourceMappingURL=attachments.js.map
@@ -63,6 +63,11 @@ export declare function botTokenStateDir(dir: string): string;
63
63
  export declare function botTurnCwd(dir: string): string;
64
64
  /** ~/.hq/bots/<name>/memory — "local" memory, outside the synced HQ tree. */
65
65
  export declare function botLocalMemoryDir(dir: string): string;
66
+ /**
67
+ * ~/.hq/bots/<name>/attachments — chat attachments the runner downloads for a
68
+ * turn (dir 0700, files 0600), one subfolder per vault scope.
69
+ */
70
+ export declare function botAttachmentsDir(dir: string): string;
66
71
  export declare function botLogsDir(dir: string): string;
67
72
  export declare function botLogPath(dir: string): string;
68
73
  export declare function launchdLabel(name: string): string;
@@ -108,6 +108,13 @@ export function botTurnCwd(dir) {
108
108
  export function botLocalMemoryDir(dir) {
109
109
  return path.join(dir, "memory");
110
110
  }
111
+ /**
112
+ * ~/.hq/bots/<name>/attachments — chat attachments the runner downloads for a
113
+ * turn (dir 0700, files 0600), one subfolder per vault scope.
114
+ */
115
+ export function botAttachmentsDir(dir) {
116
+ return path.join(dir, "attachments");
117
+ }
111
118
  export function botLogsDir(dir) {
112
119
  return path.join(dir, BOT_LOGS_DIRNAME);
113
120
  }
@@ -50,6 +50,7 @@ import type { BotConfig, BotKind } from "./config.js";
50
50
  import { type ProgressPosterOptions } from "./progress.js";
51
51
  import { type BotLogger } from "./log.js";
52
52
  import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "./runtime/index.js";
53
+ import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
53
54
  import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../heartbeat-identity.js";
54
55
  import { type BotState, type BotStatusFile } from "./status.js";
55
56
  export declare const INBOX_POLL_INTERVAL_MS = 2000;
@@ -140,6 +141,12 @@ export interface BotRunDeps {
140
141
  maxParallelTurns?: number;
141
142
  /** Wait between retries while the coding tool needs a sign-in (default SIGN_IN_RETRY_MS). */
142
143
  signInRetryMs?: number;
144
+ /**
145
+ * Downloads a chat attachment's bytes with the bot's own machine identity
146
+ * (vault presign). Unset = attachments are listed with a "cannot download"
147
+ * reason instead of a path.
148
+ */
149
+ downloadAttachment?: VaultObjectDownloader;
143
150
  agentUidSource?: HeartbeatAgentUidSource;
144
151
  credsSource?: HeartbeatCredsSource;
145
152
  reportIdentityMismatch?: HeartbeatIdentityMismatchReporter;
@@ -63,7 +63,9 @@ import { createBotLogger } from "./log.js";
63
63
  import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
64
64
  import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled } from "./runtime/claude.js";
65
65
  import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
66
- import { dmSessionScope } from "./paths.js";
66
+ import { botAttachmentsDir, dmSessionScope } from "./paths.js";
67
+ import { attachmentTurnBlock, downloadTurnAttachments } from "./attachments.js";
68
+ import { inboxEntryAttachments } from "../agent-kit/inbox-state.js";
67
69
  import { isDmItem, resolveReplyTarget } from "./room-policy.js";
68
70
  import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
69
71
  import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
@@ -660,6 +662,7 @@ export async function runBot(deps) {
660
662
  poster.message(event.text);
661
663
  },
662
664
  signal: controller.signal,
665
+ ...(args.readDirs?.length ? { readDirs: args.readDirs } : {}),
663
666
  });
664
667
  running = false;
665
668
  await poster.finish(turn.text);
@@ -1226,8 +1229,10 @@ export async function runBot(deps) {
1226
1229
  await handleJobItem(item, target);
1227
1230
  return;
1228
1231
  }
1229
- const text = (item.text ?? "").trim();
1230
- if (!text) {
1232
+ const messageText = (item.text ?? "").trim();
1233
+ // A message can be only a file (no text): that is still a message to answer.
1234
+ const attachmentRefs = inboxEntryAttachments(item);
1235
+ if (!messageText && attachmentRefs.length === 0) {
1231
1236
  markProcessed(dir, id, now().toISOString());
1232
1237
  await api.ackInbox(config.agentUid, id);
1233
1238
  return;
@@ -1237,7 +1242,7 @@ export async function runBot(deps) {
1237
1242
  const reactionId = item.eventId ?? id;
1238
1243
  const tReceived = now().getTime();
1239
1244
  const where = target.kind === "room" ? `room ${target.channelId}` : "dm";
1240
- log("info", `received ${id} (${text.length} chars, ${where})`);
1245
+ log("info", `received ${id} (${messageText.length} chars${attachmentRefs.length ? `, ${attachmentRefs.length} attachment${attachmentRefs.length === 1 ? "" : "s"}` : ""}, ${where})`);
1241
1246
  // Rooms: an UNMENTIONED responder (a plain room message)
1242
1247
  // claims first so the owner's several bots produce one answer, not one
1243
1248
  // each. A bot that was @mentioned never claims — it was asked by name and
@@ -1267,6 +1272,30 @@ export async function runBot(deps) {
1267
1272
  await api.setReaction({ peerUid: target.peerUid, messageId: reactionId, emoji: THINKING_EMOJI }, false);
1268
1273
  }
1269
1274
  };
1275
+ // Attachments are fetched here, in the runner, so every runtime sees them
1276
+ // the same way: the turn text names each saved file (or why it is missing).
1277
+ let text = messageText;
1278
+ const readDirs = [];
1279
+ if (attachmentRefs.length > 0) {
1280
+ const attachmentsDir = botAttachmentsDir(dir);
1281
+ const files = await downloadTurnAttachments({
1282
+ attachments: attachmentRefs,
1283
+ botEntityUid: config.agentUid,
1284
+ attachmentsDir,
1285
+ resolveBotCompanyUids: async () => (await api.getAgent(config.agentUid)).companyMemberships ?? [],
1286
+ ...(deps.downloadAttachment ? { download: deps.downloadAttachment } : {}),
1287
+ });
1288
+ for (const f of files) {
1289
+ if (f.error)
1290
+ log("warn", `attachment ${f.name} on ${id} not downloaded: ${f.error}`);
1291
+ else
1292
+ log("info", `attachment ${f.name} on ${id} saved (${f.sizeBytes ?? 0} bytes)`);
1293
+ }
1294
+ if (files.some((f) => f.localPath))
1295
+ readDirs.push(attachmentsDir);
1296
+ const block = attachmentTurnBlock(files);
1297
+ text = text ? `${text}\n\n${block}` : block;
1298
+ }
1270
1299
  const sessionScope = target.kind === "room" ? target.sessionScope : dmSessionScope(target.peerUid, config.ownerUid);
1271
1300
  const prompt = target.kind === "room" ? await buildRoomTurn(item, target, text) : target.peerUid === config.ownerUid ? text :
1272
1301
  `[HQ DM sender: ${target.peerUid}; owner: ${config.ownerUid}; sender is not the owner.]\n\n${text}`;
@@ -1284,6 +1313,7 @@ export async function runBot(deps) {
1284
1313
  tReceived,
1285
1314
  send,
1286
1315
  workingNotice: !isSetupBot,
1316
+ ...(readDirs.length ? { readDirs } : {}),
1287
1317
  ...(target.kind === "room"
1288
1318
  ? { afterProgressPost: () => api.postAgentStatus(target.channelId, ROOM_THINKING_STATUS, target.rootEventId) }
1289
1319
  : {}),
@@ -7,6 +7,7 @@
7
7
  * when the bot's autoApprove is false; HQ_BOT_CLAUDE_PERMISSION_MODE
8
8
  * overrides both)
9
9
  * --append-system-prompt-file <file> --effort <level> [--model m]
10
+ * [--add-dir <hqRoot>] [--add-dir <attachments dir>]
10
11
  * (--session-id <uuid> | --resume <uuid>)
11
12
  *
12
13
  * stdout is NDJSON (see messages-stream.ts): each finished assistant message
@@ -7,6 +7,7 @@
7
7
  * when the bot's autoApprove is false; HQ_BOT_CLAUDE_PERMISSION_MODE
8
8
  * overrides both)
9
9
  * --append-system-prompt-file <file> --effort <level> [--model m]
10
+ * [--add-dir <hqRoot>] [--add-dir <attachments dir>]
10
11
  * (--session-id <uuid> | --resume <uuid>)
11
12
  *
12
13
  * stdout is NDJSON (see messages-stream.ts): each finished assistant message
@@ -134,6 +135,11 @@ export function createClaudeRuntime(env = process.env) {
134
135
  // Keep the HQ tree writable even if a caller runs the turn elsewhere.
135
136
  if (input.hqRoot && input.hqRoot !== input.cwd)
136
137
  args.push("--add-dir", input.hqRoot);
138
+ // Downloaded attachments live in the bot's own dir, outside the HQ tree.
139
+ for (const d of input.readDirs ?? []) {
140
+ if (d && d !== input.cwd && d !== input.hqRoot)
141
+ args.push("--add-dir", d);
142
+ }
137
143
  if (input.model)
138
144
  args.push("--model", input.model);
139
145
  if (input.sessionId)
@@ -21,6 +21,14 @@ export interface RuntimeTurnInput {
21
21
  /** The owner's HQ root: runtimes with a write sandbox must allow it too,
22
22
  * or the bot cannot edit its own memory notes under personal/workers/. */
23
23
  hqRoot?: string;
24
+ /**
25
+ * Directories outside cwd/hqRoot the turn must be able to READ (the bot's
26
+ * downloaded chat attachments). Claude restricts file tools to its working
27
+ * dirs outside bypassPermissions, so it gets `--add-dir` for each. Codex
28
+ * (read-anywhere in every sandbox mode) and Grok (--always-approve / ACP
29
+ * auto-allow) already read them and ignore this.
30
+ */
31
+ readDirs?: string[];
24
32
  /** Previous session id to resume, if any. */
25
33
  sessionId?: string;
26
34
  /** Optional model override. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.156.0",
3
+ "version": "5.157.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {