@indigoai-us/hq-cli 5.117.2 → 5.118.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 +29 -0
- package/dist/command-catalog.generated.d.ts +105 -0
- package/dist/command-catalog.generated.js +132 -0
- package/dist/commands/agent-inbox.d.ts +9 -0
- package/dist/commands/agent-inbox.js +81 -0
- package/dist/commands/agent-kit.js +105 -0
- package/dist/commands/agent-mcp.d.ts +2 -1
- package/dist/commands/agent-mcp.js +4 -3
- package/dist/commands/agent.d.ts +1 -0
- package/dist/commands/agent.js +3 -0
- package/dist/lib/agent-kit/inbox-state.d.ts +34 -0
- package/dist/lib/agent-kit/inbox-state.js +86 -0
- package/dist/lib/agent-kit/mcp/tools.d.ts +3 -0
- package/dist/lib/agent-kit/mcp/tools.js +73 -22
- package/dist/lib/agent-kit/run/inbox.d.ts +5 -0
- package/dist/lib/agent-kit/run/inbox.js +21 -0
- package/dist/lib/agent-kit/skills.js +11 -8
- package/dist/lib/agent-kit/wake.d.ts +83 -0
- package/dist/lib/agent-kit/wake.js +236 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +1 -1
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +1 -1
- package/dist/lib/doctor/checks/claude-wiring.js +61 -1
- package/dist/lib/doctor/compat.js +1 -0
- package/dist/lib/doctor/fix/apply.js +21 -22
- package/dist/lib/doctor/fix/remediation.d.ts +7 -4
- package/dist/lib/doctor/fix/remediation.js +15 -7
- package/dist/lib/doctor/registry.js +43 -0
- package/dist/lib/doctor/stray-gate-entries.d.ts +35 -0
- package/dist/lib/doctor/stray-gate-entries.js +83 -0
- package/package.json +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local handled-state for the external agent's mirrored inbox.
|
|
3
|
+
*
|
|
4
|
+
* The inbox poller appends every new item to ~/.hq-agent/inbox/inbox.jsonl.
|
|
5
|
+
* A bot marks an item handled with `hq agent inbox done <id>` (or the
|
|
6
|
+
* `hq_inbox_done` MCP tool); ids land in done-ids.json. "Pending" is every
|
|
7
|
+
* mirrored item whose id is not done, oldest first. This works the same
|
|
8
|
+
* whether or not the kit acks on the server (`inboxAck`), so a woken bot can
|
|
9
|
+
* always drain exactly what it has not handled yet and never reply twice.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
export const DONE_IDS_NAME = "done-ids.json";
|
|
14
|
+
export const MAX_DONE_IDS = 5000;
|
|
15
|
+
const INBOX_JSONL = "inbox.jsonl";
|
|
16
|
+
export function readDoneIds(paths) {
|
|
17
|
+
try {
|
|
18
|
+
const raw = JSON.parse(fs.readFileSync(path.join(paths.inboxDir, DONE_IDS_NAME), "utf8"));
|
|
19
|
+
return new Set(Array.isArray(raw) ? raw.filter((x) => typeof x === "string") : []);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return new Set();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function markInboxDone(paths, ids) {
|
|
26
|
+
const done = readDoneIds(paths);
|
|
27
|
+
const added = [];
|
|
28
|
+
for (const id of ids) {
|
|
29
|
+
if (!done.has(id)) {
|
|
30
|
+
done.add(id);
|
|
31
|
+
added.push(id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
fs.mkdirSync(paths.inboxDir, { recursive: true, mode: 0o700 });
|
|
35
|
+
fs.writeFileSync(path.join(paths.inboxDir, DONE_IDS_NAME), JSON.stringify([...done].slice(-MAX_DONE_IDS)), { mode: 0o600 });
|
|
36
|
+
return added;
|
|
37
|
+
}
|
|
38
|
+
/** Every mirrored entry, oldest first, de-duplicated by id (last write wins). */
|
|
39
|
+
export function readMirroredInbox(paths) {
|
|
40
|
+
let text;
|
|
41
|
+
try {
|
|
42
|
+
text = fs.readFileSync(path.join(paths.inboxDir, INBOX_JSONL), "utf8");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const byId = new Map();
|
|
48
|
+
for (const line of text.split("\n")) {
|
|
49
|
+
if (!line.trim())
|
|
50
|
+
continue;
|
|
51
|
+
try {
|
|
52
|
+
const entry = JSON.parse(line);
|
|
53
|
+
if (typeof entry.id === "string" && entry.id) {
|
|
54
|
+
byId.delete(entry.id);
|
|
55
|
+
byId.set(entry.id, entry);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* skip a torn line */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...byId.values()];
|
|
63
|
+
}
|
|
64
|
+
export function pendingInbox(paths) {
|
|
65
|
+
const done = readDoneIds(paths);
|
|
66
|
+
return readMirroredInbox(paths).filter((e) => !done.has(e.id));
|
|
67
|
+
}
|
|
68
|
+
/** Compact view for bots: who, when, which channel, and the text. */
|
|
69
|
+
export function summarizeInboxEntry(e, done) {
|
|
70
|
+
const pick = (...keys) => {
|
|
71
|
+
for (const k of keys)
|
|
72
|
+
if (typeof e[k] === "string" && e[k])
|
|
73
|
+
return e[k];
|
|
74
|
+
return undefined;
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
id: e.id,
|
|
78
|
+
channel: pick("channel"),
|
|
79
|
+
from: pick("fromDisplayName", "fromEmail", "fromPersonUid") ?? "unknown",
|
|
80
|
+
fromUid: pick("fromPersonUid"),
|
|
81
|
+
at: pick("receivedAt", "createdAt", "mirroredAt"),
|
|
82
|
+
text: pick("text", "body"),
|
|
83
|
+
done,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=inbox-state.js.map
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* tool result.
|
|
17
17
|
*/
|
|
18
18
|
import type { ExternalMachineCreds } from "../creds.js";
|
|
19
|
+
import type { AgentKitPaths } from "../paths.js";
|
|
19
20
|
import { type McpTool } from "./jsonrpc.js";
|
|
20
21
|
export interface HqRunResult {
|
|
21
22
|
code: number;
|
|
@@ -24,6 +25,8 @@ export interface HqRunResult {
|
|
|
24
25
|
}
|
|
25
26
|
export interface McpToolClients {
|
|
26
27
|
creds: ExternalMachineCreds;
|
|
28
|
+
/** Kit layout; the inbox tools read the mirror and done-state here. */
|
|
29
|
+
paths: Pick<AgentKitPaths, "inboxDir">;
|
|
27
30
|
/** Run `hq <args…>` as the machine identity; never throws. */
|
|
28
31
|
runHq: (args: string[]) => Promise<HqRunResult>;
|
|
29
32
|
getToken: () => Promise<string>;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { CLI_VERSION } from "../../../cli-version.js";
|
|
19
19
|
import { peekIdToken } from "../../../utils/id-token.js";
|
|
20
|
+
import { markInboxDone, readDoneIds, readMirroredInbox, summarizeInboxEntry } from "../inbox-state.js";
|
|
20
21
|
import { errorResult, McpToolInputError, textResult } from "./jsonrpc.js";
|
|
21
22
|
export const MAX_TOOL_OUTPUT_CHARS = 40_000;
|
|
22
23
|
function str(args, key, opts = {}) {
|
|
@@ -226,37 +227,87 @@ export function buildAgentMcpTools(clients) {
|
|
|
226
227
|
},
|
|
227
228
|
{
|
|
228
229
|
name: "hq_inbox_read",
|
|
229
|
-
description: "Read this agent's
|
|
230
|
+
description: "Read this agent's HQ inbox (DMs, channel mentions, jobs), oldest first. By default only items not yet " +
|
|
231
|
+
"marked handled. Reply with hq_dm_send, then mark each one handled with hq_inbox_done so it is never answered twice.",
|
|
230
232
|
inputSchema: {
|
|
231
233
|
type: "object",
|
|
232
234
|
properties: {
|
|
233
235
|
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
234
|
-
|
|
236
|
+
include_done: { type: "boolean", description: "Also return items already marked handled" },
|
|
237
|
+
unread_only: { type: "boolean", description: "Deprecated alias: true is the default behaviour" },
|
|
235
238
|
},
|
|
236
239
|
additionalProperties: false,
|
|
237
240
|
},
|
|
238
241
|
handler: async (args) => {
|
|
239
242
|
const limit = int(args, "limit", { min: 1, max: 100, fallback: 20 });
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
243
|
+
const includeDone = args.include_done === true;
|
|
244
|
+
const done = readDoneIds(clients.paths);
|
|
245
|
+
// The kit mirror is the source of truth; merge the agent's own server
|
|
246
|
+
// inbox so items the poller has not mirrored yet are not missed.
|
|
247
|
+
const byId = new Map();
|
|
248
|
+
for (const e of readMirroredInbox(clients.paths))
|
|
249
|
+
byId.set(e.id, e);
|
|
250
|
+
let serverNote;
|
|
251
|
+
try {
|
|
252
|
+
const token = await clients.getToken();
|
|
253
|
+
const res = await clients.apiJson(token, `/v1/agents/${encodeURIComponent(creds.entityUid)}/inbox`);
|
|
254
|
+
if (res.status === 200) {
|
|
255
|
+
const messages = res.body?.messages;
|
|
256
|
+
for (const m of Array.isArray(messages) ? messages : []) {
|
|
257
|
+
if (!m || typeof m !== "object")
|
|
258
|
+
continue;
|
|
259
|
+
const raw = m;
|
|
260
|
+
const id = typeof raw.id === "string" ? raw.id : typeof raw.messageId === "string" ? raw.messageId : null;
|
|
261
|
+
if (id && !byId.has(id))
|
|
262
|
+
byId.set(id, { ...raw, id });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
serverNote = `server inbox answered ${res.status}: ${describeError(res.body)}; showing the local mirror only`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
serverNote = `server inbox unavailable (${err instanceof Error ? err.message : String(err)}); showing the local mirror only`;
|
|
271
|
+
}
|
|
272
|
+
const rows = [...byId.values()]
|
|
273
|
+
.filter((e) => includeDone || !done.has(e.id))
|
|
274
|
+
.slice(0, limit)
|
|
275
|
+
.map((e) => summarizeInboxEntry(e, done.has(e.id)));
|
|
276
|
+
return textResult(clampOutput(JSON.stringify({ count: rows.length, messages: rows, ...(serverNote ? { note: serverNote } : {}) }, null, 2)));
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: "hq_inbox_done",
|
|
281
|
+
description: "Mark inbox items handled (after replying) so they stop showing as pending and the wake hook stops retrying.",
|
|
282
|
+
inputSchema: {
|
|
283
|
+
type: "object",
|
|
284
|
+
properties: { ids: { type: "array", items: { type: "string" }, description: "Item ids from hq_inbox_read" } },
|
|
285
|
+
required: ["ids"],
|
|
286
|
+
additionalProperties: false,
|
|
287
|
+
},
|
|
288
|
+
handler: async (args) => {
|
|
289
|
+
const ids = strList(args, "ids");
|
|
290
|
+
for (const id of ids) {
|
|
291
|
+
if (!/^[A-Za-z0-9._:-]{1,200}$/.test(id))
|
|
292
|
+
throw new McpToolInputError(`invalid id ${JSON.stringify(id)}`);
|
|
293
|
+
}
|
|
294
|
+
const added = markInboxDone(clients.paths, ids);
|
|
295
|
+
const acked = [];
|
|
296
|
+
const failed = [];
|
|
297
|
+
try {
|
|
298
|
+
const token = await clients.getToken();
|
|
299
|
+
for (const id of ids) {
|
|
300
|
+
const res = await clients.apiJson(token, `/v1/agents/${encodeURIComponent(creds.entityUid)}/inbox/${encodeURIComponent(id)}/ack`, { method: "POST" });
|
|
301
|
+
if (res.status >= 200 && res.status < 300)
|
|
302
|
+
acked.push(id);
|
|
303
|
+
else
|
|
304
|
+
failed.push(`${id} (${res.status})`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
failed.push(`ack skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
309
|
+
}
|
|
310
|
+
return textResult(JSON.stringify({ marked: added.length, alreadyDone: ids.length - added.length, serverAcked: acked.length, ...(failed.length ? { ackFailures: failed } : {}) }, null, 2));
|
|
260
311
|
},
|
|
261
312
|
},
|
|
262
313
|
{
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { KitLogger } from "../log.js";
|
|
19
19
|
import type { AgentKitPaths } from "../paths.js";
|
|
20
|
+
import { type WakeConfig, type WakeDeps, type WakeNotice, type WakeResult } from "../wake.js";
|
|
20
21
|
export declare const SEEN_IDS_NAME = "seen-ids.json";
|
|
21
22
|
export declare const INBOX_JSONL_NAME = "inbox.jsonl";
|
|
22
23
|
export declare const MAX_SEEN_IDS = 5000;
|
|
@@ -42,6 +43,10 @@ export interface InboxDeps {
|
|
|
42
43
|
warmCache?: (token: string, agentUid: string) => Promise<unknown>;
|
|
43
44
|
sleep?: (ms: number) => Promise<void>;
|
|
44
45
|
maxPolls?: number;
|
|
46
|
+
/** Override wake config lookup (tests). `null` disables the wake. */
|
|
47
|
+
wakeConfig?: WakeConfig | null;
|
|
48
|
+
fireWake?: (config: WakeConfig, notice: WakeNotice, deps: WakeDeps) => Promise<WakeResult>;
|
|
49
|
+
now?: () => Date;
|
|
45
50
|
}
|
|
46
51
|
export declare const INBOX_ERROR_BODY_MAX = 300;
|
|
47
52
|
export interface InboxFetchResult {
|
|
@@ -19,6 +19,8 @@ import * as fs from "node:fs";
|
|
|
19
19
|
import * as path from "node:path";
|
|
20
20
|
import { vaultApiFetch } from "../../../utils/vault-api.js";
|
|
21
21
|
import { writeComponentStatus } from "../creds.js";
|
|
22
|
+
import { pendingInbox } from "../inbox-state.js";
|
|
23
|
+
import { buildWakeNotice, currentWakeState, fireWake, readWakeConfig, shouldWake, } from "../wake.js";
|
|
22
24
|
export const SEEN_IDS_NAME = "seen-ids.json";
|
|
23
25
|
export const INBOX_JSONL_NAME = "inbox.jsonl";
|
|
24
26
|
export const MAX_SEEN_IDS = 5000;
|
|
@@ -168,8 +170,27 @@ export async function pollInboxOnce(deps) {
|
|
|
168
170
|
deps.log("warn", `cache refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
169
171
|
}
|
|
170
172
|
}
|
|
173
|
+
await maybeWake(deps, fresh.length);
|
|
171
174
|
return { ok: true, mirrored: fresh.length };
|
|
172
175
|
}
|
|
176
|
+
/** Poke the bot's framework when mail is new, or still pending past the retry window. */
|
|
177
|
+
async function maybeWake(deps, freshCount) {
|
|
178
|
+
const config = deps.wakeConfig !== undefined ? deps.wakeConfig : readWakeConfig(deps.paths);
|
|
179
|
+
if (!config)
|
|
180
|
+
return;
|
|
181
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
182
|
+
const pending = pendingInbox(deps.paths);
|
|
183
|
+
const reason = shouldWake(config, currentWakeState(deps.paths), freshCount, pending.length, now);
|
|
184
|
+
if (!reason)
|
|
185
|
+
return;
|
|
186
|
+
const notice = buildWakeNotice(deps.agentUid, reason, pending.map((e) => e.id), now);
|
|
187
|
+
try {
|
|
188
|
+
await (deps.fireWake ?? fireWake)(config, notice, { paths: deps.paths, agentUid: deps.agentUid, log: deps.log, now: deps.now });
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
deps.log("warn", `wake failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
173
194
|
export async function runInboxLoop(deps) {
|
|
174
195
|
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
175
196
|
let polls = 0;
|
|
@@ -25,18 +25,21 @@ hq dm <uid1>,<uid2> "message text" # group DM
|
|
|
25
25
|
hq dm '#channel-name' "message text" # channel
|
|
26
26
|
\`\`\`
|
|
27
27
|
|
|
28
|
-
## Read
|
|
28
|
+
## Read and handle incoming messages
|
|
29
|
+
|
|
30
|
+
The kit's inbox poller mirrors every message addressed to you (DMs, channel
|
|
31
|
+
mentions, jobs). Work through them like this:
|
|
29
32
|
|
|
30
33
|
\`\`\`bash
|
|
31
|
-
hq
|
|
32
|
-
hq dm
|
|
33
|
-
hq
|
|
34
|
-
hq dm channel <name> # read a channel
|
|
34
|
+
hq agent inbox # pending messages, oldest first (id, sender uid, text)
|
|
35
|
+
hq dm <sender-uid> "reply" # answer each one
|
|
36
|
+
hq agent inbox done <id> [id…] # mark handled so it is never answered twice
|
|
35
37
|
\`\`\`
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
The MCP equivalents are \`hq_inbox_read\`, \`hq_dm_send\` and \`hq_inbox_done\`.
|
|
40
|
+
If the kit has a wake configured (\`hq agent kit wake show\`), HQ pokes your
|
|
41
|
+
framework whenever something is pending, so always drain the whole list and
|
|
42
|
+
mark each item done.
|
|
40
43
|
|
|
41
44
|
## Rules
|
|
42
45
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wake hook: tell the bot's own framework that HQ mail is waiting.
|
|
3
|
+
*
|
|
4
|
+
* Hosted agents are woken by the box watcher, which runs the model per inbox
|
|
5
|
+
* item. An external bot brings its own brain, so the kit cannot run it, but
|
|
6
|
+
* it can poke it. Two kinds, stored in ~/.hq-agent/wake.json (0600):
|
|
7
|
+
*
|
|
8
|
+
* url POST a small JSON notice to a URL the framework gives you, such
|
|
9
|
+
* as a Grok Bot routine webhook. The URL is treated as a secret:
|
|
10
|
+
* it is never logged or printed in full.
|
|
11
|
+
* command Run a local command (argv, no shell) for frameworks with a CLI
|
|
12
|
+
* (such as OpenClaw). The notice is written to its stdin. One run
|
|
13
|
+
* at a time; mail that arrives mid-run triggers one more run after.
|
|
14
|
+
*
|
|
15
|
+
* The notice never carries message text. It only says how many items are
|
|
16
|
+
* pending and their ids; the bot reads them through HQ (`hq agent inbox`).
|
|
17
|
+
*
|
|
18
|
+
* The inbox poller fires the wake when new items are mirrored, and re-fires
|
|
19
|
+
* every `retryMs` while items stay pending, so a missed webhook heals.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import type { KitLogger } from "./log.js";
|
|
23
|
+
import type { AgentKitPaths } from "./paths.js";
|
|
24
|
+
export declare const WAKE_FILE_NAME = "wake.json";
|
|
25
|
+
export declare const WAKE_STATE_NAME = "wake-state.json";
|
|
26
|
+
export declare const DEFAULT_WAKE_RETRY_MS: number;
|
|
27
|
+
export declare const WAKE_URL_TIMEOUT_MS = 15000;
|
|
28
|
+
export declare const DEFAULT_WAKE_COMMAND_TIMEOUT_MS: number;
|
|
29
|
+
export type WakeConfig = {
|
|
30
|
+
kind: "url";
|
|
31
|
+
url: string;
|
|
32
|
+
retryMs: number;
|
|
33
|
+
} | {
|
|
34
|
+
kind: "command";
|
|
35
|
+
command: string[];
|
|
36
|
+
timeoutMs: number;
|
|
37
|
+
retryMs: number;
|
|
38
|
+
};
|
|
39
|
+
export interface WakeNotice {
|
|
40
|
+
event: "hq.inbox.pending";
|
|
41
|
+
agentUid: string;
|
|
42
|
+
reason: "new" | "retry" | "test";
|
|
43
|
+
pending: number;
|
|
44
|
+
messageIds: string[];
|
|
45
|
+
at: string;
|
|
46
|
+
instructions: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const WAKE_INSTRUCTIONS: string;
|
|
49
|
+
export declare function wakePath(paths: Pick<AgentKitPaths, "agentDir">): string;
|
|
50
|
+
export declare function validateWakeUrl(raw: string): string;
|
|
51
|
+
/** Origin plus a masked path: enough to recognise, not enough to call. */
|
|
52
|
+
export declare function redactWakeUrl(url: string): string;
|
|
53
|
+
export declare function describeWake(config: WakeConfig | null): string;
|
|
54
|
+
export declare function readWakeConfig(paths: Pick<AgentKitPaths, "agentDir">): WakeConfig | null;
|
|
55
|
+
export declare function writeWakeConfig(paths: Pick<AgentKitPaths, "agentDir">, config: WakeConfig): void;
|
|
56
|
+
export declare function clearWakeConfig(paths: Pick<AgentKitPaths, "agentDir">): boolean;
|
|
57
|
+
export interface WakeDeps {
|
|
58
|
+
paths: Pick<AgentKitPaths, "agentDir" | "stateDir" | "logsDir">;
|
|
59
|
+
agentUid: string;
|
|
60
|
+
log: KitLogger;
|
|
61
|
+
now?: () => Date;
|
|
62
|
+
fetchImpl?: typeof fetch;
|
|
63
|
+
spawnImpl?: typeof spawn;
|
|
64
|
+
pidAlive?: (pid: number | undefined) => boolean;
|
|
65
|
+
}
|
|
66
|
+
export declare function buildWakeNotice(agentUid: string, reason: WakeNotice["reason"], ids: string[], now: Date): WakeNotice;
|
|
67
|
+
/**
|
|
68
|
+
* Decide whether to wake now: always on new mail, otherwise only when items
|
|
69
|
+
* are still pending and the last wake is older than retryMs.
|
|
70
|
+
*/
|
|
71
|
+
export declare function shouldWake(config: WakeConfig, state: {
|
|
72
|
+
lastWakeAt?: string;
|
|
73
|
+
}, freshCount: number, pendingCount: number, now: Date): WakeNotice["reason"] | null;
|
|
74
|
+
export interface WakeResult {
|
|
75
|
+
fired: boolean;
|
|
76
|
+
ok: boolean;
|
|
77
|
+
detail: string;
|
|
78
|
+
}
|
|
79
|
+
export declare function fireWake(config: WakeConfig, notice: WakeNotice, deps: WakeDeps): Promise<WakeResult>;
|
|
80
|
+
export declare function currentWakeState(paths: Pick<AgentKitPaths, "stateDir">): {
|
|
81
|
+
lastWakeAt?: string;
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=wake.d.ts.map
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wake hook: tell the bot's own framework that HQ mail is waiting.
|
|
3
|
+
*
|
|
4
|
+
* Hosted agents are woken by the box watcher, which runs the model per inbox
|
|
5
|
+
* item. An external bot brings its own brain, so the kit cannot run it, but
|
|
6
|
+
* it can poke it. Two kinds, stored in ~/.hq-agent/wake.json (0600):
|
|
7
|
+
*
|
|
8
|
+
* url POST a small JSON notice to a URL the framework gives you, such
|
|
9
|
+
* as a Grok Bot routine webhook. The URL is treated as a secret:
|
|
10
|
+
* it is never logged or printed in full.
|
|
11
|
+
* command Run a local command (argv, no shell) for frameworks with a CLI
|
|
12
|
+
* (such as OpenClaw). The notice is written to its stdin. One run
|
|
13
|
+
* at a time; mail that arrives mid-run triggers one more run after.
|
|
14
|
+
*
|
|
15
|
+
* The notice never carries message text. It only says how many items are
|
|
16
|
+
* pending and their ids; the bot reads them through HQ (`hq agent inbox`).
|
|
17
|
+
*
|
|
18
|
+
* The inbox poller fires the wake when new items are mirrored, and re-fires
|
|
19
|
+
* every `retryMs` while items stay pending, so a missed webhook heals.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
export const WAKE_FILE_NAME = "wake.json";
|
|
25
|
+
export const WAKE_STATE_NAME = "wake-state.json";
|
|
26
|
+
export const DEFAULT_WAKE_RETRY_MS = 10 * 60_000;
|
|
27
|
+
export const WAKE_URL_TIMEOUT_MS = 15_000;
|
|
28
|
+
export const DEFAULT_WAKE_COMMAND_TIMEOUT_MS = 10 * 60_000;
|
|
29
|
+
export const WAKE_INSTRUCTIONS = "New HQ messages are waiting. Run `hq agent inbox` (or the hq_inbox_read tool), reply to each " +
|
|
30
|
+
"with `hq dm <sender-uid> \"…\"` (or hq_dm_send), then mark each handled with `hq agent inbox done <id>` (or hq_inbox_done).";
|
|
31
|
+
export function wakePath(paths) {
|
|
32
|
+
return path.join(paths.agentDir, WAKE_FILE_NAME);
|
|
33
|
+
}
|
|
34
|
+
function wakeStatePath(paths) {
|
|
35
|
+
return path.join(paths.stateDir, WAKE_STATE_NAME);
|
|
36
|
+
}
|
|
37
|
+
export function validateWakeUrl(raw) {
|
|
38
|
+
const url = raw.trim();
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = new URL(url);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error("wake URL is not a valid URL");
|
|
45
|
+
}
|
|
46
|
+
const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
|
|
47
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && local)) {
|
|
48
|
+
throw new Error("wake URL must use https (http is allowed only for localhost)");
|
|
49
|
+
}
|
|
50
|
+
if (parsed.username || parsed.password)
|
|
51
|
+
throw new Error("wake URL must not embed a username or password");
|
|
52
|
+
return url;
|
|
53
|
+
}
|
|
54
|
+
/** Origin plus a masked path: enough to recognise, not enough to call. */
|
|
55
|
+
export function redactWakeUrl(url) {
|
|
56
|
+
try {
|
|
57
|
+
const u = new URL(url);
|
|
58
|
+
return `${u.origin}/…`;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return "(invalid url)";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function describeWake(config) {
|
|
65
|
+
if (!config)
|
|
66
|
+
return "not configured";
|
|
67
|
+
return config.kind === "url"
|
|
68
|
+
? `url ${redactWakeUrl(config.url)}`
|
|
69
|
+
: `command ${config.command[0]}${config.command.length > 1 ? " …" : ""}`;
|
|
70
|
+
}
|
|
71
|
+
export function readWakeConfig(paths) {
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = JSON.parse(fs.readFileSync(wakePath(paths), "utf8"));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const retryMs = typeof raw.retryMs === "number" && raw.retryMs >= 60_000 ? raw.retryMs : DEFAULT_WAKE_RETRY_MS;
|
|
80
|
+
if (raw.kind === "url" && typeof raw.url === "string") {
|
|
81
|
+
try {
|
|
82
|
+
return { kind: "url", url: validateWakeUrl(raw.url), retryMs };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (raw.kind === "command" && Array.isArray(raw.command) && raw.command.length > 0 && raw.command.every((x) => typeof x === "string" && x)) {
|
|
89
|
+
const timeoutMs = typeof raw.timeoutMs === "number" && raw.timeoutMs > 0 ? raw.timeoutMs : DEFAULT_WAKE_COMMAND_TIMEOUT_MS;
|
|
90
|
+
return { kind: "command", command: raw.command, timeoutMs, retryMs };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
export function writeWakeConfig(paths, config) {
|
|
95
|
+
fs.mkdirSync(paths.agentDir, { recursive: true, mode: 0o700 });
|
|
96
|
+
const file = wakePath(paths);
|
|
97
|
+
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
98
|
+
fs.chmodSync(file, 0o600);
|
|
99
|
+
}
|
|
100
|
+
export function clearWakeConfig(paths) {
|
|
101
|
+
try {
|
|
102
|
+
fs.unlinkSync(wakePath(paths));
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function readWakeState(paths) {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(fs.readFileSync(wakeStatePath(paths), "utf8"));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function writeWakeState(paths, state) {
|
|
118
|
+
fs.mkdirSync(paths.stateDir, { recursive: true, mode: 0o700 });
|
|
119
|
+
fs.writeFileSync(wakeStatePath(paths), JSON.stringify(state), { mode: 0o600 });
|
|
120
|
+
}
|
|
121
|
+
function pidAlive(pid) {
|
|
122
|
+
if (!pid)
|
|
123
|
+
return false;
|
|
124
|
+
try {
|
|
125
|
+
process.kill(pid, 0);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export function buildWakeNotice(agentUid, reason, ids, now) {
|
|
133
|
+
return {
|
|
134
|
+
event: "hq.inbox.pending",
|
|
135
|
+
agentUid,
|
|
136
|
+
reason,
|
|
137
|
+
pending: ids.length,
|
|
138
|
+
messageIds: ids.slice(0, 50),
|
|
139
|
+
at: now.toISOString(),
|
|
140
|
+
instructions: WAKE_INSTRUCTIONS,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Decide whether to wake now: always on new mail, otherwise only when items
|
|
145
|
+
* are still pending and the last wake is older than retryMs.
|
|
146
|
+
*/
|
|
147
|
+
export function shouldWake(config, state, freshCount, pendingCount, now) {
|
|
148
|
+
if (pendingCount === 0)
|
|
149
|
+
return null;
|
|
150
|
+
if (freshCount > 0)
|
|
151
|
+
return "new";
|
|
152
|
+
const last = state.lastWakeAt ? Date.parse(state.lastWakeAt) : NaN;
|
|
153
|
+
if (Number.isNaN(last) || now.getTime() - last >= config.retryMs)
|
|
154
|
+
return "retry";
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
export async function fireWake(config, notice, deps) {
|
|
158
|
+
const now = deps.now ?? (() => new Date());
|
|
159
|
+
const state = readWakeState(deps.paths);
|
|
160
|
+
const alive = deps.pidAlive ?? pidAlive;
|
|
161
|
+
if (config.kind === "url") {
|
|
162
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
const timer = setTimeout(() => controller.abort(), WAKE_URL_TIMEOUT_MS);
|
|
165
|
+
const target = redactWakeUrl(config.url);
|
|
166
|
+
try {
|
|
167
|
+
const res = await doFetch(config.url, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "content-type": "application/json", "user-agent": "hq-agent-kit" },
|
|
170
|
+
body: JSON.stringify(notice),
|
|
171
|
+
signal: controller.signal,
|
|
172
|
+
});
|
|
173
|
+
writeWakeState(deps.paths, { ...state, lastWakeAt: now().toISOString() });
|
|
174
|
+
const ok = res.status >= 200 && res.status < 300;
|
|
175
|
+
const detail = `wake url ${target} → ${res.status} (${notice.reason}, ${notice.pending} pending)`;
|
|
176
|
+
deps.log(ok ? "info" : "warn", detail);
|
|
177
|
+
return { fired: true, ok, detail };
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
const detail = `wake url ${target} failed: ${err instanceof Error ? err.name === "AbortError" ? "timeout" : err.message : String(err)}`;
|
|
181
|
+
deps.log("warn", detail);
|
|
182
|
+
return { fired: true, ok: false, detail };
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (alive(state.runningPid)) {
|
|
189
|
+
writeWakeState(deps.paths, { ...state, rerun: true });
|
|
190
|
+
const detail = `wake command still running (pid ${state.runningPid}); will run again after it exits`;
|
|
191
|
+
deps.log("info", detail);
|
|
192
|
+
return { fired: false, ok: true, detail };
|
|
193
|
+
}
|
|
194
|
+
const doSpawn = deps.spawnImpl ?? spawn;
|
|
195
|
+
const logFd = fs.openSync(path.join(deps.paths.logsDir, "wake.log"), "a", 0o600);
|
|
196
|
+
let child;
|
|
197
|
+
try {
|
|
198
|
+
child = doSpawn(config.command[0], config.command.slice(1), {
|
|
199
|
+
stdio: ["pipe", logFd, logFd],
|
|
200
|
+
env: {
|
|
201
|
+
...process.env,
|
|
202
|
+
HQ_WAKE_EVENT: notice.event,
|
|
203
|
+
HQ_WAKE_REASON: notice.reason,
|
|
204
|
+
HQ_WAKE_PENDING: String(notice.pending),
|
|
205
|
+
HQ_AGENT_UID: notice.agentUid,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
fs.closeSync(logFd);
|
|
211
|
+
const detail = `wake command failed to start: ${err instanceof Error ? err.message : String(err)}`;
|
|
212
|
+
deps.log("warn", detail);
|
|
213
|
+
return { fired: false, ok: false, detail };
|
|
214
|
+
}
|
|
215
|
+
fs.closeSync(logFd);
|
|
216
|
+
child.stdin?.end(`${JSON.stringify(notice)}\n`);
|
|
217
|
+
writeWakeState(deps.paths, { lastWakeAt: now().toISOString(), runningPid: child.pid, rerun: false });
|
|
218
|
+
const timer = setTimeout(() => child.kill("SIGTERM"), config.timeoutMs);
|
|
219
|
+
const detail = `wake command started pid ${child.pid} (${notice.reason}, ${notice.pending} pending)`;
|
|
220
|
+
deps.log("info", detail);
|
|
221
|
+
child.on("error", (err) => deps.log("warn", `wake command error: ${err.message}`));
|
|
222
|
+
child.on("exit", (code, signal) => {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
const after = readWakeState(deps.paths);
|
|
225
|
+
writeWakeState(deps.paths, { lastWakeAt: after.lastWakeAt, rerun: false });
|
|
226
|
+
deps.log(code === 0 ? "info" : "warn", `wake command exited ${signal ?? code}`);
|
|
227
|
+
if (after.rerun) {
|
|
228
|
+
void fireWake(config, { ...notice, reason: "new", at: now().toISOString() }, deps);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
return { fired: true, ok: true, detail };
|
|
232
|
+
}
|
|
233
|
+
export function currentWakeState(paths) {
|
|
234
|
+
return { lastWakeAt: readWakeState(paths).lastWakeAt };
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=wake.js.map
|
|
@@ -74,7 +74,7 @@ export interface FakeHookSpec {
|
|
|
74
74
|
mode?: number;
|
|
75
75
|
/** Whether the hook is registered in `.claude/settings.json`. Default: true. */
|
|
76
76
|
registered?: boolean;
|
|
77
|
-
/** Events the hook registers against. Default:
|
|
77
|
+
/** Events the hook registers against. Default: SessionStart and PreToolUse. */
|
|
78
78
|
events?: HookEventName[];
|
|
79
79
|
/** Optional settings matcher (e.g. "Bash", "Glob"). */
|
|
80
80
|
matcher?: string;
|
|
@@ -166,7 +166,7 @@ function writeHook(claudeHooksDir, codexHooksDir, hook, defaultMirror) {
|
|
|
166
166
|
const claudeBody = hook.body ?? DEFAULT_HOOK_BODY;
|
|
167
167
|
const present = hook.present !== false;
|
|
168
168
|
const registered = hook.registered !== false;
|
|
169
|
-
const events = hook.events ?? ["PreToolUse"];
|
|
169
|
+
const events = hook.events ?? ["SessionStart", "PreToolUse"];
|
|
170
170
|
const profiles = hook.profiles ?? [...GATE_PROFILES];
|
|
171
171
|
const scriptPath = path.join(claudeHooksDir, `${hook.id}.sh`);
|
|
172
172
|
let mode = null;
|