heedvane 0.3.3 → 0.3.4

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 (60) hide show
  1. package/README.md +41 -9
  2. package/dist/backend/credentials.d.ts +2 -0
  3. package/dist/backend/credentials.js +13 -0
  4. package/dist/backend/repo-remote.js +2 -1
  5. package/dist/backend/sign-in-change.js +3 -2
  6. package/dist/cli.d.ts +2 -1
  7. package/dist/cli.js +325 -118
  8. package/dist/clients/definitions.d.ts +7 -3
  9. package/dist/clients/definitions.js +27 -7
  10. package/dist/clients/hermes-plugin.d.ts +5 -20
  11. package/dist/clients/hermes-plugin.js +97 -71
  12. package/dist/clients/hook-files.d.ts +22 -0
  13. package/dist/clients/hook-files.js +39 -0
  14. package/dist/clients/pi-extension.d.ts +9 -0
  15. package/dist/clients/pi-extension.js +41 -0
  16. package/dist/commands/heedvane.js +18 -3
  17. package/dist/commands/mcp-stub.d.ts +1 -0
  18. package/dist/commands/mcp-stub.js +44 -0
  19. package/dist/dispatchers/posix.d.ts +16 -19
  20. package/dist/dispatchers/posix.js +71 -91
  21. package/dist/doctor.d.ts +2 -0
  22. package/dist/doctor.js +96 -28
  23. package/dist/guard.d.ts +73 -0
  24. package/dist/guard.js +302 -0
  25. package/dist/heed-file.d.ts +5 -2
  26. package/dist/heed-file.js +7 -7
  27. package/dist/hook-fallback.d.ts +73 -0
  28. package/dist/hook-fallback.js +61 -0
  29. package/dist/installer.d.ts +5 -5
  30. package/dist/installer.js +13 -12
  31. package/dist/listener/background.d.ts +48 -4
  32. package/dist/listener/background.js +153 -19
  33. package/dist/listener/endpoint.d.ts +26 -3
  34. package/dist/listener/endpoint.js +55 -10
  35. package/dist/listener/forward.d.ts +7 -1
  36. package/dist/listener/forward.js +40 -12
  37. package/dist/listener/serve.d.ts +37 -0
  38. package/dist/listener/serve.js +196 -32
  39. package/dist/listener/server.d.ts +9 -0
  40. package/dist/listener/server.js +59 -22
  41. package/dist/listener/spool.d.ts +2 -17
  42. package/dist/listener/spool.js +6 -3
  43. package/dist/mcp.d.ts +23 -4
  44. package/dist/mcp.js +124 -22
  45. package/dist/plan.d.ts +35 -6
  46. package/dist/plan.js +125 -40
  47. package/dist/render.js +2 -1
  48. package/dist/reply/harness-output.d.ts +9 -0
  49. package/dist/reply/harness-output.js +13 -0
  50. package/dist/skill/content.d.ts +2 -2
  51. package/dist/skill/content.js +6 -3
  52. package/dist/status.js +1 -1
  53. package/dist/steering/rules.js +4 -2
  54. package/dist/uninstall.d.ts +7 -2
  55. package/dist/uninstall.js +49 -44
  56. package/package.json +10 -3
  57. package/skills/heedvane-hooks/SKILL.md +3 -3
  58. package/skills/heedvane-hooks/references/events.md +3 -0
  59. package/dist/dispatchers/windows.d.ts +0 -15
  60. package/dist/dispatchers/windows.js +0 -50
@@ -7,22 +7,26 @@
7
7
  * listener enforces before its first sync. Without a credential it still runs: the skill check is
8
8
  * local and the rest is skipped.
9
9
  */
10
- import { mkdir, unlink } from "node:fs/promises";
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { mkdir, rm, unlink } from "node:fs/promises";
11
13
  import { join } from "node:path";
12
14
  import writeFileAtomic from "write-file-atomic";
13
- import { backendAt } from "../backend/credentials.js";
15
+ import { backendAt, signInIdentity } from "../backend/credentials.js";
14
16
  import { discardSteeringCaches, reconcileSignIn, signedInAsPath } from "../backend/sign-in-change.js";
15
17
  import { uncappedToolInput } from "../events/normalize.js";
16
18
  import { errorMessage, isRecord } from "../guards.js";
17
- import { heedvaneConfigAt } from "../heed-file.js";
19
+ import { START_LOCK } from "../hook-fallback.js";
20
+ import { DEFAULT_HEED_YAML_TEMPLATE, loadHeedFile, readHeedFileAt } from "../heed-file.js";
18
21
  import { harnessOutput } from "../reply/harness-output.js";
19
22
  import { fetchTeamMemoryMap } from "../memory/team-memory.js";
20
23
  import { BACKEND_BUDGET_MS, eventRemote, replyFor } from "../reply/reply.js";
21
24
  import { ALLOW } from "../reply/verdict.js";
22
25
  import { fetchSteeringPolicy, instructionContext, readSteeringPolicy, saveSteeringPolicy } from "../steering/rules.js";
23
- import { ensureEndpoint } from "./endpoint.js";
26
+ import { setTimeout as delay } from "node:timers/promises";
27
+ import { ensureEndpoint, freeLoopbackPort, HEEDVANE_VERSION, listenerVerdict, probeListener, readEndpoint, writeEndpoint } from "./endpoint.js";
24
28
  import { DELIVERIES_ROUTE, outbox } from "./forward.js";
25
- import { startListener } from "./server.js";
29
+ import { SHUTDOWN_ROUTE, startListener, STOP_REASON_HEADER } from "./server.js";
26
30
  const FLUSH_INTERVAL_MS = 5_000;
27
31
  /** The Claude Code events whose organization instructions the dispatcher answers with when the listener is down. */
28
32
  const INSTRUCTION_EVENTS = { session_start: "SessionStart", user_prompt_submit: "UserPromptSubmit" };
@@ -30,11 +34,31 @@ const INSTRUCTION_EVENTS = { session_start: "SessionStart", user_prompt_submit:
30
34
  function instructionAnswerPath(root, hookEventName) {
31
35
  return join(root, "steering", `instructions-${hookEventName}.json`);
32
36
  }
37
+ /** A listener of this version already runs at the port with the settings this one would run (or a newer one runs): nothing to start. */
38
+ export class ListenerAlreadyRuns extends Error {
39
+ version;
40
+ constructor(version, newer) {
41
+ super(newer ? `a newer Heedvane listener (${version}) already runs` : `a Heedvane listener (${version}) already runs with these settings`);
42
+ this.name = "ListenerAlreadyRuns";
43
+ this.version = version;
44
+ }
45
+ }
46
+ /**
47
+ * The listener for `home`, and the one place a listener starts (`listener start`, which `ensureListener` and the hooks
48
+ * run). It reads heed.yaml once, prepares everything a hook's answer needs, and only then takes the port: a listener
49
+ * there that {@link listenerVerdict} says to keep makes it throw {@link ListenerAlreadyRuns}; any other is asked to stop,
50
+ * and this one binds as soon as the port is free. Rejects when the port stays taken.
51
+ */
33
52
  export async function serveHooks(home, out) {
34
53
  const root = join(home, ".heed");
35
- await ensureEndpoint(root);
54
+ const served = await ensureEndpoint(root);
36
55
  const log = (message) => out(` · ${message}`);
37
- const config = heedvaneConfigAt(root);
56
+ const settings = await settingsToRun(root);
57
+ if (settings.unparseable === undefined)
58
+ writeFileAtomic.sync(lastGoodPath(root), settings.text, { mode: 0o600 });
59
+ else
60
+ log(`[heedvane] ${settings.unparseable}; the listener runs with the settings it last started with. Fix it, then: npx heedvane listener restart`);
61
+ const config = settings.config;
38
62
  // Whatever an earlier sign-in left (spooled events, instructions, caches) is set aside before anything reads it or replays it.
39
63
  // 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
64
  await reconcileSignIn(root, config.url).catch((err) => {
@@ -44,12 +68,18 @@ export async function serveHooks(home, out) {
44
68
  // A hook never waits on a login refresh; it runs in the background and is logged when it fails.
45
69
  const org = await backendAt(root, config.url, { wait: false, log });
46
70
  // 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);
71
+ let policy = await readSteeringPolicy(root, (await org?.identity()) ?? null);
49
72
  // Each session holds the policy fetched at its session start; one without (the listener started mid-session) uses the last synced.
50
73
  // shortcut: a session that never ends keeps its entry until the listener restarts; upgrade if sessions pile up.
51
74
  const sessionPolicies = new Map();
52
- const deliveries = await outbox(DELIVERIES_ROUTE, { root, backend: org, log });
75
+ // The outbox file is rewritten whole, so it is read and written only once the listener this one replaces has exited.
76
+ let outboxFree = () => undefined;
77
+ const fileFree = new Promise((resolve) => (outboxFree = resolve));
78
+ const deliveries = outbox(DELIVERIES_ROUTE, { root, backend: org, log, fileFree })
79
+ .catch((err) => {
80
+ log(`[heedvane] could not open the steering delivery outbox, so this listener sends no reports: ${errorMessage(err)}`);
81
+ return null;
82
+ });
53
83
  const ctx = {
54
84
  config,
55
85
  backend: org,
@@ -58,7 +88,10 @@ export async function serveHooks(home, out) {
58
88
  log,
59
89
  policy: (session) => sessionPolicies.get(session) ?? policy,
60
90
  // 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)}`)),
91
+ report: (delivery) => void deliveries.then((box) => box?.add(delivery).then(box.flush)).catch((err) => {
92
+ log(`[heedvane] could not queue a steering delivery report: ${errorMessage(err)}`);
93
+ return undefined; // a hook's answer never waits on its report
94
+ }),
62
95
  stopBlocks: new Map(),
63
96
  violations: new Map(),
64
97
  sessionEnds: new Map(),
@@ -73,7 +106,6 @@ export async function serveHooks(home, out) {
73
106
  if ((await org.identity()) !== identity)
74
107
  return; // signed in elsewhere meanwhile: that sign-in syncs its own
75
108
  policy = synced.policy;
76
- policyFor = identity;
77
109
  await saveSteeringPolicy(root, synced.answer, identity);
78
110
  await writeInstructionAnswers(root, policy);
79
111
  // A sign-in that changed while these were written has already cleaned up; do not leave its predecessor's rules behind.
@@ -82,20 +114,17 @@ export async function serveHooks(home, out) {
82
114
  };
83
115
  // A sync that cannot write its cache is logged, never an unhandled rejection that ends the listener.
84
116
  const syncPolicySafely = () => syncPolicy().catch((err) => log(`[heedvane] could not sync the steering policy: ${errorMessage(err)}`));
85
- await syncPolicySafely();
117
+ // Without a cached policy the first answers would miss the organization's rules: fetch it before taking the port.
118
+ const cached = policy !== null;
119
+ if (!cached)
120
+ await syncPolicySafely();
121
+ const handover = takeOver({ root, served, settings: settings.hash, log });
86
122
  const started = await startListener({
87
123
  root,
124
+ endpoint: served,
125
+ settings: settings.hash,
126
+ onAddressInUse: handover.onAddressInUse,
88
127
  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
128
  out(JSON.stringify(envelope));
100
129
  // A replayed event's agent got its answer (the fallback) long ago: record it, never re-steer it.
101
130
  if (replayed === true)
@@ -103,7 +132,7 @@ export async function serveHooks(home, out) {
103
132
  let teamMemory;
104
133
  if (envelope.event === "session_start") {
105
134
  // The one wait on Heedvane in a hook: the policy and the team memory's map, fetched together.
106
- [, teamMemory] = await Promise.all([syncPolicySafely(), config.dataMode === "metrics-only" ? undefined : teamMemoryAtStart(org, envelope)]);
135
+ [, teamMemory] = await Promise.all([syncPolicySafely(), ctx.config.dataMode === "metrics-only" ? undefined : teamMemoryAtStart(org, envelope)]);
107
136
  if (policy !== null)
108
137
  sessionPolicies.set(envelope.session, policy);
109
138
  }
@@ -112,19 +141,154 @@ export async function serveHooks(home, out) {
112
141
  return replyFor(envelope, ctx, { toolInput: isRecord(body) ? uncappedToolInput(body) : undefined, teamMemory });
113
142
  },
114
143
  log,
144
+ }).catch(async (err) => {
145
+ // A listener it keeps already runs: the hooks may start one at once should that one end.
146
+ if (err instanceof ListenerAlreadyRuns)
147
+ await rm(join(root, START_LOCK), { recursive: true, force: true });
148
+ throw err;
149
+ });
150
+ void handover.exited().then(outboxFree);
151
+ if (cached)
152
+ void syncPolicySafely();
153
+ const flush = () => void deliveries.then((box) => box?.flush());
154
+ // A listener steps down once endpoint.env no longer names it (another start moved, or Heedvane was removed): no hook
155
+ // reaches it there, and its last reports go out as it closes. A read that fails keeps it running.
156
+ const stepDownWhenReplaced = async () => {
157
+ const named = await readEndpoint(root).catch(() => undefined);
158
+ if (named === undefined || (named !== null && named.port === started.port && named.token === served.token))
159
+ return;
160
+ log(`[heedvane] endpoint.env names ${named === null ? "no listener" : `127.0.0.1:${named.port}`}, not this listener (${started.port}); it stops`);
161
+ started.server.close();
162
+ started.server.closeIdleConnections();
163
+ };
164
+ const timer = setInterval(() => {
165
+ flush();
166
+ void stepDownWhenReplaced();
167
+ }, FLUSH_INTERVAL_MS).unref();
168
+ started.server.on("close", () => {
169
+ clearInterval(timer);
170
+ flush();
115
171
  });
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
172
  if (org === null)
125
173
  log("not signed in (npx heedvane login) and no organization key: steering is off");
174
+ // Listening: the hooks may start a fresh listener at once when this one exits.
175
+ await rm(join(root, START_LOCK), { recursive: true, force: true });
126
176
  return started;
127
177
  }
178
+ /** How long a listener that was asked to stop gets to free the port, and then to exit (it may be sending reports). */
179
+ const HANDOVER_PORT_MS = 5_000;
180
+ const HANDOVER_EXIT_MS = 15_000;
181
+ const HANDOVER_POLL_MS = 20;
182
+ /**
183
+ * Taking the port from the listener that holds it: each time the bind finds the port taken, the listener there is
184
+ * judged by {@link listenerVerdict}; one to keep ends this start ({@link ListenerAlreadyRuns}), any other is asked once to
185
+ * stop. A port no Heedvane listener ever answered on is another program's: this one moves to a free port in
186
+ * endpoint.env, which the hooks read on every event. `exited` resolves once the stopped listener's process is gone, so
187
+ * its last writes are on disk.
188
+ */
189
+ function takeOver({ root, served, settings, log }) {
190
+ const endpoint = { ...served };
191
+ const deadline = Date.now() + HANDOVER_PORT_MS;
192
+ let asked = false;
193
+ let seen = false;
194
+ let pid = null;
195
+ const onAddressInUse = async () => {
196
+ const running = await probeListener(endpoint);
197
+ seen ||= running !== null;
198
+ const verdict = listenerVerdict(running, settings);
199
+ if (running !== null && (verdict === "keep" || verdict === "newer"))
200
+ throw new ListenerAlreadyRuns(running.version, verdict === "newer");
201
+ if (running !== null && !asked) {
202
+ asked = true;
203
+ pid = (await askToStop(endpoint, verdict === "replace" ? `replaced by version ${HEEDVANE_VERSION}` : "heed.yaml or the sign-in changed")).pid;
204
+ }
205
+ // A listener of this machine too busy to answer in 750 ms counts as another program too: it steps down once it sees endpoint.env name this one.
206
+ if (!seen)
207
+ return movedToFreePort();
208
+ if (Date.now() > deadline)
209
+ return null;
210
+ await delay(HANDOVER_POLL_MS);
211
+ return endpoint.port;
212
+ };
213
+ const movedToFreePort = async () => {
214
+ const taken = endpoint.port;
215
+ // Another start may have moved since this one read endpoint.env: a listener to keep on the port it names ends this one.
216
+ const named = await readEndpoint(root).catch(() => null);
217
+ const there = named !== null && named.port !== taken ? await probeListener(named) : null;
218
+ const verdict = listenerVerdict(there, settings);
219
+ if (there !== null && (verdict === "keep" || verdict === "newer"))
220
+ throw new ListenerAlreadyRuns(there.version, verdict === "newer");
221
+ endpoint.port = await freeLoopbackPort();
222
+ await writeEndpoint(root, endpoint);
223
+ log(`[heedvane] nothing on 127.0.0.1:${taken} answered as this machine's listener (another program, or a listener too busy to answer), so this one moves to ${endpoint.port} (endpoint.env)`);
224
+ return endpoint.port;
225
+ };
226
+ const exited = async () => {
227
+ // shortcut: a listener older than 0.3.4 does not name its pid, so it is not waited for; its last reports may be sent twice (Heedvane drops copies by id).
228
+ if (pid === null)
229
+ return;
230
+ for (const until = Date.now() + HANDOVER_EXIT_MS; isAlive(pid); await delay(HANDOVER_POLL_MS)) {
231
+ if (Date.now() > until)
232
+ return log(`[heedvane] the listener this one replaced (pid ${pid}) has not exited after ${HANDOVER_EXIT_MS / 1000} s; its outbox is read anyway`);
233
+ }
234
+ };
235
+ return { onAddressInUse, exited };
236
+ }
237
+ /** Asks the listener at `endpoint` to stop: its answer's status (null without one) and the pid it names (0.3.4 on). */
238
+ export async function askToStop(endpoint, reason) {
239
+ try {
240
+ const answer = await fetch(`http://127.0.0.1:${endpoint.port}${SHUTDOWN_ROUTE}`, { method: "POST", headers: { "x-heed-agent-hook-token": endpoint.token, [STOP_REASON_HEADER]: reason }, signal: AbortSignal.timeout(HANDOVER_PORT_MS) });
241
+ const body = (await answer.json().catch(() => null));
242
+ return { status: answer.status, pid: typeof body?.pid === "number" ? body.pid : null };
243
+ }
244
+ catch {
245
+ return { status: null, pid: null }; // no answer: whoever asked waits for the port, and gives up
246
+ }
247
+ }
248
+ /** Whether process `pid` still runs. */
249
+ export function isAlive(pid) {
250
+ try {
251
+ process.kill(pid, 0);
252
+ return true;
253
+ }
254
+ catch (err) {
255
+ return err.code === "EPERM"; // it runs, as another user
256
+ }
257
+ }
258
+ /** Where a listener keeps the heed.yaml it last started with, which it runs while heed.yaml does not parse. */
259
+ export function lastGoodPath(root) {
260
+ return join(root, "heed.yaml.last-good");
261
+ }
262
+ /**
263
+ * The settings a listener started now would run: heed.yaml (the template's when there is none), else, when it does not
264
+ * parse, the copy a listener last started with. Throws when neither parses. The hash covers the same text that was
265
+ * parsed and the sign-in, so a running listener's report compares with it exactly.
266
+ */
267
+ export async function settingsToRun(root) {
268
+ const { config, text, unparseable } = heedYamlToRun(root);
269
+ const identity = await signInIdentity(root, config.url);
270
+ return { config, text, unparseable, hash: createHash("sha256").update(text).update(`\0${identity}`).digest("hex").slice(0, 16) };
271
+ }
272
+ function heedYamlToRun(root) {
273
+ const heedYaml = join(root, "heed.yaml");
274
+ try {
275
+ const { heedFile, text } = readHeedFileAt(heedYaml);
276
+ return { config: heedFile.heedvane, text };
277
+ }
278
+ catch (err) {
279
+ const { code, syscall } = err;
280
+ // The template's text, so the copy kept as the last good one parses when a heed.yaml written later does not.
281
+ if (code === "ENOENT")
282
+ return { config: loadHeedFile(DEFAULT_HEED_YAML_TEMPLATE).heedvane, text: DEFAULT_HEED_YAML_TEMPLATE };
283
+ // A read error (EACCES, EISDIR) has a system call; a parse error does not.
284
+ const unparseable = `${heedYaml} ${syscall === undefined ? `does not parse (${errorMessage(err)})` : `cannot be read (${code})`}`;
285
+ // shortcut: with no last-good copy (a first heed.yaml that never parsed) no listener starts and the hooks retry once a minute; start on the template if that case shows up.
286
+ if (!existsSync(lastGoodPath(root)))
287
+ throw new Error(`${unparseable}, and there is no last good copy of it to run`, { cause: err });
288
+ const text = readFileSync(lastGoodPath(root), "utf8");
289
+ return { config: loadHeedFile(text).heedvane, text, unparseable };
290
+ }
291
+ }
128
292
  /** The map of the session's repository, or nothing to ask without a credential or a checkout origin. */
129
293
  async function teamMemoryAtStart(org, envelope) {
130
294
  const gitOrigin = org === null ? "" : await eventRemote(envelope);
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, Server } from "node:http";
2
+ import { type Endpoint } from "./endpoint.js";
2
3
  import type { Envelope } from "../events/contract.js";
3
4
  /** Agent ids accepted on `/hook/<agentId>`. */
4
5
  export declare const KNOWN_AGENTS: ReadonlySet<string>;
@@ -25,6 +26,12 @@ export interface StartListenerOptions {
25
26
  log?: (message: string) => void;
26
27
  /** Override the known-agent table (defaults to KNOWN_AGENTS). */
27
28
  knownAgents?: ReadonlySet<string>;
29
+ /** The port and token to serve instead of reading endpoint.env (serveHooks passes the one it ensured; port 0 takes any free one). */
30
+ endpoint?: Endpoint;
31
+ /** The hash of the settings the listener runs (serve.ts `settingsToRun`), named in its probe answer. */
32
+ settings?: string;
33
+ /** The port is taken: resolves true to bind again (the listener there was asked to stop), false to give up. */
34
+ onAddressInUse?: () => Promise<number | null>;
28
35
  }
29
36
  export interface StartedListener {
30
37
  server: Server;
@@ -41,6 +48,8 @@ export interface StartedListener {
41
48
  export declare function safeTokenEqual(a: string, b: string): boolean;
42
49
  /** `POST /shutdown` with the token: uninstall stops the listener it no longer has hooks for. */
43
50
  export declare const SHUTDOWN_ROUTE = "/shutdown";
51
+ /** Why a command asks the listener to stop, for the one line it logs. */
52
+ export declare const STOP_REASON_HEADER = "x-heed-stop-reason";
44
53
  /**
45
54
  * Start the loopback hook listener: binds 127.0.0.1 only (never 0.0.0.0),
46
55
  * serves POST /hook/<agentId>, auths via x-heed-agent-hook-token against
@@ -1,6 +1,7 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
3
- import { endpointPath, readEndpoint } from "./endpoint.js";
3
+ import { listenerProof, PROBE_REFUSAL, PROOF_HEADER } from "../hook-fallback.js";
4
+ import { endpointPath, HEEDVANE_VERSION, readEndpoint } from "./endpoint.js";
4
5
  import { drainSpool } from "./spool.js";
5
6
  import { normalize } from "../events/normalize.js";
6
7
  import { errorMessage } from "../guards.js";
@@ -13,6 +14,7 @@ export const KNOWN_AGENTS = new Set([
13
14
  "gemini",
14
15
  "cursor",
15
16
  "opencode",
17
+ "pi",
16
18
  "qwen-code",
17
19
  "kimi",
18
20
  "droid",
@@ -73,6 +75,8 @@ function sendJson(res, status, body) {
73
75
  const ROUTE_PATTERN = /^\/hook\/([A-Za-z0-9_-]+)$/;
74
76
  /** `POST /shutdown` with the token: uninstall stops the listener it no longer has hooks for. */
75
77
  export const SHUTDOWN_ROUTE = "/shutdown";
78
+ /** Why a command asks the listener to stop, for the one line it logs. */
79
+ export const STOP_REASON_HEADER = "x-heed-stop-reason";
76
80
  /** True after answering a token-carrying `POST /shutdown`; the listener closes once the answer is sent. */
77
81
  function handledShutdown(req, res, deps) {
78
82
  if (req.method !== "POST" || new URL(req.url ?? "/", "http://127.0.0.1").pathname !== SHUTDOWN_ROUTE)
@@ -81,12 +85,14 @@ function handledShutdown(req, res, deps) {
81
85
  sendJson(res, 401, { error: "invalid or missing x-heed-agent-hook-token" });
82
86
  return true;
83
87
  }
88
+ deps.log(`[heedvane] listener stopping: ${String(req.headers[STOP_REASON_HEADER] ?? "asked to stop")}`);
84
89
  res.on("finish", deps.stop);
85
- sendJson(res, 200, { stopping: true });
90
+ // The pid: whoever takes over waits for this process to exit before it reads the files this one still writes.
91
+ sendJson(res, 200, { stopping: true, pid: process.pid });
86
92
  return true;
87
93
  }
88
94
  /** The route's agent id, or null after answering 404/405. */
89
- function routeAgent(req, res, agents) {
95
+ function routeAgent(req, res, deps) {
90
96
  const route = ROUTE_PATTERN.exec(new URL(req.url ?? "/", "http://127.0.0.1").pathname);
91
97
  if (route === null) {
92
98
  sendJson(res, 404, { error: "not found" });
@@ -94,10 +100,10 @@ function routeAgent(req, res, agents) {
94
100
  }
95
101
  const agent = route[1];
96
102
  if (req.method !== "POST") {
97
- sendJson(res, 405, { error: "method not allowed; POST only" });
103
+ sendJson(res, 405, { error: PROBE_REFUSAL, version: HEEDVANE_VERSION, settings: deps.settings });
98
104
  return null;
99
105
  }
100
- if (!agents.has(agent)) {
106
+ if (!deps.agents.has(agent)) {
101
107
  sendJson(res, 404, { error: `unknown agent route: /hook/${agent}` });
102
108
  return null;
103
109
  }
@@ -158,15 +164,16 @@ function parseJsonObject(bodyText, res) {
158
164
  return body;
159
165
  }
160
166
  /** Last-resort responder: an async failure must not hang or crash the process. */
161
- function internalErrorResponder(res) {
162
- return () => {
167
+ function internalErrorResponder(res, log) {
168
+ return (err) => {
169
+ log(`[heedvane] could not answer a hook (500): ${errorMessage(err)}`); // the hooks' notice points at this log
163
170
  sendJson(res, 500, { error: "internal error" });
164
171
  };
165
172
  }
166
173
  async function handleRequest(req, res, deps) {
167
174
  if (handledShutdown(req, res, deps))
168
175
  return;
169
- const agent = routeAgent(req, res, deps.agents);
176
+ const agent = routeAgent(req, res, deps);
170
177
  if (agent === null)
171
178
  return;
172
179
  if (!hasValidToken(req, deps.token)) {
@@ -186,9 +193,19 @@ async function handleRequest(req, res, deps) {
186
193
  return;
187
194
  // Normalize at the door: every accepted event leaves as the canonical
188
195
  // envelope — one taxonomy for all ingest paths (listener, spool replay).
189
- const envelope = normalize(agent, body, { id: eventId(req.headers["x-heed-event-id"]) });
196
+ const id = eventId(req.headers["x-heed-event-id"]);
197
+ // The fallback asks again with the hook's event id when the first answer did not reach it (a timeout, a takeover):
198
+ // both asks get the answer of one steering of the event.
199
+ const answer = deps.answers.get(id) ?? answerKept(deps.answers, id, Promise.resolve(deps.onEvent({ agent, body, envelope: normalize(agent, body, { id }), headers: req.headers })));
190
200
  // The dispatcher prints this body as the agent's hook output; `{}` lets the agent continue.
191
- sendJson(res, 200, (await deps.onEvent({ agent, body, envelope, headers: req.headers })) ?? {});
201
+ sendJson(res, 200, (await answer) ?? {});
202
+ }
203
+ /** How long an event's answer is kept for its hook's second ask, which comes within seconds. */
204
+ const ANSWER_KEPT_MS = 30_000;
205
+ function answerKept(answers, id, answer) {
206
+ answers.set(id, answer);
207
+ setTimeout(() => answers.delete(id), ANSWER_KEPT_MS).unref();
208
+ return answer;
192
209
  }
193
210
  /**
194
211
  * Start the loopback hook listener: binds 127.0.0.1 only (never 0.0.0.0),
@@ -197,7 +214,7 @@ async function handleRequest(req, res, deps) {
197
214
  * missing or has no TOKEN.
198
215
  */
199
216
  export async function startListener(options) {
200
- const endpoint = await readEndpoint(options.root);
217
+ const endpoint = options.endpoint ?? (await readEndpoint(options.root));
201
218
  if (endpoint === null) {
202
219
  throw new Error(`No endpoint.env at ${endpointPath(options.root)} — the listener has no port/token to serve. ` +
203
220
  `Remediation: run "npx heedvane setup" to write ${endpointPath(options.root)} ` +
@@ -210,8 +227,10 @@ export async function startListener(options) {
210
227
  }
211
228
  const log = options.log ?? ((message) => console.log(message));
212
229
  const agents = options.knownAgents ?? KNOWN_AGENTS;
230
+ const proof = await listenerProof(endpoint.token);
213
231
  const server = createServer((req, res) => {
214
- handleRequest(req, res, deps).catch(internalErrorResponder(res));
232
+ res.setHeader(PROOF_HEADER, proof); // on every answer: this machine's listener, for the hooks and the start's probe
233
+ handleRequest(req, res, deps).catch(internalErrorResponder(res, log));
215
234
  });
216
235
  const deps = {
217
236
  agents,
@@ -219,24 +238,20 @@ export async function startListener(options) {
219
238
  onEvent: options.onEvent,
220
239
  log,
221
240
  warned: new Set(),
241
+ answers: new Map(),
242
+ settings: options.settings,
243
+ // Hooks already in flight get their answers; only idle keep-alive connections close now.
222
244
  stop: () => {
223
245
  server.close();
224
- server.closeAllConnections();
246
+ server.closeIdleConnections();
225
247
  },
226
248
  };
227
249
  // Node-level slowloris hardening (covers stalls before the request handler
228
250
  // fires); requestTimeout must be larger than headersTimeout.
229
251
  server.requestTimeout = REQUEST_TIMEOUT_MS;
230
252
  server.headersTimeout = REQUEST_TIMEOUT_MS - 500;
231
- await new Promise((resolve, reject) => {
232
- const onListenError = (err) => reject(err);
233
- server.once("error", onListenError);
234
- server.listen(endpoint.port, "127.0.0.1", () => {
235
- server.off("error", onListenError);
236
- server.on("error", (err) => log(`[heedvane] listener error: ${err.message}`));
237
- resolve();
238
- });
239
- });
253
+ await listenOn(server, endpoint.port, options.onAddressInUse);
254
+ server.on("error", (err) => log(`[heedvane] listener error: ${err.message}`));
240
255
  const address = server.address();
241
256
  const port = address !== null && typeof address === "object" ? address.port : endpoint.port;
242
257
  // Events the dispatchers spooled while no listener was up enter through the same door.
@@ -261,6 +276,28 @@ export async function startListener(options) {
261
276
  });
262
277
  return { server, port, replayed };
263
278
  }
279
+ /** Binds `port`; while another process holds it, `onAddressInUse` names the port to try next, or null to give up. */
280
+ async function listenOn(server, port, onAddressInUse) {
281
+ for (let next = port;;) {
282
+ try {
283
+ return await bind(server, next);
284
+ }
285
+ catch (err) {
286
+ next = err.code === "EADDRINUSE" ? await onAddressInUse?.() : null;
287
+ if (next === null || next === undefined)
288
+ throw err;
289
+ }
290
+ }
291
+ }
292
+ function bind(server, port) {
293
+ return new Promise((resolve, reject) => {
294
+ server.once("error", reject);
295
+ server.listen(port, "127.0.0.1", () => {
296
+ server.off("error", reject);
297
+ resolve();
298
+ });
299
+ });
300
+ }
264
301
  /** The dispatcher's event id when it is a plausible one, else a fresh one (scripts older than ids). */
265
302
  function eventId(value) {
266
303
  return typeof value === "string" && /^[A-Za-z0-9-]{8,64}$/.test(value) ? value : randomUUID();
@@ -1,19 +1,4 @@
1
- /**
2
- * One spooled hook event, exactly as the dispatcher script (`spool_hook_event` in
3
- * dispatchers/posix.ts, the only writer) appends it: `payload` is the raw hook JSON text
4
- * the agent piped in, `source` the agent id. Cap and TTL are enforced by that writer.
5
- */
6
- export interface SpooledEvent {
7
- paneKey: string;
8
- tabId: string;
9
- worktreeId: string;
10
- launchToken: string;
11
- source: string;
12
- receivedAt: string;
13
- payload: string;
14
- /** The event's unique id (absent in lines from scripts older than ids). */
15
- id?: string;
16
- }
1
+ import { type SpoolRecord } from "../hook-fallback.js";
17
2
  /** Directory under the heed root that holds per-pane spool files. */
18
3
  export declare function spoolDir(root: string): string;
19
4
  /** A pane spool file, or one a drain claimed (renamed away from the dispatcher) and has not finished. */
@@ -25,4 +10,4 @@ export declare function isPaneSpoolFile(name: string): boolean;
25
10
  * next call. Lines that fail to parse are skipped (a torn append from a killed dispatcher). Returns the
26
11
  * number of events delivered.
27
12
  */
28
- export declare function drainSpool(root: string, onEvent: (event: SpooledEvent) => void | Promise<void>): Promise<number>;
13
+ export declare function drainSpool(root: string, onEvent: (event: SpoolRecord) => void | Promise<void>): Promise<number>;
@@ -1,8 +1,11 @@
1
1
  import { readdir, readFile, rename, unlink } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { SPOOL_DIR, SPOOL_FILE_PREFIX, SPOOL_FILE_SUFFIX } from "../hook-fallback.js";
4
+ // The spool's records (SpoolRecord) have two writers, with the values of src/hook-fallback.ts, cap and TTL included:
5
+ // the dispatcher script (`spool_hook_event` in dispatchers/posix.ts) and Pi's guard (src/guard.ts).
3
6
  /** Directory under the heed root that holds per-pane spool files. */
4
7
  export function spoolDir(root) {
5
- return join(root, "spool");
8
+ return join(root, SPOOL_DIR);
6
9
  }
7
10
  /** One spool line, or null for a torn append from a killed dispatcher (dropped by design). */
8
11
  function parseSpoolLine(line) {
@@ -15,7 +18,7 @@ function parseSpoolLine(line) {
15
18
  }
16
19
  /** A pane spool file, or one a drain claimed (renamed away from the dispatcher) and has not finished. */
17
20
  export function isPaneSpoolFile(name) {
18
- return name.startsWith("pane-") && (name.endsWith(".jsonl") || name.includes(".jsonl.claimed-"));
21
+ return name.startsWith(SPOOL_FILE_PREFIX) && (name.endsWith(SPOOL_FILE_SUFFIX) || name.includes(`${SPOOL_FILE_SUFFIX}.claimed-`));
19
22
  }
20
23
  /**
21
24
  * Drain every pane spool file: claim it by renaming it, so the dispatcher's next append starts a fresh
@@ -39,7 +42,7 @@ export async function drainSpool(root, onEvent) {
39
42
  for (const entry of entries.sort()) {
40
43
  if (!isPaneSpoolFile(entry))
41
44
  continue;
42
- const claimed = entry.endsWith(".jsonl") ? `${entry}.claimed-${Date.now()}-${process.pid}` : entry;
45
+ const claimed = entry.endsWith(SPOOL_FILE_SUFFIX) ? `${entry}.claimed-${Date.now()}-${process.pid}` : entry;
43
46
  const filePath = join(dir, claimed);
44
47
  if (claimed !== entry)
45
48
  await rename(join(dir, entry), filePath);
package/dist/mcp.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Heedvane's MCP connection: the one MCP entry the installer registers, named `heedvane`, pointing at
3
- * the organization's remote server (`<server>/api/mcp/<org>`), in the shapes the dashboard's setup writes
4
- * (apps/web/src/lib/agent/mcp-setup.ts `HEEDVANE_MCP_SERVER_NAME`). The agent signs in to it in the
5
- * browser on first use.
3
+ * the organization's remote server (`<server>/api/mcp/<org>`). Remote entries take the shapes the dashboard's
4
+ * setup writes (apps/web/src/lib/agent/mcp-setup.ts `HEEDVANE_MCP_SERVER_NAME`); Claude Desktop's mcp-remote bridge
5
+ * (`bridgeEntry`, with the installing Node's folder) is this CLI's own. The agent signs in in the browser on first use.
6
6
  *
7
7
  * Identity: uninstall and detection recognize an entry by its name and shape together, never by a name
8
8
  * alone: today's connection, the local helper earlier versions registered (`npx -y heedvane mcp`, under any
@@ -10,18 +10,37 @@
10
10
  */
11
11
  /** The entry's name in every agent's MCP config. */
12
12
  export declare const HEEDVANE_SERVER_NAME = "heedvane";
13
+ /** Why metrics only registers no MCP connection: its tools send questions and repository names to Heedvane. */
14
+ export declare const MCP_OFF_METRICS_ONLY = "Heedvane's MCP connection: off under Metrics only, since its tools send your questions and repository names to Heedvane";
13
15
  /** What to do when no organization is known, so no MCP connection can be registered. */
14
- export declare const MCP_NEEDS_ORG = "Heedvane's MCP connection needs your organization: run the connect command from your dashboard's Settings (it has --org), or npx heedvane connect --org <id>";
16
+ export declare const MCP_NEEDS_ORG = "Heedvane's MCP connection needs your organization: run the connect command from your dashboard's Settings (it has --org), or npx -y heedvane connect --org <id>";
17
+ /** How to add the organization: `setup` cannot, since it compiles heed.yaml as it is. */
18
+ export declare const ORG_REMEDIATION = "run the connect command from your dashboard's Settings, or npx -y heedvane connect --org <organization id>";
15
19
  /** Uninstall/detection marker for inline dispatcher snippets (hooks configs). */
16
20
  export declare const SNIPPET_MARKER = "heedvane-agent-hook-form";
17
21
  /** The connection's URL for an organization, as the dashboard builds it (`buildHeedvaneMcpUrl`). */
18
22
  export declare function connectorUrl(server: string, org: string): string;
23
+ /**
24
+ * The entry as drift checks compare it: a bridge's installing-Node folder is left out, since the launcher finds a Node
25
+ * without it, and connect run from another Node is no change to the connection.
26
+ */
27
+ export declare function comparableEntry(entry: unknown): unknown;
28
+ /** The variable a software factory holds its organization API key in; the dashboard's factory commands use it. */
29
+ export declare const ORG_KEY_ENV = "HEEDVANE_ORG_KEY";
30
+ /** The software-factory connection entry for this agent; undefined where it keeps the browser sign-in. */
31
+ export declare function keyConnectorEntry(clientId: string, url: string): Record<string, unknown> | undefined;
19
32
  /** The connection entry for this agent, or undefined when the installer does not register it there. `nodeDir` (POSIX only) is the installing Node's folder, for an entry that runs a local bridge. */
20
33
  export declare function connectorEntry(clientId: string, url: string, nodeDir?: string): Record<string, unknown> | undefined;
34
+ /** The organization an entry's Heedvane MCP URL names at `server`, if it points there. */
35
+ export declare function connectorOrganization(entry: unknown, server: string): string | undefined;
36
+ /** The strings an entry names a server URL with: any agent's URL field, or a bridge's arguments. */
37
+ export declare function entryUrls(entry: unknown): string[];
21
38
  /** Is this today's connection entry? Its name and a Heedvane MCP URL both have to match. */
22
39
  export declare function isConnectorEntry(name: string, entry: unknown): boolean;
23
40
  /** Is this an entry an earlier version wrote: the local helper under any name, or the connection under its earlier name? */
24
41
  export declare function isLegacyEntry(name: string, entry: unknown): boolean;
42
+ /** Does this entry run the local helper an earlier version registered, under any name? */
43
+ export declare function isHelperEntry(_name: string, entry: unknown): boolean;
25
44
  /** Is this server entry Heedvane's, today's or an earlier version's? Used by uninstall, detection and doctor. */
26
45
  export declare function isHeedvaneEntry(name: string, entry: unknown): boolean;
27
46
  /** Names of the entries `match` accepts in a parsed config's MCP containers, as `container::name`. */