@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.
@@ -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 recent incoming direct messages (newest first). Optionally only unread.",
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
- unread_only: { type: "boolean" },
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 unreadOnly = args.unread_only === true;
241
- const token = await clients.getToken();
242
- const res = await clients.apiJson(token, "/v1/notify/inbox", { query: { limit: String(limit) } });
243
- if (res.status !== 200)
244
- return errorResult(`inbox read failed (${res.status}): ${describeError(res.body)}`);
245
- const events = Array.isArray(res.body?.events)
246
- ? (res.body.events)
247
- : [];
248
- const rows = events
249
- .filter((e) => !unreadOnly || !e.acknowledgedAt)
250
- .map((e) => ({
251
- eventId: e.eventId,
252
- from: e.fromDisplayName ?? e.fromEmail ?? e.fromPersonUid ?? "unknown",
253
- fromUid: e.fromPersonUid,
254
- at: e.createdAt,
255
- unread: !e.acknowledgedAt,
256
- body: e.body,
257
- ...(e.prompt ? { prompt: e.prompt } : {}),
258
- }));
259
- return textResult(clampOutput(JSON.stringify({ count: rows.length, messages: rows }, null, 2)));
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 dm inbox --unread # unread DMs addressed to you
32
- hq dm thread <email-or-uid> # the 1:1 conversation
33
- hq dm inbox --mark-read # mark what you have read
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
- New inbound messages are also mirrored by the kit's inbox poller to
38
- \`~/.hq-agent/inbox/<id>.json\` (and \`inbox.jsonl\`) check there when
39
- you are asked whether anyone has messaged you.
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: ["PreToolUse"]. */
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;