@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.
- package/CHANGELOG.md +39 -0
- package/dist/command-catalog.generated.d.ts +4 -1
- package/dist/command-catalog.generated.js +5 -1
- 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/commands/lanes.js +94 -18
- package/dist/commands/mesh.js +35 -8
- 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/dist/lib/lanes/mesh-events.d.ts +7 -3
- package/dist/lib/lanes/mesh-events.js +116 -20
- package/dist/lib/lanes/reconcile.js +55 -1
- package/dist/lib/lanes/watcher.js +27 -14
- package/dist/lib/mesh/api.d.ts +27 -1
- package/dist/lib/mesh/api.js +41 -12
- package/dist/lib/mesh/project-board.d.ts +1 -0
- package/dist/lib/mesh/project-board.js +1 -1
- 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
|
|
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. */
|
|
@@ -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
|