@indigoai-us/hq-cli 5.155.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/command-catalog.generated.d.ts +4 -1
  3. package/dist/command-catalog.generated.js +5 -1
  4. package/dist/commands/agent-mcp.d.ts +1 -3
  5. package/dist/commands/agent-mcp.js +5 -26
  6. package/dist/commands/bot.js +13 -2
  7. package/dist/commands/lanes.js +94 -18
  8. package/dist/commands/mesh.js +35 -8
  9. package/dist/lib/agent-kit/attachment-download.d.ts +24 -0
  10. package/dist/lib/agent-kit/attachment-download.js +47 -0
  11. package/dist/lib/agent-kit/inbox-state.d.ts +6 -2
  12. package/dist/lib/agent-kit/inbox-state.js +6 -2
  13. package/dist/lib/agent-kit/mcp/attachments.d.ts +28 -1
  14. package/dist/lib/agent-kit/mcp/attachments.js +48 -2
  15. package/dist/lib/agent-kit/mcp/tools.js +13 -19
  16. package/dist/lib/bot/api.d.ts +9 -0
  17. package/dist/lib/bot/attachments.d.ts +45 -0
  18. package/dist/lib/bot/attachments.js +89 -0
  19. package/dist/lib/bot/paths.d.ts +5 -0
  20. package/dist/lib/bot/paths.js +7 -0
  21. package/dist/lib/bot/run.d.ts +7 -0
  22. package/dist/lib/bot/run.js +34 -4
  23. package/dist/lib/bot/runtime/claude.d.ts +1 -0
  24. package/dist/lib/bot/runtime/claude.js +6 -0
  25. package/dist/lib/bot/runtime/types.d.ts +8 -0
  26. package/dist/lib/lanes/mesh-events.d.ts +7 -3
  27. package/dist/lib/lanes/mesh-events.js +116 -20
  28. package/dist/lib/lanes/reconcile.js +55 -1
  29. package/dist/lib/lanes/watcher.js +27 -14
  30. package/dist/lib/mesh/api.d.ts +27 -1
  31. package/dist/lib/mesh/api.js +41 -12
  32. package/dist/lib/mesh/project-board.d.ts +1 -0
  33. package/dist/lib/mesh/project-board.js +1 -1
  34. package/package.json +1 -1
@@ -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. */
@@ -42,11 +42,11 @@
42
42
  * of opening a browser nobody is watching, and the watcher records
43
43
  * `mesh_event_failed` and moves on. No credential file is read here.
44
44
  */
45
- import type { LaneCompanyRef, LaneEnvelope, LaneRecord } from "./types.js";
46
- export declare const LANE_MESH_EVENTS: readonly ["lane_started", "lane_progress", "lane_question", "lane_question_at_user", "lane_ask", "lane_blocked", "lane_done"];
45
+ import type { LaneCompanyRef, LaneEnvelope, LaneRecord, TerminalLaneState } from "./types.js";
46
+ export declare const LANE_MESH_EVENTS: readonly ["lane_started", "lane_progress", "lane_question", "lane_question_at_user", "lane_ask", "lane_blocked", "lane_done", "lane_failed", "lane_interrupted", "lane_cancelled", "lane_killed"];
47
47
  export type LaneMeshEvent = (typeof LANE_MESH_EVENTS)[number];
48
48
  /** Roster kinds this module emits. Subset of the server's `validKinds`. */
49
- export declare const WIRE_EVENT_KINDS: readonly ["run_started", "run_progress", "run_complete", "question", "note"];
49
+ export declare const WIRE_EVENT_KINDS: readonly ["run_started", "run_progress", "run_complete", "blocked", "question", "note"];
50
50
  export type WireEventKind = (typeof WIRE_EVENT_KINDS)[number];
51
51
  /** Server caps: activity title/summary 2000, question text 500, note text 500. */
52
52
  export declare const WIRE_ACTIVITY_TEXT_MAX = 2000;
@@ -70,6 +70,7 @@ export declare function toWireThreadEvent(event: LaneMeshEvent, payload: Record<
70
70
  */
71
71
  export interface LaneMesh {
72
72
  ensureProjectThread: (company: LaneCompanyRef, projectId: string) => Promise<string>;
73
+ ensureLaneThread?: (company: LaneCompanyRef, lane: LaneRecord, laneStatus?: string, signal?: AbortSignal) => Promise<string>;
73
74
  appendThreadEvent: (company: LaneCompanyRef, threadId: string, event: LaneMeshEvent, payload: Record<string, unknown>, signal?: AbortSignal) => Promise<void>;
74
75
  patchStoryStatus: (company: LaneCompanyRef, projectId: string, storyId: string, status: string) => Promise<void>;
75
76
  }
@@ -92,6 +93,7 @@ export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
92
93
  export declare function laneMeshCompany(lane: Pick<LaneRecord, "company" | "mesh_company">): LaneCompanyRef | undefined;
93
94
  /** Company attribution + thread present. Unscoped personal lanes emit nothing. */
94
95
  export declare function laneCanEmit(lane: Pick<LaneRecord, "company" | "mesh_company" | "thread_id">): boolean;
96
+ export declare function laneMeshEventIdentity(lane: LaneRecord): Record<string, unknown>;
95
97
  /**
96
98
  * Spec §8 terminal-envelope events. `done` → lane_done and story → review;
97
99
  * `blocked` → lane_blocked; `ask` → lane_ask with the queue ids. The story is
@@ -101,6 +103,8 @@ export declare function emitLaneDecisionEvents(mesh: LaneMesh, lane: LaneRecord,
101
103
  questionIds?: string[];
102
104
  signal?: AbortSignal;
103
105
  }): Promise<void>;
106
+ /** Emit an abnormal terminal state even when the worker produced no envelope. */
107
+ export declare function emitLaneTerminalStateEvent(mesh: LaneMesh, lane: LaneRecord, state: Exclude<TerminalLaneState, "done">, summary?: string, signal?: AbortSignal): Promise<void>;
104
108
  export declare function emitLaneProgressEvent(mesh: LaneMesh, lane: LaneRecord, detail: string, signal?: AbortSignal): Promise<void>;
105
109
  /**
106
110
  * Throttle for lane_progress (spec §8 progress row). True when no progress