@indigoai-us/hq-cli 5.117.3 → 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 +12 -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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.118.0] — 2026-09-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- External agents can now be woken the moment an HQ message arrives. `hq agent kit wake set-url --stdin` points the kit at a webhook, such as a Grok Bot routine webhook trigger. `hq agent kit wake set-command -- <cmd>` runs a local command for frameworks with a CLI. The kit sends a short notice with no message text when mail is pending, and again every 10 minutes until it is handled. The webhook URL is stored privately and never logged.
|
|
10
|
+
- `hq agent inbox` lists an external agent's pending HQ messages and `hq agent inbox done <id>` marks them handled, so a woken bot never answers the same message twice. The MCP server gains a matching `hq_inbox_done` tool.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- The `hq_inbox_read` MCP tool no longer fails with 403 AGENT_ROUTE_NOT_SUPPORTED. It reads the agent's own inbox instead of the person-only DM inbox, and by default returns only messages not yet handled.
|
|
15
|
+
- The HQ direct-message skill shipped to external agents no longer tells them to use person-only read commands.
|
|
16
|
+
|
|
5
17
|
## [5.117.3] — 2026-09-16
|
|
6
18
|
|
|
7
19
|
### Fixed
|
|
@@ -4218,6 +4218,83 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
4218
4218
|
readonly description: "Print machine-readable JSON";
|
|
4219
4219
|
}];
|
|
4220
4220
|
readonly subcommands: readonly [];
|
|
4221
|
+
}, {
|
|
4222
|
+
readonly name: "wake";
|
|
4223
|
+
readonly description: "Wake this bot's own framework when HQ messages arrive (webhook URL or local command)";
|
|
4224
|
+
readonly aliases: readonly [];
|
|
4225
|
+
readonly hidden: false;
|
|
4226
|
+
readonly usage: "[options] [command]";
|
|
4227
|
+
readonly arguments: readonly [];
|
|
4228
|
+
readonly options: readonly [];
|
|
4229
|
+
readonly subcommands: readonly [{
|
|
4230
|
+
readonly name: "set-url";
|
|
4231
|
+
readonly description: "POST a notice to a webhook URL (e.g. a Grok Bot routine webhook) when mail is pending. Prefer --stdin so the URL stays out of shell history";
|
|
4232
|
+
readonly aliases: readonly [];
|
|
4233
|
+
readonly hidden: false;
|
|
4234
|
+
readonly usage: "[options] [url]";
|
|
4235
|
+
readonly arguments: readonly [{
|
|
4236
|
+
readonly name: "url";
|
|
4237
|
+
readonly required: false;
|
|
4238
|
+
readonly variadic: false;
|
|
4239
|
+
}];
|
|
4240
|
+
readonly options: readonly [{
|
|
4241
|
+
readonly flags: "--stdin";
|
|
4242
|
+
readonly description: "Read the URL from stdin";
|
|
4243
|
+
}, {
|
|
4244
|
+
readonly flags: "--retry-minutes <n>";
|
|
4245
|
+
readonly description: "Re-wake while items stay pending (default 10, minimum 1)";
|
|
4246
|
+
}];
|
|
4247
|
+
readonly subcommands: readonly [];
|
|
4248
|
+
}, {
|
|
4249
|
+
readonly name: "set-command";
|
|
4250
|
+
readonly description: "Run a local command (no shell) when mail is pending; the notice JSON arrives on stdin. Use -- before the command";
|
|
4251
|
+
readonly aliases: readonly [];
|
|
4252
|
+
readonly hidden: false;
|
|
4253
|
+
readonly usage: "[options] <argv...>";
|
|
4254
|
+
readonly arguments: readonly [{
|
|
4255
|
+
readonly name: "argv";
|
|
4256
|
+
readonly required: true;
|
|
4257
|
+
readonly variadic: true;
|
|
4258
|
+
}];
|
|
4259
|
+
readonly options: readonly [{
|
|
4260
|
+
readonly flags: "--timeout-minutes <n>";
|
|
4261
|
+
readonly description: "Kill a run after this long (default 10)";
|
|
4262
|
+
}, {
|
|
4263
|
+
readonly flags: "--retry-minutes <n>";
|
|
4264
|
+
readonly description: "Re-wake while items stay pending (default 10, minimum 1)";
|
|
4265
|
+
}];
|
|
4266
|
+
readonly subcommands: readonly [];
|
|
4267
|
+
}, {
|
|
4268
|
+
readonly name: "show";
|
|
4269
|
+
readonly description: "Show the configured wake (the URL is masked)";
|
|
4270
|
+
readonly aliases: readonly [];
|
|
4271
|
+
readonly hidden: false;
|
|
4272
|
+
readonly usage: "[options]";
|
|
4273
|
+
readonly arguments: readonly [];
|
|
4274
|
+
readonly options: readonly [{
|
|
4275
|
+
readonly flags: "--json";
|
|
4276
|
+
readonly description: "Print machine-readable JSON";
|
|
4277
|
+
}];
|
|
4278
|
+
readonly subcommands: readonly [];
|
|
4279
|
+
}, {
|
|
4280
|
+
readonly name: "clear";
|
|
4281
|
+
readonly description: "Remove the wake configuration";
|
|
4282
|
+
readonly aliases: readonly [];
|
|
4283
|
+
readonly hidden: false;
|
|
4284
|
+
readonly usage: "[options]";
|
|
4285
|
+
readonly arguments: readonly [];
|
|
4286
|
+
readonly options: readonly [];
|
|
4287
|
+
readonly subcommands: readonly [];
|
|
4288
|
+
}, {
|
|
4289
|
+
readonly name: "test";
|
|
4290
|
+
readonly description: "Fire the wake once now with the current pending count";
|
|
4291
|
+
readonly aliases: readonly [];
|
|
4292
|
+
readonly hidden: false;
|
|
4293
|
+
readonly usage: "[options]";
|
|
4294
|
+
readonly arguments: readonly [];
|
|
4295
|
+
readonly options: readonly [];
|
|
4296
|
+
readonly subcommands: readonly [];
|
|
4297
|
+
}];
|
|
4221
4298
|
}, {
|
|
4222
4299
|
readonly name: "run";
|
|
4223
4300
|
readonly description: "Run one kit service in the foreground (sync|mesh|inbox|heartbeat), or `all` to supervise every service in one process";
|
|
@@ -4253,6 +4330,34 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
4253
4330
|
readonly arguments: readonly [];
|
|
4254
4331
|
readonly options: readonly [];
|
|
4255
4332
|
readonly subcommands: readonly [];
|
|
4333
|
+
}, {
|
|
4334
|
+
readonly name: "inbox";
|
|
4335
|
+
readonly description: "List this agent's pending HQ messages (mirrored by the kit), or mark them handled";
|
|
4336
|
+
readonly aliases: readonly [];
|
|
4337
|
+
readonly hidden: false;
|
|
4338
|
+
readonly usage: "[options] [command]";
|
|
4339
|
+
readonly arguments: readonly [];
|
|
4340
|
+
readonly options: readonly [{
|
|
4341
|
+
readonly flags: "--all";
|
|
4342
|
+
readonly description: "Include items already marked handled";
|
|
4343
|
+
}, {
|
|
4344
|
+
readonly flags: "--json";
|
|
4345
|
+
readonly description: "Print machine-readable JSON";
|
|
4346
|
+
}];
|
|
4347
|
+
readonly subcommands: readonly [{
|
|
4348
|
+
readonly name: "done";
|
|
4349
|
+
readonly description: "Mark inbox items handled and ack them on the server";
|
|
4350
|
+
readonly aliases: readonly [];
|
|
4351
|
+
readonly hidden: false;
|
|
4352
|
+
readonly usage: "[options] <ids...>";
|
|
4353
|
+
readonly arguments: readonly [{
|
|
4354
|
+
readonly name: "ids";
|
|
4355
|
+
readonly required: true;
|
|
4356
|
+
readonly variadic: true;
|
|
4357
|
+
}];
|
|
4358
|
+
readonly options: readonly [];
|
|
4359
|
+
readonly subcommands: readonly [];
|
|
4360
|
+
}];
|
|
4256
4361
|
}];
|
|
4257
4362
|
}, {
|
|
4258
4363
|
readonly name: "outposts";
|
|
@@ -5457,6 +5457,102 @@ export const COMMAND_CATALOG = [
|
|
|
5457
5457
|
],
|
|
5458
5458
|
"subcommands": []
|
|
5459
5459
|
},
|
|
5460
|
+
{
|
|
5461
|
+
"name": "wake",
|
|
5462
|
+
"description": "Wake this bot's own framework when HQ messages arrive (webhook URL or local command)",
|
|
5463
|
+
"aliases": [],
|
|
5464
|
+
"hidden": false,
|
|
5465
|
+
"usage": "[options] [command]",
|
|
5466
|
+
"arguments": [],
|
|
5467
|
+
"options": [],
|
|
5468
|
+
"subcommands": [
|
|
5469
|
+
{
|
|
5470
|
+
"name": "set-url",
|
|
5471
|
+
"description": "POST a notice to a webhook URL (e.g. a Grok Bot routine webhook) when mail is pending. Prefer --stdin so the URL stays out of shell history",
|
|
5472
|
+
"aliases": [],
|
|
5473
|
+
"hidden": false,
|
|
5474
|
+
"usage": "[options] [url]",
|
|
5475
|
+
"arguments": [
|
|
5476
|
+
{
|
|
5477
|
+
"name": "url",
|
|
5478
|
+
"required": false,
|
|
5479
|
+
"variadic": false
|
|
5480
|
+
}
|
|
5481
|
+
],
|
|
5482
|
+
"options": [
|
|
5483
|
+
{
|
|
5484
|
+
"flags": "--stdin",
|
|
5485
|
+
"description": "Read the URL from stdin"
|
|
5486
|
+
},
|
|
5487
|
+
{
|
|
5488
|
+
"flags": "--retry-minutes <n>",
|
|
5489
|
+
"description": "Re-wake while items stay pending (default 10, minimum 1)"
|
|
5490
|
+
}
|
|
5491
|
+
],
|
|
5492
|
+
"subcommands": []
|
|
5493
|
+
},
|
|
5494
|
+
{
|
|
5495
|
+
"name": "set-command",
|
|
5496
|
+
"description": "Run a local command (no shell) when mail is pending; the notice JSON arrives on stdin. Use -- before the command",
|
|
5497
|
+
"aliases": [],
|
|
5498
|
+
"hidden": false,
|
|
5499
|
+
"usage": "[options] <argv...>",
|
|
5500
|
+
"arguments": [
|
|
5501
|
+
{
|
|
5502
|
+
"name": "argv",
|
|
5503
|
+
"required": true,
|
|
5504
|
+
"variadic": true
|
|
5505
|
+
}
|
|
5506
|
+
],
|
|
5507
|
+
"options": [
|
|
5508
|
+
{
|
|
5509
|
+
"flags": "--timeout-minutes <n>",
|
|
5510
|
+
"description": "Kill a run after this long (default 10)"
|
|
5511
|
+
},
|
|
5512
|
+
{
|
|
5513
|
+
"flags": "--retry-minutes <n>",
|
|
5514
|
+
"description": "Re-wake while items stay pending (default 10, minimum 1)"
|
|
5515
|
+
}
|
|
5516
|
+
],
|
|
5517
|
+
"subcommands": []
|
|
5518
|
+
},
|
|
5519
|
+
{
|
|
5520
|
+
"name": "show",
|
|
5521
|
+
"description": "Show the configured wake (the URL is masked)",
|
|
5522
|
+
"aliases": [],
|
|
5523
|
+
"hidden": false,
|
|
5524
|
+
"usage": "[options]",
|
|
5525
|
+
"arguments": [],
|
|
5526
|
+
"options": [
|
|
5527
|
+
{
|
|
5528
|
+
"flags": "--json",
|
|
5529
|
+
"description": "Print machine-readable JSON"
|
|
5530
|
+
}
|
|
5531
|
+
],
|
|
5532
|
+
"subcommands": []
|
|
5533
|
+
},
|
|
5534
|
+
{
|
|
5535
|
+
"name": "clear",
|
|
5536
|
+
"description": "Remove the wake configuration",
|
|
5537
|
+
"aliases": [],
|
|
5538
|
+
"hidden": false,
|
|
5539
|
+
"usage": "[options]",
|
|
5540
|
+
"arguments": [],
|
|
5541
|
+
"options": [],
|
|
5542
|
+
"subcommands": []
|
|
5543
|
+
},
|
|
5544
|
+
{
|
|
5545
|
+
"name": "test",
|
|
5546
|
+
"description": "Fire the wake once now with the current pending count",
|
|
5547
|
+
"aliases": [],
|
|
5548
|
+
"hidden": false,
|
|
5549
|
+
"usage": "[options]",
|
|
5550
|
+
"arguments": [],
|
|
5551
|
+
"options": [],
|
|
5552
|
+
"subcommands": []
|
|
5553
|
+
}
|
|
5554
|
+
]
|
|
5555
|
+
},
|
|
5460
5556
|
{
|
|
5461
5557
|
"name": "run",
|
|
5462
5558
|
"description": "Run one kit service in the foreground (sync|mesh|inbox|heartbeat), or `all` to supervise every service in one process",
|
|
@@ -5499,6 +5595,42 @@ export const COMMAND_CATALOG = [
|
|
|
5499
5595
|
"arguments": [],
|
|
5500
5596
|
"options": [],
|
|
5501
5597
|
"subcommands": []
|
|
5598
|
+
},
|
|
5599
|
+
{
|
|
5600
|
+
"name": "inbox",
|
|
5601
|
+
"description": "List this agent's pending HQ messages (mirrored by the kit), or mark them handled",
|
|
5602
|
+
"aliases": [],
|
|
5603
|
+
"hidden": false,
|
|
5604
|
+
"usage": "[options] [command]",
|
|
5605
|
+
"arguments": [],
|
|
5606
|
+
"options": [
|
|
5607
|
+
{
|
|
5608
|
+
"flags": "--all",
|
|
5609
|
+
"description": "Include items already marked handled"
|
|
5610
|
+
},
|
|
5611
|
+
{
|
|
5612
|
+
"flags": "--json",
|
|
5613
|
+
"description": "Print machine-readable JSON"
|
|
5614
|
+
}
|
|
5615
|
+
],
|
|
5616
|
+
"subcommands": [
|
|
5617
|
+
{
|
|
5618
|
+
"name": "done",
|
|
5619
|
+
"description": "Mark inbox items handled and ack them on the server",
|
|
5620
|
+
"aliases": [],
|
|
5621
|
+
"hidden": false,
|
|
5622
|
+
"usage": "[options] <ids...>",
|
|
5623
|
+
"arguments": [
|
|
5624
|
+
{
|
|
5625
|
+
"name": "ids",
|
|
5626
|
+
"required": true,
|
|
5627
|
+
"variadic": true
|
|
5628
|
+
}
|
|
5629
|
+
],
|
|
5630
|
+
"options": [],
|
|
5631
|
+
"subcommands": []
|
|
5632
|
+
}
|
|
5633
|
+
]
|
|
5502
5634
|
}
|
|
5503
5635
|
]
|
|
5504
5636
|
},
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq agent inbox` — what a woken bot runs to see and clear its HQ mail.
|
|
3
|
+
*
|
|
4
|
+
* hq agent inbox [--all] [--json] pending items (oldest first), or all
|
|
5
|
+
* hq agent inbox done <id...> mark handled locally and ack on the server
|
|
6
|
+
*/
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
export declare function registerAgentInboxCommand(agent: Command): void;
|
|
9
|
+
//# sourceMappingURL=agent-inbox.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq agent inbox` — what a woken bot runs to see and clear its HQ mail.
|
|
3
|
+
*
|
|
4
|
+
* hq agent inbox [--all] [--json] pending items (oldest first), or all
|
|
5
|
+
* hq agent inbox done <id...> mark handled locally and ack on the server
|
|
6
|
+
*/
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
10
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
11
|
+
import { markInboxDone, readDoneIds, readMirroredInbox, summarizeInboxEntry } from "../lib/agent-kit/inbox-state.js";
|
|
12
|
+
import { agentKitPaths } from "../lib/agent-kit/paths.js";
|
|
13
|
+
import { requireExternalCreds } from "./agent-kit.js";
|
|
14
|
+
const ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
15
|
+
export function registerAgentInboxCommand(agent) {
|
|
16
|
+
const inbox = agent
|
|
17
|
+
.command("inbox")
|
|
18
|
+
.description("List this agent's pending HQ messages (mirrored by the kit), or mark them handled")
|
|
19
|
+
.option("--all", "Include items already marked handled")
|
|
20
|
+
.option("--json", "Print machine-readable JSON")
|
|
21
|
+
.action((opts) => {
|
|
22
|
+
const paths = agentKitPaths(os.homedir(), process.env);
|
|
23
|
+
const done = readDoneIds(paths);
|
|
24
|
+
const rows = readMirroredInbox(paths)
|
|
25
|
+
.filter((e) => opts.all || !done.has(e.id))
|
|
26
|
+
.map((e) => summarizeInboxEntry(e, done.has(e.id)));
|
|
27
|
+
if (opts.json) {
|
|
28
|
+
console.log(JSON.stringify({ count: rows.length, messages: rows }, null, 2));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (rows.length === 0) {
|
|
32
|
+
console.log(opts.all ? "Inbox is empty." : "No pending messages.");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const r of rows) {
|
|
36
|
+
const state = r.done ? chalk.dim(" (done)") : "";
|
|
37
|
+
console.log(`${chalk.bold(r.id)}${state} ${r.channel ?? "?"} from ${r.from}${r.fromUid ? ` <${r.fromUid}>` : ""} ${r.at ?? ""}`);
|
|
38
|
+
if (r.text)
|
|
39
|
+
console.log(` ${r.text.replace(/\n/g, "\n ")}`);
|
|
40
|
+
}
|
|
41
|
+
if (!opts.all)
|
|
42
|
+
console.log(chalk.dim(`\nReply with hq dm <uid> "…", then: hq agent inbox done <id>`));
|
|
43
|
+
});
|
|
44
|
+
inbox
|
|
45
|
+
.command("done <ids...>")
|
|
46
|
+
.description("Mark inbox items handled and ack them on the server")
|
|
47
|
+
.action(async (ids) => {
|
|
48
|
+
const bad = ids.filter((id) => !ID_RE.test(id));
|
|
49
|
+
if (bad.length) {
|
|
50
|
+
console.error(chalk.red(`Invalid id(s): ${bad.join(", ")}`));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const paths = agentKitPaths(os.homedir(), process.env);
|
|
54
|
+
const creds = requireExternalCreds(paths);
|
|
55
|
+
const added = markInboxDone(paths, ids);
|
|
56
|
+
process.env.HQ_MACHINE_CREDS_FILE = paths.machineCredsPath;
|
|
57
|
+
process.env.HQ_REQUIRE_MACHINE_IDENTITY = "1";
|
|
58
|
+
process.env.HQ_VAULT_API_URL = creds.apiBaseUrl;
|
|
59
|
+
let acked = 0;
|
|
60
|
+
try {
|
|
61
|
+
const token = await ensureCognitoToken({ tokenSource: "machine", interactive: false });
|
|
62
|
+
for (const id of ids) {
|
|
63
|
+
const res = await vaultApiFetch({
|
|
64
|
+
token,
|
|
65
|
+
baseUrl: creds.apiBaseUrl,
|
|
66
|
+
path: `/v1/agents/${encodeURIComponent(creds.entityUid)}/inbox/${encodeURIComponent(id)}/ack`,
|
|
67
|
+
method: "POST",
|
|
68
|
+
});
|
|
69
|
+
if (res.ok)
|
|
70
|
+
acked += 1;
|
|
71
|
+
else
|
|
72
|
+
console.error(chalk.yellow(`server ack ${id} → ${res.status}`));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.error(chalk.yellow(`server ack skipped: ${err instanceof Error ? err.message : String(err)}`));
|
|
77
|
+
}
|
|
78
|
+
console.log(`Marked ${added.length} handled (${ids.length - added.length} already done); server acked ${acked}.`);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=agent-inbox.js.map
|
|
@@ -34,6 +34,8 @@ import { fallbackStatus, rebootCrontabLine, shouldUseFallback, startFallback, st
|
|
|
34
34
|
import { serviceLogPath } from "../lib/agent-kit/paths.js";
|
|
35
35
|
import { spawn } from "node:child_process";
|
|
36
36
|
import { startMeshListener } from "../lib/agent-kit/run/mesh-listener.js";
|
|
37
|
+
import { pendingInbox } from "../lib/agent-kit/inbox-state.js";
|
|
38
|
+
import { buildWakeNotice, clearWakeConfig, currentWakeState, DEFAULT_WAKE_RETRY_MS, describeWake, fireWake, readWakeConfig, validateWakeUrl, writeWakeConfig, } from "../lib/agent-kit/wake.js";
|
|
37
39
|
import { runSyncLoop } from "../lib/agent-kit/run/sync.js";
|
|
38
40
|
import { resolveHqBinary, resolveNodeBinary, } from "../lib/mesh/live/daemon/install.js";
|
|
39
41
|
import { installServices, servicesStatus, uninstallServices, } from "../lib/service-manager/index.js";
|
|
@@ -245,6 +247,14 @@ export function formatFallbackStatus(paths, kill) {
|
|
|
245
247
|
: chalk.yellow(st.pid === null ? "pidfile unreadable" : `not running (stale pid ${st.pid})`);
|
|
246
248
|
return [` mode: fallback supervisor (hq agent kit run all) ${state}${chalk.dim(` ${st.pidFile}`)}`];
|
|
247
249
|
}
|
|
250
|
+
function retryMsFrom(raw) {
|
|
251
|
+
if (raw === undefined)
|
|
252
|
+
return DEFAULT_WAKE_RETRY_MS;
|
|
253
|
+
const n = Number(raw);
|
|
254
|
+
if (!Number.isFinite(n) || n < 1)
|
|
255
|
+
throw new Error("--retry-minutes must be at least 1");
|
|
256
|
+
return n * 60_000;
|
|
257
|
+
}
|
|
248
258
|
function fail(err) {
|
|
249
259
|
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
250
260
|
process.exit(1);
|
|
@@ -341,6 +351,101 @@ export function registerAgentKitCommand(agent) {
|
|
|
341
351
|
fail(err);
|
|
342
352
|
}
|
|
343
353
|
});
|
|
354
|
+
const wake = kit
|
|
355
|
+
.command("wake")
|
|
356
|
+
.description("Wake this bot's own framework when HQ messages arrive (webhook URL or local command)");
|
|
357
|
+
wake
|
|
358
|
+
.command("set-url [url]")
|
|
359
|
+
.description("POST a notice to a webhook URL (e.g. a Grok Bot routine webhook) when mail is pending. Prefer --stdin so the URL stays out of shell history")
|
|
360
|
+
.option("--stdin", "Read the URL from stdin")
|
|
361
|
+
.option("--retry-minutes <n>", "Re-wake while items stay pending (default 10, minimum 1)")
|
|
362
|
+
.action(async (url, opts) => {
|
|
363
|
+
try {
|
|
364
|
+
const paths = agentKitPaths(os.homedir(), process.env);
|
|
365
|
+
requireExternalCreds(paths);
|
|
366
|
+
let raw = url;
|
|
367
|
+
if (opts.stdin) {
|
|
368
|
+
const chunks = [];
|
|
369
|
+
for await (const c of process.stdin)
|
|
370
|
+
chunks.push(c);
|
|
371
|
+
raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
372
|
+
}
|
|
373
|
+
if (!raw)
|
|
374
|
+
throw new Error("Provide the URL as an argument or with --stdin");
|
|
375
|
+
const config = { kind: "url", url: validateWakeUrl(raw), retryMs: retryMsFrom(opts.retryMinutes) };
|
|
376
|
+
writeWakeConfig(paths, config);
|
|
377
|
+
console.log(chalk.green(`Wake set: ${describeWake(config)}`));
|
|
378
|
+
console.log("Test it: hq agent kit wake test");
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
fail(err);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
wake
|
|
385
|
+
.command("set-command <argv...>")
|
|
386
|
+
.description("Run a local command (no shell) when mail is pending; the notice JSON arrives on stdin. Use -- before the command")
|
|
387
|
+
.option("--timeout-minutes <n>", "Kill a run after this long (default 10)")
|
|
388
|
+
.option("--retry-minutes <n>", "Re-wake while items stay pending (default 10, minimum 1)")
|
|
389
|
+
.action((argv, opts) => {
|
|
390
|
+
try {
|
|
391
|
+
const paths = agentKitPaths(os.homedir(), process.env);
|
|
392
|
+
requireExternalCreds(paths);
|
|
393
|
+
const minutes = opts.timeoutMinutes ? Number(opts.timeoutMinutes) : 10;
|
|
394
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
395
|
+
throw new Error("--timeout-minutes must be a positive number");
|
|
396
|
+
const config = { kind: "command", command: argv, timeoutMs: minutes * 60_000, retryMs: retryMsFrom(opts.retryMinutes) };
|
|
397
|
+
writeWakeConfig(paths, config);
|
|
398
|
+
console.log(chalk.green(`Wake set: ${describeWake(config)}`));
|
|
399
|
+
console.log("Test it: hq agent kit wake test");
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
fail(err);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
wake
|
|
406
|
+
.command("show")
|
|
407
|
+
.description("Show the configured wake (the URL is masked)")
|
|
408
|
+
.option("--json", "Print machine-readable JSON")
|
|
409
|
+
.action((opts) => {
|
|
410
|
+
const paths = agentKitPaths(os.homedir(), process.env);
|
|
411
|
+
const config = readWakeConfig(paths);
|
|
412
|
+
const state = currentWakeState(paths);
|
|
413
|
+
if (opts.json) {
|
|
414
|
+
console.log(JSON.stringify({ configured: !!config, wake: describeWake(config), lastWakeAt: state.lastWakeAt ?? null }, null, 2));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
console.log(` wake: ${describeWake(config)}`);
|
|
418
|
+
console.log(` last wake: ${state.lastWakeAt ?? "never"}`);
|
|
419
|
+
});
|
|
420
|
+
wake
|
|
421
|
+
.command("clear")
|
|
422
|
+
.description("Remove the wake configuration")
|
|
423
|
+
.action(() => {
|
|
424
|
+
const paths = agentKitPaths(os.homedir(), process.env);
|
|
425
|
+
console.log(clearWakeConfig(paths) ? "Wake cleared." : "No wake was configured.");
|
|
426
|
+
});
|
|
427
|
+
wake
|
|
428
|
+
.command("test")
|
|
429
|
+
.description("Fire the wake once now with the current pending count")
|
|
430
|
+
.action(async () => {
|
|
431
|
+
try {
|
|
432
|
+
const paths = agentKitPaths(os.homedir(), process.env);
|
|
433
|
+
const creds = requireExternalCreds(paths);
|
|
434
|
+
ensureKitDirs(paths);
|
|
435
|
+
const config = readWakeConfig(paths);
|
|
436
|
+
if (!config)
|
|
437
|
+
throw new Error("No wake configured. Run hq agent kit wake set-url --stdin (or set-command).");
|
|
438
|
+
const log = createKitLogger(paths, "wake", { echo: false });
|
|
439
|
+
const ids = pendingInbox(paths).map((e) => e.id);
|
|
440
|
+
const result = await fireWake(config, buildWakeNotice(creds.entityUid, "test", ids, new Date()), { paths, agentUid: creds.entityUid, log });
|
|
441
|
+
console.log(result.ok ? chalk.green(result.detail) : chalk.red(result.detail));
|
|
442
|
+
if (!result.ok)
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
fail(err);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
344
449
|
kit
|
|
345
450
|
.command("run <service>")
|
|
346
451
|
.description(`Run one kit service in the foreground (${KIT_SERVICES.join("|")}), or \`all\` to supervise every service in one process`)
|
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
import { Command } from "commander";
|
|
12
12
|
import { McpServer } from "../lib/agent-kit/mcp/jsonrpc.js";
|
|
13
13
|
import { type HqRunResult, type McpToolClients } from "../lib/agent-kit/mcp/tools.js";
|
|
14
|
+
import { type AgentKitPaths } from "../lib/agent-kit/paths.js";
|
|
14
15
|
import type { ExternalMachineCreds } from "../lib/agent-kit/creds.js";
|
|
15
16
|
export declare const AGENT_MCP_SERVER_NAME = "hq-agent";
|
|
16
17
|
export declare const AGENT_MCP_INSTRUCTIONS: string;
|
|
17
18
|
export declare const HQ_SUBPROCESS_TIMEOUT_MS = 120000;
|
|
18
19
|
export declare function defaultRunHq(nodeBinary: string, hqBinary: string, env: NodeJS.ProcessEnv): (args: string[]) => Promise<HqRunResult>;
|
|
19
|
-
export declare function defaultMcpClients(creds: ExternalMachineCreds, credsPath: string): McpToolClients;
|
|
20
|
+
export declare function defaultMcpClients(creds: ExternalMachineCreds, credsPath: string, paths?: Pick<AgentKitPaths, "inboxDir">): McpToolClients;
|
|
20
21
|
export declare function createAgentMcpServer(clients: McpToolClients): McpServer;
|
|
21
22
|
export declare function registerAgentMcpCommand(agent: Command): void;
|
|
22
23
|
//# sourceMappingURL=agent-mcp.d.ts.map
|
|
@@ -21,7 +21,7 @@ export const AGENT_MCP_SERVER_NAME = "hq-agent";
|
|
|
21
21
|
export const AGENT_MCP_INSTRUCTIONS = "You are acting inside HQ as an enrolled external agent. Tools run as that machine identity: " +
|
|
22
22
|
"hq_whoami (identity), hq_search / hq_files_list / hq_files_read (company vault), " +
|
|
23
23
|
"hq_secrets_list / hq_secrets_exec (secrets by NAME only — values are never returned), " +
|
|
24
|
-
"hq_dm_send /
|
|
24
|
+
"hq_inbox_read / hq_dm_send / hq_inbox_done (read pending HQ messages, reply, then mark each handled), hq_work_mesh_status (what the team is doing). " +
|
|
25
25
|
"Never ask a user for a token or secret value; if a secret is missing, name it and ask an admin to add it.";
|
|
26
26
|
export const HQ_SUBPROCESS_TIMEOUT_MS = 120_000;
|
|
27
27
|
export function defaultRunHq(nodeBinary, hqBinary, env) {
|
|
@@ -45,7 +45,7 @@ export function defaultRunHq(nodeBinary, hqBinary, env) {
|
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
-
export function defaultMcpClients(creds, credsPath) {
|
|
48
|
+
export function defaultMcpClients(creds, credsPath, paths = agentKitPaths(os.homedir(), process.env)) {
|
|
49
49
|
const env = {
|
|
50
50
|
...process.env,
|
|
51
51
|
HQ_MACHINE_CREDS_FILE: credsPath,
|
|
@@ -55,6 +55,7 @@ export function defaultMcpClients(creds, credsPath) {
|
|
|
55
55
|
};
|
|
56
56
|
return {
|
|
57
57
|
creds,
|
|
58
|
+
paths,
|
|
58
59
|
runHq: defaultRunHq(process.execPath, process.argv[1], env),
|
|
59
60
|
getToken: () => ensureCognitoToken({ tokenSource: "machine", interactive: false }),
|
|
60
61
|
apiJson: async (token, path, init) => {
|
|
@@ -96,7 +97,7 @@ export function registerAgentMcpCommand(agent) {
|
|
|
96
97
|
process.env.HQ_MACHINE_CREDS_FILE = paths.machineCredsPath;
|
|
97
98
|
process.env.HQ_REQUIRE_MACHINE_IDENTITY = "1";
|
|
98
99
|
process.env.HQ_VAULT_API_URL = creds.apiBaseUrl;
|
|
99
|
-
const server = createAgentMcpServer(defaultMcpClients(creds, paths.machineCredsPath));
|
|
100
|
+
const server = createAgentMcpServer(defaultMcpClients(creds, paths.machineCredsPath, paths));
|
|
100
101
|
process.stderr.write(`hq agent mcp: serving as ${creds.entityUid} (${creds.companySlug})\n`);
|
|
101
102
|
await server.serve(process.stdin, process.stdout);
|
|
102
103
|
});
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* hq agent kit install|status|uninstall|run <service>
|
|
7
7
|
* hq agent probe end-to-end membership check
|
|
8
8
|
* hq agent mcp stdio MCP server for the bot framework
|
|
9
|
+
* hq agent inbox [done <id…>] pending HQ messages / mark handled
|
|
9
10
|
*/
|
|
10
11
|
import { Command } from "commander";
|
|
11
12
|
export declare function registerAgentCommand(program: Command): void;
|
package/dist/commands/agent.js
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* hq agent kit install|status|uninstall|run <service>
|
|
7
7
|
* hq agent probe end-to-end membership check
|
|
8
8
|
* hq agent mcp stdio MCP server for the bot framework
|
|
9
|
+
* hq agent inbox [done <id…>] pending HQ messages / mark handled
|
|
9
10
|
*/
|
|
10
11
|
import { registerAgentEnrollCommand } from "./agent-enroll.js";
|
|
12
|
+
import { registerAgentInboxCommand } from "./agent-inbox.js";
|
|
11
13
|
import { registerAgentKitCommand } from "./agent-kit.js";
|
|
12
14
|
import { registerAgentMcpCommand } from "./agent-mcp.js";
|
|
13
15
|
import { registerAgentProbeCommand } from "./agent-probe.js";
|
|
@@ -19,5 +21,6 @@ export function registerAgentCommand(program) {
|
|
|
19
21
|
registerAgentKitCommand(agent);
|
|
20
22
|
registerAgentProbeCommand(agent);
|
|
21
23
|
registerAgentMcpCommand(agent);
|
|
24
|
+
registerAgentInboxCommand(agent);
|
|
22
25
|
}
|
|
23
26
|
//# sourceMappingURL=agent.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
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 type { AgentKitPaths } from "./paths.js";
|
|
12
|
+
export declare const DONE_IDS_NAME = "done-ids.json";
|
|
13
|
+
export declare const MAX_DONE_IDS = 5000;
|
|
14
|
+
export interface MirroredInboxEntry {
|
|
15
|
+
id: string;
|
|
16
|
+
mirroredAt?: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
export declare function readDoneIds(paths: Pick<AgentKitPaths, "inboxDir">): Set<string>;
|
|
20
|
+
export declare function markInboxDone(paths: Pick<AgentKitPaths, "inboxDir">, ids: string[]): string[];
|
|
21
|
+
/** Every mirrored entry, oldest first, de-duplicated by id (last write wins). */
|
|
22
|
+
export declare function readMirroredInbox(paths: Pick<AgentKitPaths, "inboxDir">): MirroredInboxEntry[];
|
|
23
|
+
export declare function pendingInbox(paths: Pick<AgentKitPaths, "inboxDir">): MirroredInboxEntry[];
|
|
24
|
+
/** Compact view for bots: who, when, which channel, and the text. */
|
|
25
|
+
export declare function summarizeInboxEntry(e: MirroredInboxEntry, done: boolean): {
|
|
26
|
+
id: string;
|
|
27
|
+
channel: string | undefined;
|
|
28
|
+
from: string;
|
|
29
|
+
fromUid: string | undefined;
|
|
30
|
+
at: string | undefined;
|
|
31
|
+
text: string | undefined;
|
|
32
|
+
done: boolean;
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=inbox-state.d.ts.map
|
|
@@ -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
|