@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 +16 -0
- package/dist/commands/agent-mcp.d.ts +1 -3
- package/dist/commands/agent-mcp.js +5 -26
- package/dist/commands/bot.js +13 -2
- package/dist/lib/agent-kit/attachment-download.d.ts +24 -0
- package/dist/lib/agent-kit/attachment-download.js +47 -0
- package/dist/lib/agent-kit/inbox-state.d.ts +6 -2
- package/dist/lib/agent-kit/inbox-state.js +6 -2
- package/dist/lib/agent-kit/mcp/attachments.d.ts +28 -1
- package/dist/lib/agent-kit/mcp/attachments.js +48 -2
- package/dist/lib/agent-kit/mcp/tools.js +13 -19
- package/dist/lib/bot/api.d.ts +9 -0
- package/dist/lib/bot/attachments.d.ts +45 -0
- package/dist/lib/bot/attachments.js +89 -0
- package/dist/lib/bot/paths.d.ts +5 -0
- package/dist/lib/bot/paths.js +7 -0
- package/dist/lib/bot/run.d.ts +7 -0
- package/dist/lib/bot/run.js +34 -4
- package/dist/lib/bot/runtime/claude.d.ts +1 -0
- package/dist/lib/bot/runtime/claude.js +6 -0
- package/dist/lib/bot/runtime/types.d.ts +8 -0
- package/package.json +1 -1
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
|
-
|
|
82
|
-
|
|
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,
|
package/dist/commands/bot.js
CHANGED
|
@@ -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
|
|
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({
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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 {
|
|
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
|
|
299
|
-
const scopeUid = str(args, "company_uid", { required: true, max: 220 })
|
|
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
|
|
317
|
-
|
|
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
|
},
|
package/dist/lib/bot/api.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/bot/paths.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/bot/paths.js
CHANGED
|
@@ -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
|
}
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -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
|
|
1230
|
-
|
|
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} (${
|
|
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. */
|