heedvane 0.0.0-stage → 0.2.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.
Files changed (98) hide show
  1. package/README.md +62 -2
  2. package/dist/apply.d.ts +104 -0
  3. package/dist/apply.js +388 -0
  4. package/dist/backend/client.d.ts +40 -0
  5. package/dist/backend/client.js +32 -0
  6. package/dist/backend/credentials.d.ts +15 -0
  7. package/dist/backend/credentials.js +76 -0
  8. package/dist/backend/login.d.ts +33 -0
  9. package/dist/backend/login.js +265 -0
  10. package/dist/backend/repo-remote.d.ts +15 -0
  11. package/dist/backend/repo-remote.js +61 -0
  12. package/dist/backend/sign-in-change.d.ts +12 -0
  13. package/dist/backend/sign-in-change.js +72 -0
  14. package/dist/best-effort.d.ts +4 -0
  15. package/dist/best-effort.js +25 -0
  16. package/dist/cli.d.ts +29 -0
  17. package/dist/cli.js +666 -0
  18. package/dist/clients/definitions.d.ts +113 -0
  19. package/dist/clients/definitions.js +427 -0
  20. package/dist/clients/hermes-plugin.d.ts +33 -0
  21. package/dist/clients/hermes-plugin.js +247 -0
  22. package/dist/commands/heedvane.d.ts +15 -0
  23. package/dist/commands/heedvane.js +74 -0
  24. package/dist/commands/memory.d.ts +10 -0
  25. package/dist/commands/memory.js +106 -0
  26. package/dist/config-doc.d.ts +97 -0
  27. package/dist/config-doc.js +465 -0
  28. package/dist/dispatchers/posix.d.ts +41 -0
  29. package/dist/dispatchers/posix.js +208 -0
  30. package/dist/dispatchers/snippets.d.ts +6 -0
  31. package/dist/dispatchers/snippets.js +39 -0
  32. package/dist/dispatchers/windows.d.ts +15 -0
  33. package/dist/dispatchers/windows.js +50 -0
  34. package/dist/doctor.d.ts +37 -0
  35. package/dist/doctor.js +303 -0
  36. package/dist/events/contract.d.ts +54 -0
  37. package/dist/events/contract.js +87 -0
  38. package/dist/events/normalize.d.ts +36 -0
  39. package/dist/events/normalize.js +143 -0
  40. package/dist/guards.d.ts +18 -0
  41. package/dist/guards.js +28 -0
  42. package/dist/heed-file.d.ts +78 -0
  43. package/dist/heed-file.js +316 -0
  44. package/dist/installer.d.ts +47 -0
  45. package/dist/installer.js +174 -0
  46. package/dist/listener/background.d.ts +8 -0
  47. package/dist/listener/background.js +36 -0
  48. package/dist/listener/endpoint.d.ts +26 -0
  49. package/dist/listener/endpoint.js +125 -0
  50. package/dist/listener/forward.d.ts +36 -0
  51. package/dist/listener/forward.js +257 -0
  52. package/dist/listener/serve.d.ts +2 -0
  53. package/dist/listener/serve.js +155 -0
  54. package/dist/listener/server.d.ts +48 -0
  55. package/dist/listener/server.js +251 -0
  56. package/dist/listener/spool.d.ts +28 -0
  57. package/dist/listener/spool.js +60 -0
  58. package/dist/mcp-server.d.ts +9 -0
  59. package/dist/mcp-server.js +187 -0
  60. package/dist/mcp.d.ts +57 -0
  61. package/dist/mcp.js +83 -0
  62. package/dist/memory/private-memory.d.ts +12 -0
  63. package/dist/memory/private-memory.js +54 -0
  64. package/dist/memory/table-of-contents.d.ts +10 -0
  65. package/dist/memory/table-of-contents.js +80 -0
  66. package/dist/memory/team-memory.d.ts +60 -0
  67. package/dist/memory/team-memory.js +54 -0
  68. package/dist/plan.d.ts +77 -0
  69. package/dist/plan.js +288 -0
  70. package/dist/render.d.ts +32 -0
  71. package/dist/render.js +132 -0
  72. package/dist/reply/harness-output.d.ts +57 -0
  73. package/dist/reply/harness-output.js +88 -0
  74. package/dist/reply/reply.d.ts +65 -0
  75. package/dist/reply/reply.js +169 -0
  76. package/dist/reply/verdict.d.ts +28 -0
  77. package/dist/reply/verdict.js +17 -0
  78. package/dist/skill/bundle.d.ts +23 -0
  79. package/dist/skill/bundle.js +102 -0
  80. package/dist/skill/content.d.ts +16 -0
  81. package/dist/skill/content.js +182 -0
  82. package/dist/skills/approved.d.ts +31 -0
  83. package/dist/skills/approved.js +123 -0
  84. package/dist/skills/check.d.ts +21 -0
  85. package/dist/skills/check.js +83 -0
  86. package/dist/state.d.ts +51 -0
  87. package/dist/state.js +118 -0
  88. package/dist/steering/rules.d.ts +49 -0
  89. package/dist/steering/rules.js +165 -0
  90. package/dist/steering/steer.d.ts +29 -0
  91. package/dist/steering/steer.js +64 -0
  92. package/dist/steering/violations.d.ts +27 -0
  93. package/dist/steering/violations.js +24 -0
  94. package/dist/uninstall.d.ts +23 -0
  95. package/dist/uninstall.js +159 -0
  96. package/package.json +35 -4
  97. package/skills/heedvane-hooks/SKILL.md +87 -0
  98. package/skills/heedvane-hooks/references/events.md +69 -0
@@ -0,0 +1,125 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmod, mkdir, open, readFile, rename } from "node:fs/promises";
3
+ import { createServer } from "node:net";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { unlinkOptional } from "../best-effort.js";
6
+ /** The variable names the dispatcher scripts and the Hermes plugin read, sourced straight from endpoint.env. */
7
+ const PORT_KEY = "HEED_AGENT_HOOK_PORT";
8
+ const TOKEN_KEY = "HEED_AGENT_HOOK_TOKEN";
9
+ /** Path of the endpoint file: `root/endpoint.env`. */
10
+ export function endpointPath(root) {
11
+ return join(root, "endpoint.env");
12
+ }
13
+ /** Parse KEY=VALUE lines (ignores blanks, `#` comments, and matching quotes). */
14
+ function parseEnvFile(content) {
15
+ const values = {};
16
+ for (const rawLine of content.split(/\r?\n/)) {
17
+ const line = rawLine.trim();
18
+ if (!line || line.startsWith("#"))
19
+ continue;
20
+ const eq = line.indexOf("=");
21
+ if (eq <= 0)
22
+ continue;
23
+ const key = line.slice(0, eq).trim();
24
+ let value = line.slice(eq + 1).trim();
25
+ if (value.length >= 2 &&
26
+ ((value.startsWith('"') && value.endsWith('"')) ||
27
+ (value.startsWith("'") && value.endsWith("'")))) {
28
+ value = value.slice(1, -1);
29
+ }
30
+ values[key] = value;
31
+ }
32
+ return values;
33
+ }
34
+ /**
35
+ * Read `{port, token}` from `root/endpoint.env`.
36
+ * Returns null when the file does not exist. If the file exists but is
37
+ * missing/incoherent, the corresponding field degrades (`port` -> 0,
38
+ * `token` -> "") so callers can produce a precise remediation error.
39
+ */
40
+ export async function readEndpoint(root) {
41
+ let content;
42
+ try {
43
+ content = await readFile(endpointPath(root), "utf8");
44
+ }
45
+ catch (err) {
46
+ if (err.code === "ENOENT")
47
+ return null;
48
+ throw err;
49
+ }
50
+ const values = parseEnvFile(content);
51
+ const port = Number.parseInt(values[PORT_KEY] ?? "", 10);
52
+ return {
53
+ port: Number.isInteger(port) && port >= 0 && port <= 65535 ? port : 0,
54
+ token: values[TOKEN_KEY] ?? "",
55
+ };
56
+ }
57
+ /**
58
+ * Atomic write: temp file in the same directory (created 0600, fsync'd,
59
+ * chmod'd to defeat umask), then rename over the target. Same contract as
60
+ * write-file-atomic, kept stdlib-only so the listener has zero deps.
61
+ */
62
+ async function atomicWriteFile(filePath, data, mode) {
63
+ const dir = dirname(filePath);
64
+ await mkdir(dir, { recursive: true });
65
+ const tmp = join(dir, `.${basename(filePath)}.${process.pid}.${Date.now().toString(36)}.tmp`);
66
+ const handle = await open(tmp, "w", mode);
67
+ try {
68
+ await handle.writeFile(data, "utf8");
69
+ await handle.sync();
70
+ }
71
+ finally {
72
+ await handle.close();
73
+ }
74
+ try {
75
+ await chmod(tmp, mode);
76
+ await rename(tmp, filePath);
77
+ }
78
+ catch (err) {
79
+ await unlinkOptional(tmp);
80
+ throw err;
81
+ }
82
+ }
83
+ /** Atomically write `root/endpoint.env` (mode 0600) with the port and token lines. */
84
+ export async function writeEndpoint(root, endpoint) {
85
+ const content = `${PORT_KEY}=${endpoint.port}\n${TOKEN_KEY}=${endpoint.token}\n`;
86
+ await atomicWriteFile(endpointPath(root), content, 0o600);
87
+ }
88
+ /** A loopback port free right now (the OS picks it). */
89
+ async function freeLoopbackPort() {
90
+ const server = createServer();
91
+ await new Promise((resolve, reject) => {
92
+ server.once("error", reject);
93
+ server.listen(0, "127.0.0.1", resolve);
94
+ });
95
+ const address = server.address();
96
+ await new Promise((resolve) => server.close(() => resolve()));
97
+ if (address === null || typeof address !== "object")
98
+ throw new Error("could not reserve a loopback port");
99
+ return address.port;
100
+ }
101
+ /**
102
+ * The listener endpoint, issued once: an existing endpoint.env with a port and token is kept
103
+ * (dispatchers already point at it); otherwise a free loopback port and a fresh 256-bit token are written.
104
+ */
105
+ export async function ensureEndpoint(root) {
106
+ const existing = await readEndpoint(root);
107
+ if (existing !== null && existing.port > 0 && existing.token)
108
+ return existing;
109
+ const endpoint = { port: await freeLoopbackPort(), token: randomBytes(32).toString("hex") };
110
+ await writeEndpoint(root, endpoint);
111
+ return endpoint;
112
+ }
113
+ /**
114
+ * Whether a listener answers at `endpoint` within 750 ms. The probe is a GET, which the listener refuses
115
+ * with 405 before it reads a token or records anything, so probing never adds an event.
116
+ */
117
+ export async function pingListener(endpoint) {
118
+ try {
119
+ await fetch(`http://127.0.0.1:${endpoint.port}/hook/claude`, { signal: AbortSignal.timeout(750) });
120
+ return true;
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ }
@@ -0,0 +1,36 @@
1
+ import { type Backend } from "../backend/client.js";
2
+ /** Held while Heedvane is unreachable; past this, the oldest {@link SHED_FRACTION} moves to the dead-letter file in one rewrite. */
3
+ export declare const QUEUE_MAX = 5000;
4
+ /** Held while the route refuses this machine outright (403) or does not exist (404): nothing will change soon. */
5
+ export declare const REFUSED_QUEUE_MAX = 500;
6
+ /** The dead-letter file turns over at this size (the old one is kept once as `.1`), so a permanent refusal cannot fill the disk. */
7
+ export declare const DEAD_LETTER_MAX_BYTES: number;
8
+ /** How often an item refused on its own is retried before it is dead-lettered. */
9
+ export declare const MAX_REFUSALS = 3;
10
+ export interface Outbox<T> {
11
+ /** Queue an item; it is on disk when this resolves. It is stamped with the current sign-in, or with `identity` when the caller read it earlier (a replayed event belongs to the sign-in the replay began under). */
12
+ add: (item: T, identity?: string | null) => Promise<void>;
13
+ /** Send everything queued; what could not be sent stays queued for the next flush. One at a time. */
14
+ flush: () => Promise<void>;
15
+ }
16
+ export interface OutboxRoute {
17
+ /** The outbox's file name stem under `<root>/spool`. */
18
+ name: string;
19
+ path: string;
20
+ /** The body's array key, `{ [key]: items }`, also the first element of a 400's issue paths. */
21
+ key: string;
22
+ maxItems: number;
23
+ maxBytes: number;
24
+ }
25
+ /** Steering's delivery reports: at most 100 per post and 256 KiB per body (a little headroom for the wrapper). */
26
+ export declare const DELIVERIES_ROUTE: OutboxRoute;
27
+ export interface OutboxContext {
28
+ /** The heed root; the outbox lives in its spool directory. */
29
+ root: string;
30
+ backend: Backend | null;
31
+ log: (message: string) => void;
32
+ /** The clock the backoff reads (tests move it). */
33
+ now?: () => number;
34
+ }
35
+ /** An outbox for one route, loaded with whatever an earlier listener left unsent. Without a backend it only queues. */
36
+ export declare function outbox<T>(route: OutboxRoute, { root, backend, log, now }: OutboxContext): Promise<Outbox<T>>;
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Steering's delivery reports (`POST /api/agent/steering/deliveries`) are queued to an on-disk outbox
3
+ * and sent in batches.
4
+ *
5
+ * Each item remembers the sign-in that queued it (`Backend.identity`) and is sent only under that same
6
+ * sign-in, read in the same snapshot as its bearer; after a login to another organization, or with items
7
+ * queued under no sign-in, those items move to the dead-letter file and nothing of theirs is sent. A failed disk write fails only its own caller; the next write runs.
8
+ *
9
+ * Nothing waits only in memory: an item is appended to an on-disk outbox (`<root>/spool/<name>.outbox.jsonl`)
10
+ * before `add` resolves and leaves it only once its send settled, so a crash or restart resends it
11
+ * (Heedvane drops the duplicate by `id`). One flush runs at a time, and settled items leave by id.
12
+ *
13
+ * No answer, 5xx, 401, 403, 404, 408 and 429 keep the batch: none is the items' fault. A kept batch is
14
+ * retried with exponential backoff (5 s doubling to 5 min), logged once per streak; past the cap
15
+ * (5,000 items, or 500 while the route answers 403 or 404) the oldest tenth moves to the dead-letter
16
+ * file in one write, so one item the server never accepts cannot hold the head forever and stop every
17
+ * later item, and a full outbox costs a hook one append. Any other 4xx splits the batch until the refused item is alone (a 400 that names its items
18
+ * drops just those); an item refused alone {@link MAX_REFUSALS} times moves to
19
+ * `<root>/spool/<name>.dead-letter.jsonl` with the answer, and everything behind it is delivered. The dead-letter file turns over at {@link DEAD_LETTER_MAX_BYTES}, keeping one older generation.
20
+ */
21
+ import { randomUUID } from "node:crypto";
22
+ import { appendFile, mkdir, readFile, rename, stat } from "node:fs/promises";
23
+ import { join } from "node:path";
24
+ import writeFileAtomic from "write-file-atomic";
25
+ import { callHeedvaneAs } from "../backend/client.js";
26
+ import { errorMessage, isRecord } from "../guards.js";
27
+ import { spoolDir } from "./spool.js";
28
+ /** Held while Heedvane is unreachable; past this, the oldest {@link SHED_FRACTION} moves to the dead-letter file in one rewrite. */
29
+ export const QUEUE_MAX = 5_000;
30
+ /** Held while the route refuses this machine outright (403) or does not exist (404): nothing will change soon. */
31
+ export const REFUSED_QUEUE_MAX = 500;
32
+ /** How much of a full outbox is shed at once, so the rewrite happens once per many items, not per item. */
33
+ const SHED_FRACTION = 0.1;
34
+ /** Kept batches wait this long before the next try, doubling per kept flush up to {@link BACKOFF_MAX_MS}. */
35
+ const BACKOFF_BASE_MS = 5_000;
36
+ const BACKOFF_MAX_MS = 5 * 60_000;
37
+ const SEND_TIMEOUT_MS = 10_000;
38
+ /** The dead-letter file turns over at this size (the old one is kept once as `.1`), so a permanent refusal cannot fill the disk. */
39
+ export const DEAD_LETTER_MAX_BYTES = 50 * 1024 * 1024;
40
+ /** How often an item refused on its own is retried before it is dead-lettered. */
41
+ export const MAX_REFUSALS = 3;
42
+ /** Answers that say nothing about the items: the credential, the route or the server, so keep them. */
43
+ const KEEP_STATUSES = new Set([401, 403, 404, 408, 429]);
44
+ /** Steering's delivery reports: at most 100 per post and 256 KiB per body (a little headroom for the wrapper). */
45
+ export const DELIVERIES_ROUTE = { name: "deliveries", path: "/api/agent/steering/deliveries", key: "deliveries", maxItems: 100, maxBytes: 250 * 1024 };
46
+ /** An outbox for one route, loaded with whatever an earlier listener left unsent. Without a backend it only queues. */
47
+ export async function outbox(route, { root, backend, log, now = Date.now }) {
48
+ const dir = spoolDir(root);
49
+ const file = join(dir, `${route.name}.outbox.jsonl`);
50
+ await mkdir(dir, { recursive: true, mode: 0o700 });
51
+ const { entries: queue, endsCleanly } = await readOutbox(file);
52
+ const refusals = new Map();
53
+ // File writes in order, so a rewrite never races an append.
54
+ let writes = Promise.resolve();
55
+ // One failed write (a full disk, a permission) fails its own caller only; the next write still runs.
56
+ const persist = (write) => {
57
+ const next = writes.then(write);
58
+ writes = next.catch(() => undefined);
59
+ return next;
60
+ };
61
+ const rewrite = () => persist(() => writeFileAtomic(file, queue.map((entry) => `${JSON.stringify(entry)}\n`).join(""), { mode: 0o600 }));
62
+ // A crash mid-append left a line with no newline: the next append would glue onto it and be lost with it.
63
+ if (!endsCleanly)
64
+ await rewrite();
65
+ const deadLetter = (entries, status, json) => {
66
+ log(`[heedvane] dead-lettered ${entries.length} item(s) ${route.path} (${status ?? "no answer"}): ${JSON.stringify(json).slice(0, 500)}`);
67
+ const lines = entries.map((entry) => `${JSON.stringify({ deadAt: new Date().toISOString(), status, answer: json, ...entry })}\n`).join("");
68
+ const deadFile = join(dir, `${route.name}.dead-letter.jsonl`);
69
+ return persist(async () => {
70
+ const size = await stat(deadFile).then((info) => info.size, () => 0);
71
+ if (size >= DEAD_LETTER_MAX_BYTES)
72
+ await rename(deadFile, `${deadFile}.1`);
73
+ await appendFile(deadFile, lines, { mode: 0o600 });
74
+ });
75
+ };
76
+ // A streak of kept flushes: back off, say so once, and hold fewer items while the route refuses outright.
77
+ const kept = { streak: 0, until: 0, status: null, shed: 0 };
78
+ const cap = () => (kept.status === 403 || kept.status === 404 ? REFUSED_QUEUE_MAX : QUEUE_MAX);
79
+ // Oldest first: the head is the item most likely to be the one never accepted. Dead-lettered, not discarded.
80
+ const shedOldest = async () => {
81
+ const shed = queue.slice(0, Math.ceil(cap() * SHED_FRACTION));
82
+ if (kept.shed === 0)
83
+ log(`[heedvane] ${route.path}: the outbox is full${kept.status === null ? "" : ` (Heedvane answers ${kept.status})`}; the oldest items move to the dead-letter file`);
84
+ kept.shed += shed.length;
85
+ await deadLetter(shed, kept.status, { reason: "outbox full" });
86
+ removeFromQueue(new Set(shed.map((entry) => entry.id)));
87
+ await rewrite();
88
+ };
89
+ let shedding = null;
90
+ const add = async (item, stampedAs) => {
91
+ const identity = stampedAs !== undefined ? stampedAs : ((await backend?.identity()) ?? null);
92
+ // One shed at a time, shared by every add that finds the outbox full; the check and the push below share one tick.
93
+ if (queue.length >= cap())
94
+ await (shedding ??= shedOldest().finally(() => (shedding = null)));
95
+ const entry = { id: isRecord(item) && typeof item["id"] === "string" ? item["id"] : randomUUID(), item, identity };
96
+ queue.push(entry);
97
+ return persist(() => appendFile(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 }));
98
+ };
99
+ const keptFlush = (status) => {
100
+ kept.streak += 1;
101
+ kept.status = status ?? null;
102
+ kept.until = now() + Math.min(BACKOFF_BASE_MS * 2 ** (kept.streak - 1), BACKOFF_MAX_MS);
103
+ if (kept.streak === 1)
104
+ log(`[heedvane] ${route.path}: ${status === null || status === undefined ? "Heedvane did not answer" : `Heedvane answered ${status}`}; ${queue.length} item(s) wait, retried with backoff`);
105
+ };
106
+ const delivered = () => {
107
+ if (kept.streak > 0)
108
+ log(`[heedvane] ${route.path}: delivering again after ${kept.streak} kept flush(es)${kept.shed > 0 ? `; ${kept.shed} old item(s) were dead-lettered meanwhile` : ""}`);
109
+ Object.assign(kept, { streak: 0, until: 0, status: null, shed: 0 });
110
+ };
111
+ const flushOnce = async () => {
112
+ for (;;) {
113
+ // The identity is read around the bearer so a login to another organization in between sends nothing.
114
+ const { identity, bearer } = await backend.credential();
115
+ await deadLetterOtherSignIns(identity);
116
+ const eligible = queue.filter((entry) => entry.identity !== null && entry.identity === identity);
117
+ if (eligible.length === 0)
118
+ return;
119
+ const sender = { url: backend.url, bearer, route, refusals, deadLetter };
120
+ const sent = await sendBatch(sender, nextBatch(eligible, route));
121
+ const settled = new Set(sent.settled);
122
+ for (let index = queue.length - 1; index >= 0; index -= 1)
123
+ if (settled.has(queue[index].id))
124
+ queue.splice(index, 1);
125
+ if (settled.size > 0)
126
+ await rewrite();
127
+ if (sent.kept)
128
+ return keptFlush(sent.status); // keep them, and everything after, for a later flush
129
+ delivered();
130
+ }
131
+ };
132
+ // Items another sign-in queued are never sent under this one: they move, whole, to the dead-letter file.
133
+ // With no sign-in at all (null) nothing moves, so a momentary read failure costs nothing.
134
+ const deadLetterOtherSignIns = async (identity) => {
135
+ if (identity === null)
136
+ return;
137
+ const foreign = queue.filter((entry) => entry.identity !== identity);
138
+ if (foreign.length === 0)
139
+ return;
140
+ await deadLetter(foreign, null, { reason: "queued under another Heedvane sign-in, or under none" });
141
+ removeFromQueue(new Set(foreign.map((entry) => entry.id)));
142
+ await rewrite();
143
+ };
144
+ // Removed only after the dead-letter append succeeded, so a full disk never loses an item.
145
+ const removeFromQueue = (gone) => {
146
+ for (let index = queue.length - 1; index >= 0; index -= 1)
147
+ if (gone.has(queue[index].id))
148
+ queue.splice(index, 1);
149
+ };
150
+ let flushing = null;
151
+ const flush = () => {
152
+ if (backend === null || now() < kept.until)
153
+ return Promise.resolve();
154
+ flushing ??= flushOnce().catch((err) => log(`[heedvane] ${route.path}: could not update the outbox after a send: ${errorMessage(err)}`)).finally(() => (flushing = null));
155
+ return flushing;
156
+ };
157
+ return { add, flush };
158
+ }
159
+ /** The outbox file's entries (a torn last line and duplicates are skipped), and whether the file ends on a line break. */
160
+ async function readOutbox(file) {
161
+ let text;
162
+ try {
163
+ text = await readFile(file, "utf8");
164
+ }
165
+ catch (err) {
166
+ if (err.code === "ENOENT")
167
+ return { entries: [], endsCleanly: true };
168
+ throw err;
169
+ }
170
+ const seen = new Set();
171
+ const entries = text.split("\n").flatMap((line) => {
172
+ const entry = parseEntry(line);
173
+ if (entry === null || seen.has(entry.id))
174
+ return [];
175
+ seen.add(entry.id);
176
+ return [entry];
177
+ });
178
+ return { entries, endsCleanly: text === "" || text.endsWith("\n") };
179
+ }
180
+ function parseEntry(line) {
181
+ if (line.trim() === "")
182
+ return null;
183
+ try {
184
+ const value = JSON.parse(line);
185
+ return isRecord(value) && typeof value["id"] === "string" && "item" in value
186
+ ? { id: value["id"], item: value["item"], identity: typeof value["identity"] === "string" ? value["identity"] : null }
187
+ : null;
188
+ }
189
+ catch {
190
+ return null; // a torn append: the item was never acknowledged as queued
191
+ }
192
+ }
193
+ /** The queue's next batch: at most the route's items and bytes, but always at least one. */
194
+ function nextBatch(queue, route) {
195
+ const batch = [];
196
+ let bytes = 0;
197
+ for (const entry of queue.slice(0, route.maxItems)) {
198
+ const size = Buffer.byteLength(JSON.stringify(entry.item));
199
+ if (batch.length > 0 && bytes + size > route.maxBytes)
200
+ break;
201
+ batch.push(entry);
202
+ bytes += size;
203
+ }
204
+ return batch;
205
+ }
206
+ const kept = (status) => ({ settled: [], kept: true, status });
207
+ async function sendBatch(sender, entries) {
208
+ const { route } = sender;
209
+ const answer = await callHeedvaneAs(sender.url, sender.bearer, { method: "POST", path: route.path, body: { [route.key]: entries.map((entry) => entry.item) }, timeoutMs: SEND_TIMEOUT_MS });
210
+ if (answer === null)
211
+ return kept(null);
212
+ if (answer.status >= 200 && answer.status < 300)
213
+ return { settled: entries.map((entry) => entry.id), kept: false };
214
+ if (answer.status < 400 || answer.status >= 500 || KEEP_STATUSES.has(answer.status))
215
+ return kept(answer.status);
216
+ return settleRefused(sender, { entries, status: answer.status, json: answer.json });
217
+ }
218
+ /** A refusing 4xx: dead-letter what the answer names, else split until the refused item is alone. */
219
+ async function settleRefused(sender, refusal) {
220
+ const { entries, status, json } = refusal;
221
+ if (entries.length === 1)
222
+ return refusedAlone(sender, refusal);
223
+ const named = status === 400 ? rejectedIndexes(json, sender.route.key, entries.length) : new Set();
224
+ if (named.size > 0) {
225
+ const bad = entries.filter((_, index) => named.has(index));
226
+ await sender.deadLetter(bad, status, json);
227
+ const rest = entries.filter((_, index) => !named.has(index));
228
+ const sent = rest.length === 0 ? { settled: [], kept: false } : await sendBatch(sender, rest);
229
+ return { ...sent, settled: [...bad.map((entry) => entry.id), ...sent.settled] };
230
+ }
231
+ const half = Math.ceil(entries.length / 2);
232
+ const first = await sendBatch(sender, entries.slice(0, half));
233
+ if (first.kept)
234
+ return first;
235
+ const second = await sendBatch(sender, entries.slice(half));
236
+ return { ...second, settled: [...first.settled, ...second.settled] };
237
+ }
238
+ async function refusedAlone(sender, refusal) {
239
+ const entry = refusal.entries[0];
240
+ const refused = (sender.refusals.get(entry.id) ?? 0) + 1;
241
+ if (refused < MAX_REFUSALS) {
242
+ sender.refusals.set(entry.id, refused);
243
+ return kept(refusal.status);
244
+ }
245
+ sender.refusals.delete(entry.id);
246
+ await sender.deadLetter([entry], refusal.status, refusal.json);
247
+ return { settled: [entry.id], kept: false };
248
+ }
249
+ /** The item indexes a 400's zod `issues` point at (`[key, i, …]`). */
250
+ function rejectedIndexes(json, key, count) {
251
+ const issues = isRecord(json) && Array.isArray(json["issues"]) ? json["issues"] : [];
252
+ const indexes = issues.flatMap((issue) => {
253
+ const path = isRecord(issue) && Array.isArray(issue["path"]) ? issue["path"] : [];
254
+ return path[0] === key && Number.isInteger(path[1]) && path[1] < count ? [path[1]] : [];
255
+ });
256
+ return new Set(indexes);
257
+ }
@@ -0,0 +1,2 @@
1
+ import { type StartedListener } from "./server.js";
2
+ export declare function serveHooks(home: string, out: (line: string) => void): Promise<StartedListener>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `npx heedvane listener start`: the loopback listener wired to Heedvane. Each event is logged
3
+ * and answered with the skill check, steering, organization instructions and memory recall
4
+ * (src/reply/reply.ts), from this machine alone: no hook waits on Heedvane. The organization's
5
+ * steering policy is fetched at start and once per session, at its session start
6
+ * (src/steering/rules.ts); a session holds the policy it started with. It is cached, so a restarted
7
+ * listener enforces before its first sync. Without a credential it still runs: the skill check is
8
+ * local and the rest is skipped.
9
+ */
10
+ import { mkdir, unlink } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import writeFileAtomic from "write-file-atomic";
13
+ import { backendAt } from "../backend/credentials.js";
14
+ import { discardSteeringCaches, reconcileSignIn, signedInAsPath } from "../backend/sign-in-change.js";
15
+ import { uncappedToolInput } from "../events/normalize.js";
16
+ import { errorMessage, isRecord } from "../guards.js";
17
+ import { heedvaneConfigAt } from "../heed-file.js";
18
+ import { harnessOutput } from "../reply/harness-output.js";
19
+ import { fetchTeamMemoryMap } from "../memory/team-memory.js";
20
+ import { BACKEND_BUDGET_MS, eventRemote, replyFor } from "../reply/reply.js";
21
+ import { ALLOW } from "../reply/verdict.js";
22
+ import { fetchSteeringPolicy, instructionContext, readSteeringPolicy, saveSteeringPolicy } from "../steering/rules.js";
23
+ import { ensureEndpoint } from "./endpoint.js";
24
+ import { DELIVERIES_ROUTE, outbox } from "./forward.js";
25
+ import { startListener } from "./server.js";
26
+ const FLUSH_INTERVAL_MS = 5_000;
27
+ /** The Claude Code events whose organization instructions the dispatcher answers with when the listener is down. */
28
+ const INSTRUCTION_EVENTS = { session_start: "SessionStart", user_prompt_submit: "UserPromptSubmit" };
29
+ /** Where the dispatcher finds the pre-rendered instruction answer for one Claude Code event (dispatchers/posix.ts). */
30
+ function instructionAnswerPath(root, hookEventName) {
31
+ return join(root, "steering", `instructions-${hookEventName}.json`);
32
+ }
33
+ export async function serveHooks(home, out) {
34
+ const root = join(home, ".heed");
35
+ await ensureEndpoint(root);
36
+ const log = (message) => out(` · ${message}`);
37
+ const config = heedvaneConfigAt(root);
38
+ // Whatever an earlier sign-in left (spooled events, instructions, caches) is set aside before anything reads it or replays it.
39
+ // Failing closed: when it cannot tell whose sign-in the leftovers belong to, the listener does not start and does not replay or enforce under a guess; it says why.
40
+ await reconcileSignIn(root, config.url).catch((err) => {
41
+ out(` · [heedvane] could not check whose sign-in the spool and caches belong to (${errorMessage(err)}); the listener did not start. Fix or remove ${signedInAsPath(root)} and start it again`);
42
+ throw err;
43
+ });
44
+ // A hook never waits on a login refresh; it runs in the background and is logged when it fails.
45
+ const org = await backendAt(root, config.url, { wait: false, log });
46
+ // The policy is the sign-in's own: a cache from, or a login to, another organization never applies.
47
+ let policyFor = (await org?.identity()) ?? null;
48
+ let policy = await readSteeringPolicy(root, policyFor);
49
+ // Each session holds the policy fetched at its session start; one without (the listener started mid-session) uses the last synced.
50
+ // shortcut: a session that never ends keeps its entry until the listener restarts; upgrade if sessions pile up.
51
+ const sessionPolicies = new Map();
52
+ const deliveries = await outbox(DELIVERIES_ROUTE, { root, backend: org, log });
53
+ const ctx = {
54
+ config,
55
+ backend: org,
56
+ root,
57
+ home,
58
+ log,
59
+ policy: (session) => sessionPolicies.get(session) ?? policy,
60
+ // Sent at once: Heedvane settles an enforcement's outcome from the session's next event, so a late report misreads it.
61
+ report: (delivery) => void deliveries.add(delivery).then(deliveries.flush).catch((err) => log(`[heedvane] could not queue a steering delivery report: ${errorMessage(err)}`)),
62
+ stopBlocks: new Map(),
63
+ violations: new Map(),
64
+ sessionEnds: new Map(),
65
+ };
66
+ const syncPolicy = async () => {
67
+ if (org === null)
68
+ return;
69
+ const identity = await org.identity();
70
+ const synced = await fetchSteeringPolicy(org, BACKEND_BUDGET_MS);
71
+ if (!("policy" in synced))
72
+ return refusedSync(synced.status, log);
73
+ if ((await org.identity()) !== identity)
74
+ return; // signed in elsewhere meanwhile: that sign-in syncs its own
75
+ policy = synced.policy;
76
+ policyFor = identity;
77
+ await saveSteeringPolicy(root, synced.answer, identity);
78
+ await writeInstructionAnswers(root, policy);
79
+ // A sign-in that changed while these were written has already cleaned up; do not leave its predecessor's rules behind.
80
+ if ((await org.identity()) !== identity)
81
+ await discardSteeringCaches(root);
82
+ };
83
+ // A sync that cannot write its cache is logged, never an unhandled rejection that ends the listener.
84
+ const syncPolicySafely = () => syncPolicy().catch((err) => log(`[heedvane] could not sync the steering policy: ${errorMessage(err)}`));
85
+ await syncPolicySafely();
86
+ const started = await startListener({
87
+ root,
88
+ onEvent: async ({ envelope, body, replayed }) => {
89
+ const signedInAs = (await org?.identity()) ?? null;
90
+ if (signedInAs !== policyFor) {
91
+ policy = null;
92
+ sessionPolicies.clear();
93
+ // What was pending or counted belongs to the organization signed out of: it must not reach, or be reported under, the next.
94
+ ctx.violations.clear();
95
+ ctx.stopBlocks.clear();
96
+ policyFor = signedInAs;
97
+ void syncPolicySafely();
98
+ }
99
+ out(JSON.stringify(envelope));
100
+ // A replayed event's agent got its answer (the fallback) long ago: record it, never re-steer it.
101
+ if (replayed === true)
102
+ return {};
103
+ let teamMemory;
104
+ if (envelope.event === "session_start") {
105
+ // The one wait on Heedvane in a hook: the policy and the team memory's map, fetched together.
106
+ [, teamMemory] = await Promise.all([syncPolicySafely(), teamMemoryAtStart(org, envelope)]);
107
+ if (policy !== null)
108
+ sessionPolicies.set(envelope.session, policy);
109
+ }
110
+ else if (envelope.event === "session_end")
111
+ sessionPolicies.delete(envelope.session);
112
+ return replyFor(envelope, ctx, { toolInput: isRecord(body) ? uncappedToolInput(body) : undefined, teamMemory });
113
+ },
114
+ log,
115
+ });
116
+ if (org !== null) {
117
+ const flush = () => void deliveries.flush();
118
+ const timer = setInterval(flush, FLUSH_INTERVAL_MS);
119
+ started.server.on("close", () => {
120
+ clearInterval(timer);
121
+ flush();
122
+ });
123
+ }
124
+ if (org === null)
125
+ log("not signed in (npx heedvane login) and no organization key: steering is off");
126
+ return started;
127
+ }
128
+ /** The map of the session's repository, or nothing to ask without a credential or a checkout origin. */
129
+ async function teamMemoryAtStart(org, envelope) {
130
+ const gitOrigin = org === null ? "" : await eventRemote(envelope);
131
+ return org === null || gitOrigin === "" ? undefined : fetchTeamMemoryMap(org, gitOrigin);
132
+ }
133
+ /** A refused sync keeps the cached policy; a refused credential is said loudly, every sync. */
134
+ function refusedSync(status, log) {
135
+ if (status === 401)
136
+ log("[heedvane] HEEDVANE REFUSED THIS MACHINE'S CREDENTIAL (401): steering rules and skills are not reaching Heedvane. Run npx heedvane login again, or on CI save an organization key with npx heedvane auth org");
137
+ else if (status === 403)
138
+ log("[heedvane] Heedvane does not serve steering to this organization (403): the plan or the credential's access does not include it");
139
+ }
140
+ /**
141
+ * Pre-render the organization instructions as the Claude Code answers the dispatcher prints when the
142
+ * listener cannot answer, so they are injected even then; removed when there are none.
143
+ */
144
+ async function writeInstructionAnswers(root, policy) {
145
+ await mkdir(join(root, "steering"), { recursive: true, mode: 0o700 });
146
+ for (const [event, hookEventName] of Object.entries(INSTRUCTION_EVENTS)) {
147
+ const path = instructionAnswerPath(root, hookEventName);
148
+ const envelope = { v: 1, event, agent: "claude", session: "", pane: "", receivedAt: "", payload: {} };
149
+ const answer = harnessOutput(envelope, { ...ALLOW, context: instructionContext(policy, event) });
150
+ if (Object.keys(answer).length > 0)
151
+ await writeFileAtomic(path, JSON.stringify(answer), { mode: 0o600 });
152
+ else
153
+ await unlink(path).catch(() => undefined); // absent already: nothing to inject
154
+ }
155
+ }
@@ -0,0 +1,48 @@
1
+ import type { IncomingMessage, Server } from "node:http";
2
+ import type { Envelope } from "../events/contract.js";
3
+ /** Agent ids accepted on `/hook/<agentId>`. */
4
+ export declare const KNOWN_AGENTS: ReadonlySet<string>;
5
+ /** Request body cap: 5 MB. */
6
+ export declare const MAX_BODY_BYTES = 5242880;
7
+ /** The hook's stdout JSON for the agent, or nothing for `{}`. */
8
+ export type HookReply = Record<string, unknown> | void;
9
+ /** What the listener hands the consumer for each accepted POST. */
10
+ export interface HookEventContext {
11
+ agent: string;
12
+ body: unknown;
13
+ /** The canonical envelope every ingest path emits (normalized at the door). */
14
+ envelope: Envelope;
15
+ headers: IncomingMessage["headers"];
16
+ /** A spooled event replayed at start: record it, but its agent stopped waiting for an answer long ago. */
17
+ replayed?: boolean;
18
+ }
19
+ export interface StartListenerOptions {
20
+ /** Heed root directory containing `endpoint.env`. */
21
+ root: string;
22
+ /** Called (and awaited) for every accepted event. */
23
+ onEvent: (event: HookEventContext) => HookReply | Promise<HookReply>;
24
+ /** Warning sink; defaults to console.log. */
25
+ log?: (message: string) => void;
26
+ /** Override the known-agent table (defaults to KNOWN_AGENTS). */
27
+ knownAgents?: ReadonlySet<string>;
28
+ }
29
+ export interface StartedListener {
30
+ server: Server;
31
+ /** The actual bound port (useful when endpoint.env said 0). */
32
+ port: number;
33
+ /** Spooled events replayed through `onEvent` at start. */
34
+ replayed: number;
35
+ }
36
+ /**
37
+ * Constant-time token comparison. Both sides are hashed first so the buffers
38
+ * always match in length (timingSafeEqual throws on length mismatch, and a
39
+ * length-leaking early return would defeat the point).
40
+ */
41
+ export declare function safeTokenEqual(a: string, b: string): boolean;
42
+ /**
43
+ * Start the loopback hook listener: binds 127.0.0.1 only (never 0.0.0.0),
44
+ * serves POST /hook/<agentId>, auths via x-heed-agent-hook-token against
45
+ * endpoint.env. Refuses to start (with remediation) when endpoint.env is
46
+ * missing or has no TOKEN.
47
+ */
48
+ export declare function startListener(options: StartListenerOptions): Promise<StartedListener>;